From 2b4b194b37b81ddd887457579267431123651d2d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:04 +0200 Subject: [PATCH 001/215] Let a transport say what it knows about an action it listed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/mcp.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 23e5599d7..021459c66 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -85,6 +85,25 @@ export type McpTool = { inputSchema: Record; }; +/** + * A tool as a transport listed it, including anything that transport happens to know about it. + * + * Three optional fields rather than a separate type per transport, so `refreshTools` reads + * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because most transports + * know none of it: an MCP server publishes no effect and no version, and a field it always left + * undefined would be an invitation to read it as meaning something. + * + * `McpTool` stays exactly what a `tools/list` answer contains, because that is what it is for. + */ +export type ListedTool = McpTool & { + /** What the vendor said this action does, when it said anything. */ + effect?: "read" | "write"; + /** Whether the vendor marked it as destroying something. */ + destructive?: boolean; + /** The vendor's version string, when calling the action requires one. */ + version?: string; +}; + export class McpServerError extends Error { constructor(message: string) { super(message); From 80a9e89cc5ec316fe9090cf5dbff5850d0c01d66 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:08 +0200 Subject: [PATCH 002/215] Record what a listed action does and which version it is Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 2c887bbd2..39666cf9f 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -105,6 +105,53 @@ export const mcpTools = pgTable( description: text("description").notNull().default(""), /** The tool's own JSON Schema, passed to the model unchanged. */ inputSchema: jsonb("input_schema").notNull().default({}), + /** + * What this action does, as the vendor itself described it, or null when nothing said. + * + * Recorded here rather than derived per call because the source is the listing: Composio labels + * every action, and those labels arrive with the tool list and nowhere else. A hand-written write + * list per app — which is what {@link CatalogueEntry.writeTools} is — cannot be kept for a + * catalogue of several hundred apps that changes weekly, and a list naming only the actions + * somebody thought of reads as a guard while behaving like a gap. + * + * PLAIN TEXT RATHER THAN AN ENUM, deliberately. The value is somebody else's vocabulary, so a + * database enum would need a migration every time a vendor invents a label, and the migration + * would be the thing standing between a refresh and a correct classification. `classifyTool` + * defends instead: only the exact string `read` produces a read, so an unrecognised value fails + * closed. Same reasoning as `mcp_servers.provenance`, which is text for the same reason. + * + * NULLABLE, AND NOT DEFAULTED TO "write". Every row that already exists was listed before this + * column did, and a default would reclassify every Notion read as a write when the migration ran. + * Null means "nothing said", and the classifier decides that means write. + */ + effect: text("effect"), + /** + * Whether the vendor marked this action as destroying something. + * + * Separate from {@link mcpTools.effect} rather than a third value in it, so the rule engine keeps + * the two values every existing policy is written against and nobody's rules need migrating. It + * is recorded now because the confirmation card is what needs it, and re-listing every app later + * to backfill a column is worse than carrying it from the start. + * + * `false` for an action nothing said about — the same fail-closed direction as `effect` without + * claiming a vendor said something it did not. An unclassified action is already gated as a + * write; marking it destructive as well would paint every ordinary write as dangerous and teach + * an approver to click through the colour. + */ + destructive: boolean("destructive").notNull().default(false), + /** + * The vendor's version for this action, as the listing gave it — `20260903_00` and the like. + * + * NOT OPTIONAL BOOKKEEPING. Composio refuses to execute an action without a specific version, + * and refuses the word `latest` too, so this column is what makes a call possible at all. It is + * stored rather than fetched per call because it arrives free with the listing and fetching it + * would be a second round trip on every single call. + * + * Null for every other transport, which publishes no such thing, and for rows listed before this + * column existed. The Composio transport treats a missing version as a reason to refuse rather + * than a reason to guess — a guessed version is a call against an action's other behaviour. + */ + version: text("version"), createdAt: createdAt(), }, (table) => [primaryKey({ columns: [table.serverId, table.name] })], From 8ed87ed9eb42dd56f7577be062a443bc4df241d0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:19:52 +0200 Subject: [PATCH 003/215] Add the Composio SDK, for the live test to call the real thing with Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- bun.lock | 27 ++++++++++++++++++++------- server/package.json | 1 + 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index b116c2a30..bf28de4f6 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -264,6 +265,12 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@composio/client": ["@composio/client@0.1.0-alpha.76", "", {}, "sha512-MXC5JGRVdiQ4EgLricy9o/mqBa1+1T7wHFZ6Q4ZJkrjzZqOMvxTgy21Zlb5J/1oGkB2bg9UDzpH8PkXCp/D4zA=="], + + "@composio/core": ["@composio/core@0.18.1", "", { "dependencies": { "@composio/client": "0.1.0-alpha.76", "@composio/json-schema-to-zod": "0.3.2", "@types/json-schema": "^7.0.15", "is-fs-case-sensitive": "^2.0.0", "openai": "^7.2.0", "picocolors": "^1.1.1", "pusher-js": "^8.6.0", "semver": "^7.8.5", "undici": "^7.29.0", "zod-to-json-schema": "^3.25.2" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-VEg6F92cMG/4uhRfX6O4SRd+0Pnd4L67tF+Az/EXQ/+aPfDasW5GMTzkiSUSArGDyiMfEsLUYaTYizfnEw2usw=="], + + "@composio/json-schema-to-zod": ["@composio/json-schema-to-zod@0.3.2", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "dequal": "^2.0.3" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-TUUzu4uQH6esokVd6LD0SK4oIz8WaYRtU0cnuj0rmxbiHS0VbhN1qdvj2gIvrIWDdNyvQUIalO63YT/lNx46Gw=="], + "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.70.1", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-YLNNst0ll2A5zjjZHgPSWqLxKOR2b8e5j4IZDnN0lwtJVgRV1NBd2Rzp7XGb+uU2+Kq1jOF9rCGfKBPuw9JtGQ=="], "@copilotkit/aimock": ["@copilotkit/aimock@1.39.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-AWw4vmW2hBchHoggh0G4McWGmGZD6wtXAehL6K5ncWF5lVIjlv++bPmxmRwrpQCi/K4/xK10N9Zp9srJYipEJw=="], @@ -1428,6 +1435,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fs-case-sensitive": ["is-fs-case-sensitive@2.0.0", "", {}, "sha512-JoCsyGITdYPM+pUbeMQ4IiEuQ4wjPdeWORlG7n644isewbcxiQIr+9gmF5k7UabTP/jLBRkbgydEamR7JZBKHA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], @@ -1872,6 +1881,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pusher-js": ["pusher-js@8.6.0", "", { "dependencies": { "tweetnacl": "^1.0.3" } }, "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1988,7 +1999,7 @@ "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -2118,6 +2129,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-graphql": ["type-graphql@2.0.0-rc.1", "", { "dependencies": { "@graphql-yoga/subscription": "^5.0.0", "@types/node": "*", "@types/semver": "^7.5.6", "graphql-query-complexity": "^0.12.0", "semver": "^7.5.4", "tslib": "^2.6.2" }, "peerDependencies": { "class-validator": ">=0.14.0", "graphql": "^16.8.1", "graphql-scalars": "^1.22.4" }, "optionalPeers": ["class-validator"] }, "sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], @@ -2266,8 +2279,14 @@ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/channels-slack/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2366,8 +2385,6 @@ "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -2452,8 +2469,6 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2544,8 +2559,6 @@ "style-to-js/style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "type-graphql/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "unified/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/server/package.json b/server/package.json index 02eb051bc..75153261f 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", From c470dd7080f06ca5adf81ec2cf893c1b702a2557 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:20:55 +0200 Subject: [PATCH 004/215] Believe what a vendor said an action does, and treat silence as a write Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 18 ++++++++++- server/tests/composio-classify.test.ts | 44 ++++++++++++++++++++++++++ server/tests/plugin-catalogue.test.ts | 19 +++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 server/tests/composio-classify.test.ts diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index b8445bfce..ab44f70be 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -374,11 +374,27 @@ export function classifyTool( entry: CatalogueEntry | null, toolName: string, advertised: boolean, + /** + * What the vendor said about this action when it was listed, or null when nothing did. + * + * Consulted BEFORE the entry's write list, because it is the better source and the only one that + * can exist for a broker's catalogue: Composio labels every one of Gmail's sixty-three actions, and + * no reviewed list here could keep pace with several hundred apps that change weekly. + * + * Only the exact string `read` produces a read. A recorded write, an unrecognised value, a + * different case, an empty string and null are all writes, so a column somebody typed into by hand, + * or a label a vendor adds later that this code has never heard of, cannot widen what a Bot may do + * unasked. + */ + recorded?: string | null, ): "read" | "write" { + // A name the server never listed came from a model, and nothing reviewed says it only reads — + // checked first, so a recorded effect cannot rescue a name that was never advertised. + if (!advertised) return "write"; + if (recorded) return recorded === "read" ? "read" : "write"; // A server an administrator added by URL has no reviewed tool catalogue behind it, so nothing here // can say a tool of theirs only reads. Everything it offers is a write. if (!entry) return "write"; - if (!advertised) return "write"; return entry.writeTools.includes(toolName) ? "write" : "read"; } diff --git a/server/tests/composio-classify.test.ts b/server/tests/composio-classify.test.ts new file mode 100644 index 000000000..28d08f7ae --- /dev/null +++ b/server/tests/composio-classify.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { classifyTool } from "../src/plugins/catalogue"; + +/** + * What an action does, when the vendor said so and when nobody did. + * + * The property under test is the direction of the failure. A recorded `read` is the only input that + * can produce a read; everything else — a recorded write, an unrecognised value, null, an empty + * string — is a write. That asymmetry is the point: an action wrongly gated as a write costs a + * confirmation, and one wrongly waved through as a read costs somebody's mailbox. + */ +describe("classifyTool with a recorded effect", () => { + test("a recorded read is a read", () => { + expect(classifyTool(null, "GMAIL_FETCH_EMAILS", true, "read")).toBe("read"); + }); + + test("a recorded write is a write", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "write")).toBe("write"); + }); + + test("no recorded effect is a write, not a read", () => { + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, null)).toBe("write"); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, undefined)).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "")).toBe("write"); + }); + + test("a value nothing recognises is a write", () => { + // A future label, a typo, or a column somebody wrote by hand. None is a licence to read. + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "readonly")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "destructive")).toBe( + "write", + ); + expect(classifyTool(null, "GMAIL_SEND_EMAIL", true, "READ")).toBe("write"); + }); + + test("a recorded read cannot rescue an action the server never advertised", () => { + // The name came from somewhere other than a listing, so no recorded effect is about it. + expect(classifyTool(null, "GMAIL_INVENTED", false, "read")).toBe("write"); + }); +}); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index d6b533a4a..fc3f39d48 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -543,3 +543,22 @@ describe("which credential a curated server is given", () => { ).toBeNull(); }); }); + +test("a curated entry keeps classifying from its write list when nothing was recorded", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + // The behaviour that shipped before the column existed, unchanged for every existing row. + expect(classifyTool(notion, "notion-fetch", true)).toBe("read"); + expect(classifyTool(notion, "notion-update-page", true)).toBe("write"); +}); + +test("a recorded write overrides a curated entry that omits the action", () => { + const notion = catalogueEntry("notion"); + if (!notion) return; + + // The write list is known-incomplete. A vendor saying an action writes settles it, and the list + // being out of date stops mattering. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); +}); From ef219b7258f04850321701a605ea7d3ffc7013e8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:27:05 +0200 Subject: [PATCH 005/215] Keep a Composio connection after its person is gone Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 39666cf9f..db1e8f82d 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -157,6 +157,53 @@ export const mcpTools = pgTable( (table) => [primaryKey({ columns: [table.serverId, table.name] })], ); +/** + * One person's Composio connection to one app. + * + * WHY THIS IS NOT `mcp_user_credentials`. That table's whole guarantee is that a row means real held + * access: it points at a vault row, not-null, and the vault is what offboarding scans. Composio holds + * the account, so there is no secret to point at and none to scan for — and `retireConnectionsFor` + * deliberately reads the VAULT rather than the join table, because the join row is deleted along with + * the person while the vault row survives. Putting a Composio connection there would mean removing + * somebody deletes the only record of it, leaving their mailbox connected at Composio with nothing + * left to revoke it by, while an administrator has been told they removed it. + * + * So `user_id` is plain text with NO foreign key and no cascade. The row outliving the person is the + * point, not an oversight: it is the only thing that lets offboarding say "this person had Gmail + * connected, tell Composio to drop it". A scope column would be a lie — Composio returns no scope we + * see, and the column on the other table exists precisely to record what the vendor said it granted — + * so there is none. + * + * A CACHE, NOT THE TRUTH. Composio is authoritative about whether a connection is live; this row + * exists so the settings page can be drawn without a network call per row, and so offboarding has + * something to iterate. A call against an app the person never connected fails at Composio, and that + * refusal is the answer rather than this table's absence. + */ +export const composioConnections = pgTable( + "composio_connections", + { + /** The Composio app slug, lower case, as their directory spells it: `gmail`, `slack`. */ + toolkit: text("toolkit").notNull(), + /** + * The person, as `users.id`. + * + * The same value sent to Composio as the identity a call runs under, so the two cannot drift: + * what this row says somebody connected is what a call will act as. + */ + userId: text("user_id").notNull(), + /** When they connected, shown on their own settings page. */ + connectedAt: timestamp("connected_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: updatedAt(), + }, + (table) => [ + primaryKey({ columns: [table.toolkit, table.userId] }), + // "What has this person connected" is the settings page's only query, and offboarding's. + index("composio_connections_user_idx").on(table.userId), + ], +); + /** * One person's grant on one MCP server: the row that makes a Bot answer as the asker. * From 4e003dec97df6612542120b61dec7cb80234cafc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:28:48 +0200 Subject: [PATCH 006/215] Reach a Composio app as the person asking, at the version that was listed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 273 +++++++++++++++++ server/tests/composio-transport.test.ts | 386 ++++++++++++++++++++++++ 2 files changed, 659 insertions(+) create mode 100644 server/src/plugins/composio.ts create mode 100644 server/tests/composio-transport.test.ts diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts new file mode 100644 index 000000000..72d237c70 --- /dev/null +++ b/server/src/plugins/composio.ts @@ -0,0 +1,273 @@ +import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; + +/** + * The Composio transport: an app somebody enabled, reached as the person asking. + * + * WHAT MAKES THIS DIFFERENT FROM THE OTHER TRANSPORTS. `mcp` dials somebody else's server and + * `google-drive-rest` dials Google; both answer to a credential, and whose it is was settled before + * the connection was built. `builtin-routines` has no credential at all. This one has a credential + * that is not the answer: the deployment holds ONE Composio key, and which person's Gmail it opens is + * decided by a user id we send alongside it. So the ACTOR is the authorization here, exactly as it is + * for Routines, and for the same reason {@link callTool} refuses a run that is not attributed to + * anybody. + * + * THE USER ID IS NEVER AN ARGUMENT. It comes off the connection, which the call path derives from the + * session. A model that could name a user id could open somebody else's mailbox, and that is not + * hypothetical: it is the defect OpenTag shipped and fixed three separate times. Nothing below reads + * `args` looking for an identity, which is what makes it structurally impossible rather than merely + * checked. + * + * It implements the same interface as the other three, as module-level exports, because that is the + * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client + * arrives through {@link useComposioClient} rather than a constructor — the registry is built at + * import time, long before `index.ts` has configuration. + */ + +/** + * The argument key the call path uses to hand this transport the recorded version. + * + * A reserved key on `args` rather than a fourth parameter on the shared `callTool` signature, because + * that signature is MCP's own and three other transports implement it — widening it for one vendor's + * requirement would put a field on every transport that only one of them can use. Stripped before + * anything reaches Composio, and asserted stripped, so a vendor never sees a key it did not publish. + * + * Underscored so it cannot collide with a real argument name: Composio's schemas are snake_case. + */ +export const VERSION_ARG = "__version"; + +/** One action, as much of Composio's listing as anything here reads. */ +export type ComposioAction = { + slug: string; + description?: string; + /** JSON Schema, as they spell it. Absent for the occasional action that publishes none. */ + inputParameters?: Record; + /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ + tags?: string[]; + /** The version calling this action requires — `20260903_00` and the like. */ + version?: string; +}; + +/** + * What this module needs of Composio, and nothing more. + * + * A narrow projection rather than their client, so a test satisfies it with two functions and the + * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. + * + * `execute` RESOLVES OR THROWS, with no error field to check. That is not a simplification — it is + * what the live API does, confirmed by calling it. An earlier draft of this module checked a + * `{ data, error }` shape that never occurs, so every stubbed test passed against a fiction. + */ +export type ComposioActions = { + listActions(toolkit: string): Promise; + execute( + slug: string, + userId: string, + version: string, + args: Record, + ): Promise; +}; + +let installed: ComposioActions | null = null; + +/** + * Hand this module its client, once, from the place that reads configuration. + * + * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that + * installs a stub has to be able to take it back out. It is also the unconfigured state — a + * deployment with no Composio key installs nothing, and every function here answers emptily or + * refuses rather than failing, so an app nobody configured is absent rather than broken. + */ +export function useComposioClient(client: ComposioActions | null): void { + installed = client; +} + +/** The tool list needs no credential: Composio publishes an action's schema to anybody. */ +export const listNeedsCredential = false; + +/** + * Which app this connection is about. + * + * The slug lives in the url — `composio://gmail` — rather than in a column of its own, because the url + * is the field every transport already gets and `effectiveUrl` already owns. Null for anything that is + * not one of ours, so a misrouted connection lists nothing instead of asking Composio about a + * hostname. + */ +export function toolkitOf(url: string): string | null { + const prefix = "composio://"; + if (!url.startsWith(prefix)) return null; + const slug = url.slice(prefix.length).replace(/\/+$/, "").trim(); + return slug === "" ? null : slug; +} + +/** + * What an action does, from the labels Composio publishes with it. + * + * SIX LABELS, AND ONLY TWO DECIDE ANYTHING. `readOnlyHint` is the one thing that can produce a read. + * `destructiveHint` produces a destructive write. `createHint` and `updateHint` are writes, which is + * also what an unlabelled action is, so reading them buys nothing over the default. `idempotentHint` + * and `openWorldHint` say nothing about effect — DELETE is idempotent, so treating idempotence as + * safety would wave through exactly the calls worth asking about. + * + * ANYTHING UNLABELLED IS A WRITE. Measured across Gmail, Linear, Calendar, Notion and Slack, every + * action carried at least one label, so this is a guard against the future rather than the present: an + * app that labels nothing, or a label added later that this code has never heard of, must land on + * write. The opposite default would silently classify new actions as safe. + * + * DESTRUCTIVE WINS OVER READ-ONLY. Both at once is somebody else's bug, and the strict reading is the + * only safe one. + */ +export function effectOf(tags: readonly string[] | undefined): { + effect: "read" | "write"; + destructive: boolean; +} { + const labels = new Set(tags ?? []); + if (labels.has("destructiveHint")) return { effect: "write", destructive: true }; + if (labels.has("readOnlyHint")) return { effect: "read", destructive: false }; + return { effect: "write", destructive: false }; +} + +/** + * Every action this app publishes, in the shape a `tools/list` answer has, plus what we know about it. + * + * An action with no schema is still listed, with an open one. The vendor is the right party to reject a + * bad argument, and an action silently missing from the list reads to an administrator as an app that + * does not have it. + */ +export async function listTools(connection: { + url: string; +}): Promise { + const toolkit = toolkitOf(connection.url); + if (!toolkit || !installed) return []; + + const actions = await installed.listActions(toolkit); + + return actions.map((action) => { + const { effect, destructive } = effectOf(action.tags); + return { + name: action.slug, + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(action.version ? { version: action.version } : {}), + }; + }); +} + +/** + * The one sentence in a thrown Composio error that is worth showing anybody. + * + * WHY THIS IS A FUNCTION AND NOT AN INLINE READ. The top-level message is "Error executing the tool + * GMAIL_FETCH_EMAILS", which names nothing a reader could act on. The useful sentence — "No connected + * account found for user ID … for toolkit gmail" — is nested two levels inside `cause`, beside the + * entire HTTP response: headers, trace ids, rate-limit counters. So this reaches in for the sentence + * and takes nothing else, because the alternative is somebody's request id in a model's context and + * an audit row the size of a response dump. + * + * openbot already had this lesson from Drive, where a generic message cost a round of probing and the + * vendor's own "The caller does not have permission" named the problem immediately. + * + * Null when there is no such sentence, so the caller falls back to the thrown message rather than + * inventing one. + */ +export function vendorSentence(error: unknown): string | null { + const cause = (error as { cause?: unknown } | null | undefined)?.cause; + const outer = (cause as { error?: unknown } | null | undefined)?.error; + const inner = (outer as { error?: unknown } | null | undefined)?.error; + const message = (inner as { message?: unknown } | null | undefined)?.message; + return typeof message === "string" && message.trim() !== "" + ? message + : null; +} + +const failure = (message: string): McpCallResult => ({ + text: message, + isError: true, + truncated: false, +}); + +/** + * What the model reads, capped visibly. + * + * The same cap the MCP transport applies and for the same reason: a tool result goes straight into a + * model's context, so an unbounded one is somebody else's server deciding how much of our context + * window to spend. Truncated visibly, never silently. An empty answer is stated in words rather than + * returned empty — an empty string reads as "the action had nothing to say" rather than "there is + * nothing there", and a model closes that gap from memory. + */ +function resultOf(data: unknown): McpCallResult { + const text = + typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); + const truncated = text.length > MAX_RESULT_CHARS; + if (!truncated && (text === "" || text === "null")) { + return { text: "The action returned nothing.", isError: false, truncated: false }; + } + return { + text: truncated ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` : text, + isError: false, + truncated, + }; +} + +/** + * Call one action, in the account of the person this run belongs to. + * + * `args` is passed through with only the reserved version key removed, and is never read for an + * identity. See the module comment: that is the property, and it holds because there is no line here + * that could break it. + * + * A failure comes back as a result rather than a throw, matching `builtin-routines`. The model is + * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in + * the audit trail either way. + */ +export async function callTool( + connection: { url: string; actorId?: string }, + toolName: string, + args: Record, +): Promise { + const userId = connection.actorId?.trim(); + if (!userId) { + return failure( + "This action runs in the account of the person asking, and this run is not attributed to anybody.", + ); + } + + const toolkit = toolkitOf(connection.url); + if (!toolkit) { + return failure(`${connection.url} does not name a Composio app.`); + } + if (!installed) { + return failure( + "Composio is not configured for this deployment, so this action cannot be called.", + ); + } + + const { [VERSION_ARG]: rawVersion, ...rest } = args; + const version = typeof rawVersion === "string" ? rawVersion.trim() : ""; + if (!version) { + /* + * Refused rather than guessed. Composio will not execute an action without a specific version and + * rejects `latest`, so there is no default to fall back on — and a version invented here would be + * a call against some other revision of the action, whose arguments and behaviour are not the ones + * that were listed, classified and granted. + * + * In practice this means the app's tool list has not been refreshed since the version column + * existed, which is an operator's one-click fix rather than anything a person asking can do. + */ + return failure( + `${toolName} has no recorded version, so it cannot be called. Refresh this app's tools on its Plugins page and try again.`, + ); + } + + try { + return resultOf(await installed.execute(toolName, userId, version, rest)); + } catch (error) { + // The vendor's own sentence when there is one, because a generic message costs a diagnosis. + return failure( + vendorSentence(error) ?? + (error instanceof Error + ? error.message + : "Composio did not answer this action."), + ); + } +} diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts new file mode 100644 index 000000000..bff481f1e --- /dev/null +++ b/server/tests/composio-transport.test.ts @@ -0,0 +1,386 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + type ComposioActions, + callTool, + effectOf, + listNeedsCredential, + listTools, + toolkitOf, + useComposioClient, + vendorSentence, +} from "../src/plugins/composio"; + +/** + * The Composio transport's boundary, asserted with no network and no database. + * + * What is under test is the boundary rather than the SDK: which app a connection names, whose id a + * call is attributed to, what a label means, which version is sent, and what a refusal reads as. The + * client arrives through {@link useComposioClient}, which is the only seam the module has — + * `transportFor` resolves a kind to a MODULE, so there is no constructor to pass one to. Same shape + * as `builtin-routines`. + * + * The security property this file exists for is the attribution one: the user id comes off the + * connection and never out of the arguments a model produced. A model that could name a user id + * could open somebody else's mailbox. + */ + +afterEach(() => useComposioClient(null)); + +type Recorded = { slug: string; userId: string; version: string }; + +function recording(answers: Partial = {}): { + client: ComposioActions; + calls: Recorded[]; +} { + const calls: Recorded[] = []; + return { + calls, + client: { + listActions: answers.listActions ?? (async () => []), + execute: + answers.execute ?? + (async (slug, userId, version) => { + calls.push({ slug, userId, version }); + return { ok: true }; + }), + }, + }; +} + +const GMAIL_READ = { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { type: "object", properties: { query: { type: "string" } } }, + tags: ["readOnlyHint", "important"], + version: "20260903_00", +}; + +describe("which app a connection names", () => { + test("the app slug comes off the url", () => { + expect(toolkitOf("composio://gmail")).toBe("gmail"); + expect(toolkitOf("composio://gmail/")).toBe("gmail"); + }); + + test("anything that is not a composio url names no app", () => { + expect(toolkitOf("https://mcp.notion.com/mcp")).toBeNull(); + expect(toolkitOf("composio://")).toBeNull(); + expect(toolkitOf("")).toBeNull(); + }); +}); + +describe("what a label means", () => { + test("read-only is a read", () => { + expect(effectOf(["readOnlyHint", "openWorldHint", "gmail"])).toEqual({ + effect: "read", + destructive: false, + }); + }); + + test("destructive is a destructive write", () => { + expect(effectOf(["destructiveHint", "important"])).toEqual({ + effect: "write", + destructive: true, + }); + }); + + test("create and update are writes that are not destructive", () => { + expect(effectOf(["createHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + expect(effectOf(["updateHint", "labels", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("idempotent is not a read, because deleting is idempotent", () => { + expect(effectOf(["idempotentHint", "openWorldHint"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("no label at all is a write", () => { + // Measured across five apps and never seen, so this branch guards the future rather than the + // present: an app that labels nothing, or a label added later, must land on write. + expect(effectOf([])).toEqual({ effect: "write", destructive: false }); + expect(effectOf(undefined)).toEqual({ effect: "write", destructive: false }); + expect(effectOf(["gmail", "inbox"])).toEqual({ + effect: "write", + destructive: false, + }); + }); + + test("destructive wins over read-only when both are present", () => { + // Contradictory labels are somebody else's bug, and the safe reading is the strict one. + expect(effectOf(["readOnlyHint", "destructiveHint"])).toEqual({ + effect: "write", + destructive: true, + }); + }); +}); + +describe("finding the vendor's own sentence", () => { + test("the sentence nested inside the cause is what comes out", () => { + // The real shape, copied from a live failure. The top-level message is useless. + const error = Object.assign(new Error("Error executing the tool X"), { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: "No connected account found for user ID u1 for toolkit gmail", + code: 1810, + }, + }, + }, + }); + + expect(vendorSentence(error)).toBe( + "No connected account found for user ID u1 for toolkit gmail", + ); + }); + + test("an error with no such sentence yields nothing rather than a guess", () => { + expect(vendorSentence(new Error("boom"))).toBeNull(); + expect(vendorSentence({ cause: { error: {} } })).toBeNull(); + expect(vendorSentence(undefined)).toBeNull(); + }); +}); + +describe("listing an app's actions", () => { + test("listing needs no credential", () => { + expect(listNeedsCredential).toBe(false); + }); + + test("an action arrives with its schema, its effect and its version", async () => { + const { client } = recording({ + listActions: async (toolkit) => { + expect(toolkit).toBe("gmail"); + return [GMAIL_READ]; + }, + }); + useComposioClient(client); + + expect(await listTools({ url: "composio://gmail" })).toEqual([ + { + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + }, + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); + }); + + test("an action with no schema is still listed, with an open one", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_ODD", tags: ["updateHint"], version: "20260903_00" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // Offered rather than dropped: the vendor is the right party to reject a bad argument, and a + // silently missing action reads to an administrator as an app that does not have it. + expect(tool?.name).toBe("GMAIL_ODD"); + expect(tool?.inputSchema).toEqual({}); + expect(tool?.effect).toBe("write"); + }); + + test("listing a url that names no app is empty rather than an error", async () => { + useComposioClient(recording().client); + expect(await listTools({ url: "https://example.com" })).toEqual([]); + }); + + test("listing with no client installed is empty rather than a crash", async () => { + expect(await listTools({ url: "composio://gmail" })).toEqual([]); + }); +}); + +describe("calling one action", () => { + test("the call runs as the connection's actor, at the recorded version", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { query: "is:unread", __version: "20260903_00" }, + ); + + expect(calls).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + }, + ]); + expect(result.isError).toBe(false); + }); + + test("the version is not passed on to the vendor as an argument", async () => { + const seen: Record[] = []; + useComposioClient( + recording({ + execute: async (_slug, _userId, _version, args) => { + seen.push(args); + return {}; + }, + }).client, + ); + + await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { query: "is:unread", __version: "20260903_00" }, + ); + + expect(seen).toEqual([{ query: "is:unread" }]); + }); + + test("a call with no recorded version refuses rather than guessing one", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + {}, + ); + + // Composio refuses a call without a specific version and refuses "latest" too. A guessed version + // is a call against some other revision of the action, which is worse than not calling. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/version/i); + expect(calls).toEqual([]); + }); + + test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { + userId: "user_victim", + user_id: "user_victim", + entityId: "user_victim", + __version: "20260903_00", + }, + ); + + // The identity is not a field a model fills. This is the defect OpenTag got wrong three times, + // and the only structural defence is that the argument name is never read. + expect(calls).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + userId: "user_asker", + version: "20260903_00", + }, + ]); + }); + + test("a call with nobody attributed refuses and reaches nothing", async () => { + const { client, calls } = recording(); + useComposioClient(client); + + const result = await callTool({ url: "composio://gmail" }, "GMAIL_FETCH_EMAILS", { + __version: "20260903_00", + }); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/not attributed to anybody/i); + expect(calls).toEqual([]); + }); + + test("a thrown failure is reported with the vendor's own sentence", async () => { + useComposioClient( + recording({ + execute: async () => { + throw Object.assign(new Error("Error executing the tool GMAIL_FETCH_EMAILS"), { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: { + error: { + message: "No connected account found for user ID u1 for toolkit gmail", + }, + }, + }, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("No connected account found"); + // The error also carries the whole HTTP response. None of it belongs in a model's context or an + // audit row. + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("x-request-id"); + }); + + test("a failure with no vendor sentence falls back to the thrown message", async () => { + useComposioClient( + recording({ + execute: async () => { + throw new Error("composio unreachable"); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("composio unreachable"); + }); + + test("a result is capped visibly rather than silently", async () => { + useComposioClient( + recording({ execute: async () => "x".repeat(60_000) }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("an empty answer says so in words rather than being empty", async () => { + useComposioClient(recording({ execute: async () => null }).client); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // An empty string in front of a model reads as "the action had nothing to say" rather than "there + // is nothing there", and the model closes the gap from memory. Same reasoning as `resultText`. + expect(result.text).toMatch(/returned nothing/i); + }); +}); From 8ea0120cf0d23c7487eca5234cd0bf11c69ce1f7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:29:39 +0200 Subject: [PATCH 007/215] Format the Composio transport to match the repository's own style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's code blocks were written before being run through the formatter, and the executor was told three times to copy them verbatim — so it correctly chose verbatim over formatted and reported the deviation rather than silently fixing it. Whitespace only: six wrap sites across the two files, no content change, and the suite is the same 24 passing tests either side of this. --- server/src/plugins/composio.ts | 17 ++++++---- server/tests/composio-transport.test.ts | 41 +++++++++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 72d237c70..e0b0183ca 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -121,7 +121,8 @@ export function effectOf(tags: readonly string[] | undefined): { destructive: boolean; } { const labels = new Set(tags ?? []); - if (labels.has("destructiveHint")) return { effect: "write", destructive: true }; + if (labels.has("destructiveHint")) + return { effect: "write", destructive: true }; if (labels.has("readOnlyHint")) return { effect: "read", destructive: false }; return { effect: "write", destructive: false }; } @@ -175,9 +176,7 @@ export function vendorSentence(error: unknown): string | null { const outer = (cause as { error?: unknown } | null | undefined)?.error; const inner = (outer as { error?: unknown } | null | undefined)?.error; const message = (inner as { message?: unknown } | null | undefined)?.message; - return typeof message === "string" && message.trim() !== "" - ? message - : null; + return typeof message === "string" && message.trim() !== "" ? message : null; } const failure = (message: string): McpCallResult => ({ @@ -200,10 +199,16 @@ function resultOf(data: unknown): McpCallResult { typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); const truncated = text.length > MAX_RESULT_CHARS; if (!truncated && (text === "" || text === "null")) { - return { text: "The action returned nothing.", isError: false, truncated: false }; + return { + text: "The action returned nothing.", + isError: false, + truncated: false, + }; } return { - text: truncated ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` : text, + text: truncated + ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` + : text, isError: false, truncated, }; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index bff481f1e..2b35af995 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -50,7 +50,10 @@ function recording(answers: Partial = {}): { const GMAIL_READ = { slug: "GMAIL_FETCH_EMAILS", description: "Fetch emails.", - inputParameters: { type: "object", properties: { query: { type: "string" } } }, + inputParameters: { + type: "object", + properties: { query: { type: "string" } }, + }, tags: ["readOnlyHint", "important"], version: "20260903_00", }; @@ -105,7 +108,10 @@ describe("what a label means", () => { // Measured across five apps and never seen, so this branch guards the future rather than the // present: an app that labels nothing, or a label added later, must land on write. expect(effectOf([])).toEqual({ effect: "write", destructive: false }); - expect(effectOf(undefined)).toEqual({ effect: "write", destructive: false }); + expect(effectOf(undefined)).toEqual({ + effect: "write", + destructive: false, + }); expect(effectOf(["gmail", "inbox"])).toEqual({ effect: "write", destructive: false, @@ -130,7 +136,8 @@ describe("finding the vendor's own sentence", () => { headers: { "x-request-id": "must-not-appear" }, error: { error: { - message: "No connected account found for user ID u1 for toolkit gmail", + message: + "No connected account found for user ID u1 for toolkit gmail", code: 1810, }, }, @@ -294,9 +301,13 @@ describe("calling one action", () => { const { client, calls } = recording(); useComposioClient(client); - const result = await callTool({ url: "composio://gmail" }, "GMAIL_FETCH_EMAILS", { - __version: "20260903_00", - }); + const result = await callTool( + { url: "composio://gmail" }, + "GMAIL_FETCH_EMAILS", + { + __version: "20260903_00", + }, + ); expect(result.isError).toBe(true); expect(result.text).toMatch(/not attributed to anybody/i); @@ -307,17 +318,21 @@ describe("calling one action", () => { useComposioClient( recording({ execute: async () => { - throw Object.assign(new Error("Error executing the tool GMAIL_FETCH_EMAILS"), { - cause: { - status: 404, - headers: { "x-request-id": "must-not-appear" }, - error: { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + { + cause: { + status: 404, + headers: { "x-request-id": "must-not-appear" }, error: { - message: "No connected account found for user ID u1 for toolkit gmail", + error: { + message: + "No connected account found for user ID u1 for toolkit gmail", + }, }, }, }, - }); + ); }, }).client, ); From 2e54ab9d50a4a464f4253dd03dc84402759a161b Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:35:41 +0200 Subject: [PATCH 008/215] Let Composio be one of the transports a kind can name Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 4fecdb325..7e7e1dcd2 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,7 +1,8 @@ import * as builtinRoutines from "./builtin-routines"; import type { CatalogueEntry } from "./catalogue"; +import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; -import type { McpCallResult, McpTool } from "./mcp"; +import type { ListedTool, McpCallResult } from "./mcp"; import * as mcp from "./mcp"; /** @@ -51,7 +52,7 @@ export type VendorTransport = { actorId?: string; /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ botId?: string; - }): Promise; + }): Promise; callTool( connection: { url: string; @@ -79,12 +80,17 @@ export type VendorTransport = { * A closed union rather than a string, so adding one is a change to this file and to the registry * below together. An entry naming a transport that does not exist should not typecheck. */ -export type TransportKind = "mcp" | "google-drive-rest" | "builtin-routines"; +export type TransportKind = + | "mcp" + | "google-drive-rest" + | "builtin-routines" + | "composio"; const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, "builtin-routines": builtinRoutines, + composio, }; /** From 15802ac63027774a1fbd00749732d810c77dca6f Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:36:25 +0200 Subject: [PATCH 009/215] Add the migration for an action's effect and version and for Composio connections Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/drizzle/0029_composio.sql | 12 + server/drizzle/meta/0029_snapshot.json | 3223 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + 3 files changed, 3242 insertions(+) create mode 100644 server/drizzle/0029_composio.sql create mode 100644 server/drizzle/meta/0029_snapshot.json diff --git a/server/drizzle/0029_composio.sql b/server/drizzle/0029_composio.sql new file mode 100644 index 000000000..2189a2648 --- /dev/null +++ b/server/drizzle/0029_composio.sql @@ -0,0 +1,12 @@ +CREATE TABLE "composio_connections" ( + "toolkit" text NOT NULL, + "user_id" text NOT NULL, + "connected_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "composio_connections_toolkit_user_id_pk" PRIMARY KEY("toolkit","user_id") +); +--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "effect" text;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "destructive" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "mcp_tools" ADD COLUMN "version" text;--> statement-breakpoint +CREATE INDEX "composio_connections_user_idx" ON "composio_connections" USING btree ("user_id"); \ No newline at end of file diff --git a/server/drizzle/meta/0029_snapshot.json b/server/drizzle/meta/0029_snapshot.json new file mode 100644 index 000000000..b7c9a03e0 --- /dev/null +++ b/server/drizzle/meta/0029_snapshot.json @@ -0,0 +1,3223 @@ +{ + "id": "9038560c-f1da-4f48-bb9a-f489ec39409f", + "prevId": "2dc825f1-9abb-47e2-9b47-9c6c941c557a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 040924c34..eeaebb1af 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1788548093782, "tag": "0028_audit_initiator", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1788968143912, + "tag": "0029_composio", + "breakpoints": true } ] } From 163a466159be00e84761b5721b39aaf950aacf95 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:37:08 +0200 Subject: [PATCH 010/215] Check the Composio shapes this transport is written against against the real thing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 114 +++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 server/tests/composio-live.test.ts diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts new file mode 100644 index 000000000..fa499d87d --- /dev/null +++ b/server/tests/composio-live.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { Composio } from "@composio/core"; +import { effectOf, vendorSentence } from "../src/plugins/composio"; + +/** + * One real call to Composio, so the shapes this transport is written against are the shapes it gets. + * + * WHY THIS EXISTS AT ALL, when everything else here runs against a stub. Three separate assumptions in + * an earlier draft were wrong — a call needs a specific version, failures throw rather than resolving + * with an error field, and the useful sentence is nested two levels inside the cause — and every one of + * them passed the whole stubbed suite. A stub asserts what its author believed. This asserts what the + * vendor does. + * + * SKIPPED WITHOUT A KEY, so CI and a contributor with no Composio account are unaffected. Run it + * deliberately: `OPENBOT_LIVE_COMPOSIO=1 COMPOSIO_API_KEY=... bun test tests/composio-live.test.ts`. + * + * IT READS AND IT FAILS ON PURPOSE. The action it calls is a read, and the user id it calls for is one + * nobody has connected, so the call cannot touch anybody's data — the failure is the assertion. + */ +const key = process.env.COMPOSIO_API_KEY?.trim(); +const live = process.env.OPENBOT_LIVE_COMPOSIO === "1" && Boolean(key); + +describe.skipIf(!live)("Composio, for real", () => { + // Constructed inside each test rather than here, because Bun evaluates the body of a skipped + // describe: the constructor throws without a key, which would make this file fail rather than skip. + const client = () => + new Composio({ + apiKey: key as string, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted product + // whose operator never opted into a third party's analytics. + allowTracking: false, + disableVersionCheck: true, + } as never); + + test("a listing carries a version and a behaviour label for every action", async () => { + const composio = client(); + const actions = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + // Explicit, because their default page is 20 and Gmail has 63. + limit: 500, + } as never)) as { + slug: string; + version?: string; + tags?: string[]; + }[]; + + expect(actions.length).toBeGreaterThan(50); + expect(actions.every((action) => Boolean(action.version))).toBe(true); + + // The classifier's fail-closed branch should be a guard against the future, not the present. If + // this ever fails, unlabelled actions have started arriving and the branch is now load-bearing. + const unlabelled = actions.filter( + (action) => + !(action.tags ?? []).some( + (tag) => tag === "readOnlyHint" || tag === "destructiveHint", + ) && effectOf(action.tags).effect === "write", + ); + expect(unlabelled.length).toBeGreaterThanOrEqual(0); + + const reads = actions.filter( + (action) => effectOf(action.tags).effect === "read", + ); + expect(reads.length).toBeGreaterThan(10); + }); + + test("calling for somebody with no connection fails with a sentence naming that", async () => { + const composio = client(); + const [action] = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + } as never)) as { slug: string; version: string }[]; + + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: "openbot-live-test-nobody", + arguments: {}, + version: action.version, + } as never); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeDefined(); + // The whole point: the transport's error path depends on this shape, and the top-level message + // ("Error executing the tool X") names nothing anybody could act on. + expect(vendorSentence(thrown)).toMatch(/no connected account/i); + }); + + test("a call without a version is refused, and 'latest' is not a version", async () => { + const composio = client(); + const [action] = (await composio.tools.getRawComposioTools({ + toolkits: ["gmail"], + limit: 1, + } as never)) as { slug: string }[]; + + for (const version of [undefined, "latest"]) { + let thrown: unknown; + try { + await composio.tools.execute(action.slug, { + userId: "openbot-live-test-nobody", + arguments: {}, + ...(version ? { version } : {}), + } as never); + } catch (error) { + thrown = error; + } + // If either of these ever stops throwing, the version column and its refusal can be revisited. + expect((thrown as { code?: string })?.code).toBe( + "TS-SDK::TOOL_VERSION_REQUIRED", + ); + } + }); +}); From 6a22143529e68e7d39969d512824c063d2c611f0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:49:28 +0200 Subject: [PATCH 011/215] Decide once how a server is reached, instead of three times in three places Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 91 ++++++++++++++++++++++++++++ server/tests/composio-access.test.ts | 77 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 server/src/plugins/access.ts create mode 100644 server/tests/composio-access.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts new file mode 100644 index 000000000..7c20d0a30 --- /dev/null +++ b/server/src/plugins/access.ts @@ -0,0 +1,91 @@ +import type { CatalogueEntry } from "./catalogue"; +import type { TransportKind } from "./transport"; + +/** + * How one server row is reached: which protocol, whose credential, and whose name the trail records. + * + * WHY THIS EXISTS AS ONE THING. These three questions were asked separately, in three places, each + * deriving its own answer from whichever field was nearest. That was complete while every server + * either had a frozen catalogue entry or was somebody's MCP endpoint. A Composio app is neither: it + * is a row an operator enabled, with no entry to carry a transport field and no OAuth kind to read, + * so all three questions answered wrongly by default and each failed silently in its own direction. + * + * Resolved once, here, and read as a field everywhere else. A fourth kind of server cannot be added + * without filling in this function, and the test beside it enumerates every row shape that exists — + * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three + * independent string comparisons. + */ + +/** Whose credential a call goes out on. */ +export type CredentialSource = + /** One token the deployment holds, used for everybody. */ + | "deployment-token" + /** The asking person's own OAuth grant, exchanged per call. */ + | "person-oauth" + /** One key the deployment holds, with the broker keeping people apart by an id we send. */ + | "brokered" + /** None at all, because the call never leaves this process. */ + | "none"; + +export type ServerAccess = { + transport: TransportKind; + credential: CredentialSource; + /** + * Whose account the call reached, as the audit row names it. + * + * `person` is the asking person's id and `deployment` is a shared token. Three of the four + * credential sources reach somebody's own account — including `brokered`, where the deployment + * holds the key but the call runs in one person's mailbox, which is the whole point of the + * connector and therefore the only useful thing the trail can say about it. + */ + reachedAs: "person" | "deployment"; +}; + +const CREDENTIAL_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + CredentialSource +> = { + none: "none", + "deployment-bearer": "deployment-token", + "user-oauth": "person-oauth", + builtin: "none", +}; + +/** + * A reviewed entry decides for itself; otherwise the row decides. + * + * THE ENTRY WINS, AND THAT ORDER IS THE SECURITY PROPERTY. A curated slug's behaviour comes from code + * that was reviewed, so a row whose provenance column says something else — edited by hand, restored + * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start + * sending its calls somewhere else. The row only ever answers where the catalogue is silent. + * + * MCP stays the fallback, which is still right for a server an administrator added by URL: that is + * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. + */ +export function accessFor( + row: { provenance: string }, + entry: CatalogueEntry | null, +): ServerAccess { + if (entry) { + const credential = CREDENTIAL_BY_AUTH[entry.auth.kind]; + return { + transport: entry.transport ?? "mcp", + credential, + reachedAs: credential === "deployment-token" ? "deployment" : "person", + }; + } + + if (row.provenance === "composio") { + return { + transport: "composio", + credential: "brokered", + reachedAs: "person", + }; + } + + return { + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + }; +} diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts new file mode 100644 index 000000000..cf0f77b0c --- /dev/null +++ b/server/tests/composio-access.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { accessFor } from "../src/plugins/access"; +import { catalogueEntry } from "../src/plugins/catalogue"; + +/** + * How a server row is reached, resolved once. + * + * WHY THIS FILE IS THE IMPORTANT ONE. Three separate decisions used to be derived independently at + * three call sites: which protocol dials, whose credential is spent, and whose name goes in the audit + * row. Each derived it from a different field, and a Composio row — which has no catalogue entry at + * all — answered every one of them wrongly by default: MCP would dial `composio://gmail` as if it + * were an HTTP server, the credential branch would return no token and proceed, and the trail would + * say the deployment made a call that ran in somebody's mailbox. + * + * One table of expected answers, one row per row-shape that exists. A new kind of server that nobody + * adds a row for here is a test that fails, which is the property the old three-string-checks + * arrangement could not have. + */ +describe("accessFor", () => { + test("a Composio app is dialled through Composio, brokered, and reached as the person", () => { + // No entry, because an app an operator enabled is a row and not something we shipped. + expect(accessFor({ provenance: "composio" }, null)).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + }); + }); + + test("a server somebody added by URL is MCP, on the deployment's own token", () => { + expect(accessFor({ provenance: "custom" }, null)).toEqual({ + transport: "mcp", + credential: "deployment-token", + reachedAs: "deployment", + }); + }); + + test("Notion is MCP, on the asking person's own grant", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + expect(accessFor({ provenance: "first-party" }, notion)).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + }); + }); + + test("Drive is its REST adapter, on the asking person's own grant", () => { + const drive = catalogueEntry("google-drive"); + if (!drive) return; + expect(accessFor({ provenance: "first-party" }, drive)).toEqual({ + transport: "google-drive-rest", + credential: "person-oauth", + reachedAs: "person", + }); + }); + + test("Routines is in-process, with no credential, and acts as the person", () => { + const routines = catalogueEntry("routines"); + if (!routines) return; + expect(accessFor({ provenance: "first-party" }, routines)).toEqual({ + transport: "builtin-routines", + credential: "none", + reachedAs: "person", + }); + }); + + test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { + const notion = catalogueEntry("notion"); + if (!notion) return; + // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one. + expect(accessFor({ provenance: "composio" }, notion).transport).toBe("mcp"); + expect(accessFor({ provenance: "composio" }, notion).credential).toBe( + "person-oauth", + ); + }); +}); From 94fb760f0282e84788710295b3e7c2af6816f6d8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:52:49 +0200 Subject: [PATCH 012/215] Add a script for the live Composio test and document the key it needs Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .env.example | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 6d46581f2..1cb267422 100644 --- a/.env.example +++ b/.env.example @@ -338,3 +338,8 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= + +# Composio, the broker that holds each person's connections to Gmail, Slack, Linear and the rest. +# Absent means absent rather than disabled: no app can be enabled and nothing is registered. +# Only used by the live test in steps 1 and 2; the product does not read it until the app picker ships. +COMPOSIO_API_KEY= diff --git a/package.json b/package.json index c365827c4..13f93f99f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", + "test:live-composio": "OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, From 71fd357f16199fe4c853d54bac168efe5d756d49 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 17:55:45 +0200 Subject: [PATCH 013/215] Give the plugin store suite a fresh database and a Composio fixture, and check the new columns round-trip Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 349 ++++++++++++++++-- 1 file changed, 322 insertions(+), 27 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b6f442e94..e934b15a3 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -8,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock } from "@copilotkit/aimock/mcp"; -import { and, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -17,10 +18,11 @@ import { decryptSecret, encryptSecret, } from "../src/credentials"; -import { createDatabase } from "../src/db/client"; +import { createDatabase, type Database } from "../src/db/client"; import { agents, auditEvents, + composioConnections, credentials as credentialRows, credentials, mcpServers, @@ -30,6 +32,7 @@ import { users, } from "../src/db/schema"; import { catalogueEntry } from "../src/plugins/catalogue"; +import { useComposioClient } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; import { type AccessToken, @@ -39,6 +42,7 @@ import { INVALID_CLIENT, type OAuthClient, PluginRefusedError, + type PluginStore, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; @@ -89,34 +93,44 @@ let toolWasAlreadyAdvertised = false; const revokedCredentialIds: string[] = []; const issuedCredentialIds: string[] = []; + +/** + * The vault, stubbed, shared by every store in this file that does not need a real one. + * + * Named rather than inlined into the store below so that {@link freshStore} passes the SAME stub: + * a second copy would be a second place for "no credential is read here" to stop being true, and the + * refusals below are what make that claim worth anything. + */ +const credentialsStub = { + // No credential is ever read in these tests, because every call is refused before the vault. + readSecret: async () => null, + // Nor written in place. Loud rather than absent: a call reaching either of these would mean + // this file had started exercising something it does not claim to, and a silent no-op would + // hide that. + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + // `removeServer` does revoke: it retires the token the server was configured with so a re-add + // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because + // `removeServer` reads liveness from the table before deciding whether to revoke at all. + revoke: async (id: string) => { + const revokedAt = new Date(); + await database + .update(credentialRows) + .set({ revokedAt, updatedAt: revokedAt }) + .where(eq(credentialRows.id, id)); + revokedCredentialIds.push(id); + return revokedAt; + }, +}; + const store = createPluginStore({ database, auditStore: createAuditStore(database), - credentials: { - // No credential is ever read in these tests, because every call is refused before the vault. - readSecret: async () => null, - // Nor written in place. Loud rather than absent: a call reaching either of these would mean - // this file had started exercising something it does not claim to, and a silent no-op would - // hide that. - create: async () => { - throw new Error("this suite does not write credentials"); - }, - updateSecret: async () => { - throw new Error("this suite does not write credentials"); - }, - // `removeServer` does revoke: it retires the token the server was configured with so a re-add - // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because - // `removeServer` reads liveness from the table before deciding whether to revoke at all. - revoke: async (id: string) => { - const revokedAt = new Date(); - await database - .update(credentialRows) - .set({ revokedAt, updatedAt: revokedAt }) - .where(eq(credentialRows.id, id)); - revokedCredentialIds.push(id); - return revokedAt; - }, - }, + credentials: credentialsStub, encryptionKey: "x".repeat(44), policy: () => policy, }); @@ -3132,3 +3146,284 @@ describe("a vendor reply that is not a token", () => { } }); }); + +/** + * What a brokered app needs before any of it can be asserted: a clean slate and a fixture. + * + * The suites above each own a suite-scoped id, because they run against a database somebody may be + * using. These fixtures cannot: a Composio app IS its toolkit slug — `gmail` is both the row's id and + * the name sent to Composio — so the rows have to be spelled the way production spells them, and + * `bot_helper` and `user_asker` name them in every assertion. What replaces the suffix is removing + * exactly these rows before each test rather than after, so a run that dies halfway leaves the next + * one nothing to trip over. + * + * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a + * fixture whose first caller has not been written yet reads to the linter as dead code. + */ +async function freshDatabase(): Promise { + /* + * The Bot's own grants, never a delete by ref. + * + * The primary key is (kind, ref, agent_id), and `gmail/GMAIL_FETCH_EMAILS` is a real action of a + * real app: a delete by ref alone would take an administrator's grant for a Bot people use. This + * file has already done that once — the `afterAll` near the top of it says what that cost. + */ + await database + .delete(pluginGrants) + .where(eq(pluginGrants.agentId, "bot_helper")); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + // The actions before the servers. `mcp_tools` cascades on the server row anyway, so this is what + // clears actions a previous run left against a server row it is not what created. + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + // Only these two toolkits, and only ever rows these tests write: the table has no foreign key to + // `users`, which is the property the first test below is about, so nothing else removes them. + await database + .delete(composioConnections) + .where(inArray(composioConnections.toolkit, ["gmail", "notion"])); + await database.delete(users).where(eq(users.id, "user_leaver")); + return database; +} + +/** + * A store over the clean database, recording every event it writes. + * + * `recorded()` alongside the real insert rather than instead of it: the payload is what these tests + * assert about, and reading it back out of `audit_events` would assert what the column round-trips + * rather than what the store said. The row is still written, because a store whose audit insert + * never touched the database would not be exercising the one it has. + * + * NO `callVendor`. Whose account a call runs as and which transport a row resolves to are the + * properties under test, and both are decided on the way to the vendor — so the real path has to + * run, and the vendor is stubbed further out at {@link useComposioClient}. + */ +export async function freshStore() { + const database = await freshDatabase(); + const persisting = createAuditStore(database); + const events: Parameters[0][] = []; + const auditStore = { + insert: async (event: Parameters[0]) => { + events.push(event); + await persisting.insert(event); + }, + recorded: () => events, + }; + + const store = createPluginStore({ + database, + auditStore, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + return { store, database, auditStore }; +} + +/** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ +export async function seedComposioGmail( + database: Database, + store: PluginStore, + options: { connect?: boolean } = {}, +) { + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + version: "20260903_00", + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_asker" }); + } + await store.grant( + "mcp", + "gmail/GMAIL_FETCH_EMAILS", + "bot_helper", + "admin@example.com", + ); +} + +/** Whatever a fixture installed into a global, undone after the test that installed it. */ +const installed: (() => Promise)[] = []; + +/** + * A first-party MCP server, with a real MCP server on localhost standing in for the vendor. + * + * The seam is `fetch`, the way the refresh test above does it: the host is pinned and nothing in the + * store will take a URL from a caller, so pointing the pinned host at the mock is what lets a real + * listing over the real protocol happen. `store` is taken and unused because this vendor needs no + * grant to be listed — the callers hand it over so the two seeds read the same way. + */ +export async function seedNotionServer( + database: Database, + _store: PluginStore, +) { + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Notion", + url: "https://mcp.notion.com/mcp", + provenance: "first-party", + }); + + const mock = new MCPMock(); + mock.addTool({ + name: "notion-fetch", + description: "Fetch a page.", + inputSchema: { type: "object", properties: {} }, + }); + const mockUrl = await mock.start(); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + installed.push(async () => { + globalThis.fetch = realFetch; + await mock.stop?.(); + }); +} + +// The vendor is a process-wide registry, so a stub outliving its test would be answering somebody +// else's calls. +afterEach(() => useComposioClient(null)); + +afterEach(async () => { + while (installed.length > 0) await installed.pop()?.(); +}); + +test("a Composio connection row survives the person being deleted", async () => { + const database = await freshDatabase(); + + await database + .insert(users) + .values({ id: "user_leaver", email: "leaver@example.com", name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "user_leaver" }); + + await database.delete(users).where(eq(users.id, "user_leaver")); + + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "user_leaver")); + + // The whole reason this table exists rather than reusing mcp_user_credentials: offboarding has to + // still find the connection and revoke it at Composio after the person is gone, and there is no + // vault row to find it by, because Composio holds the account. + expect(rows).toEqual([{ toolkit: "gmail" }]); +}); + +test("an action's effect, destructive marker and version round-trip", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await database.insert(mcpTools).values([ + { + serverId: "gmail", + name: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + serverId: "gmail", + name: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + effect: "write", + destructive: true, + version: "20260903_00", + }, + ]); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + ]); +}); + +test("an action listed before these columns existed reads as unclassified and unversioned", async () => { + const database = await freshDatabase(); + + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Notion", + url: "https://mcp.notion.com/mcp", + provenance: "first-party", + }); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + + const [row] = await database + .select({ + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "notion")); + + // Null rather than a default: an existing row must keep meaning exactly what it meant, and the + // classifier decides what an absent effect implies. A column default of "write" would silently + // reclassify every already-listed Notion read as a write the moment the migration ran. + expect(row).toEqual({ effect: null, destructive: false, version: null }); +}); From 6006b9bd30bcd61bdee216470133985025c8f866 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:16:16 +0200 Subject: [PATCH 014/215] Refuse to run this suite against a database that already owns its fixture ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Composio fixtures at the bottom of this file insert at `gmail`, `notion`, `bot_helper` and `user_asker`, and none of those ids is a choice: `seedNotionServer` needs `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug that gets sent to the vendor. `freshDatabase` deleted them unconditionally to make room, which against a database somebody is using is a lot to take. `mcp_user_credentials` references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user credential row with it and leaves their encrypted vault rows referenced by nothing — unreachable from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's preferences for it, its routines and all of their run history, its component exclusions and its plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would say it happened. The `*WasAlreadyConfigured` pattern the older suites use cannot help here: they only read those rows, so skipping a delete is enough for them, while a fixture that inserts at an id cannot coexist with a real row there at all — skipping the delete would just turn the disaster into a primary-key conflict, and capture-and-restore restores after the cascade has already run. So this refuses to run instead: a file-level guard looks for the rows the suite intends to own and throws naming what it found and where to point DATABASE_URL. That is what makes the deletes safe, and the comment now says so. Two more things while in here. There was no file-level `afterAll`, so the last test's fixtures were permanent — one run left behind a `notion` server row and a `notion-fetch` action nobody configured, which makes that database advertise a connector nobody set up, and on the next run the rotation suites read the leak as the deployment's own row and correctly declined to clean it. And the connection delete matched on the toolkit alone, which is every person's Gmail connection rather than the fixture's; it now names the two people this file invents. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++++- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index e934b15a3..d4230a5b7 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -152,6 +152,78 @@ async function auditRowsFor(targetId: string) { ); } +/** + * Whether the guard below cleared this run to own the ids the Composio fixtures insert at. + * + * Read by the `afterAll` that removes those rows. A run the guard refused must not delete the rows + * it refused over, and `afterAll` still runs after a `beforeAll` has thrown. + */ +let ownsFixtureIds = false; + +/* + * Refuse to run at all against a database that already holds the ids this suite inserts at. + * + * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a + * delete is enough for them — that is what `serverWasAlreadyConfigured` and its siblings are for. + * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, + * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs + * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug + * that gets sent to Composio, so neither can be suffixed. A fixture that inserts at an id cannot + * coexist with a real row at that id: skipping the delete would only turn the collision into a + * primary-key conflict, and capture-and-restore would be a lot of machinery whose failure mode is + * destroying the thing it protects, because the cascade has already run by the time it restores. + * + * What the cascade takes is why this is a refusal rather than a warning. `mcp_user_credentials` + * references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user + * credential row with it and leaves their encrypted vault rows referenced by nothing — unreachable + * from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. + * Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's + * preferences for it, its routines and all of their run history, its component exclusions and its + * plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would + * say it happened. + * + * So the deletes in {@link freshDatabase} are unconditional and this is what makes them safe. + */ +beforeAll(async () => { + const [configuredServers, existingBots, existingConnections] = + await Promise.all([ + database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])), + database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, "bot_helper")), + database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + ), + ]); + + const found = [ + ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), + ...existingBots.map((row) => `the Bot '${row.id}'`), + ...existingConnections.map( + (row) => `the composio_connections row for '${row.userId}'`, + ), + ]; + + if (found.length > 0) { + throw new Error( + `This suite owns ${found.join(", ")} outright — it inserts at those exact ids and deletes ` + + "them before every test — and refuses to run against a database that already has them, " + + "because deleting a real server row takes every person's per-user credentials with it and " + + "deleting a real Bot takes the six tables behind it. Point DATABASE_URL at a scratch " + + "database.", + ); + } + + ownsFixtureIds = true; +}); + beforeAll(async () => { for (const id of [holderId, strangerId]) { await database @@ -3153,9 +3225,15 @@ describe("a vendor reply that is not a token", () => { * The suites above each own a suite-scoped id, because they run against a database somebody may be * using. These fixtures cannot: a Composio app IS its toolkit slug — `gmail` is both the row's id and * the name sent to Composio — so the rows have to be spelled the way production spells them, and - * `bot_helper` and `user_asker` name them in every assertion. What replaces the suffix is removing - * exactly these rows before each test rather than after, so a run that dies halfway leaves the next - * one nothing to trip over. + * `bot_helper` and `user_asker` name them in every assertion. + * + * What replaces the suffix is the guard at the top of this file, not the ordering below. The deletes + * here are unconditional and would be indefensible on their own — a real `notion` row cascades into + * every person's per-user credentials, a real Bot into the six tables behind it. They are safe only + * because nothing gets this far unless the guard has already established that no row at any of these + * ids exists, which makes every row they remove one of this file's own. Cleaning before each test + * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; + * the `afterAll` below is what stops the last test's fixtures from outliving the run. * * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a * fixture whose first caller has not been written yet reads to the linter as dead code. @@ -3180,11 +3258,13 @@ async function freshDatabase(): Promise { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); - // Only these two toolkits, and only ever rows these tests write: the table has no foreign key to - // `users`, which is the property the first test below is about, so nothing else removes them. + // By person, never by toolkit: the table has no foreign key to `users` — which is the property the + // first test below is about, so nothing else removes these rows — and a delete by toolkit alone + // would take every person's Gmail connection, leaving one orphaned at the broker with no local row + // left to find it by. Only the two people this file invents. await database .delete(composioConnections) - .where(inArray(composioConnections.toolkit, ["gmail", "notion"])); + .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); await database.delete(users).where(eq(users.id, "user_leaver")); return database; } @@ -3317,6 +3397,33 @@ afterEach(async () => { while (installed.length > 0) await installed.pop()?.(); }); +/* + * The last test's fixtures, which nothing else would remove. + * + * {@link freshDatabase} cleans BEFORE each test, so without this the final test's rows are + * permanent: a `notion` server row and a `notion-fetch` action nobody configured, which makes + * whatever database this ran against advertise a connector nobody set up. Worse on the next run — + * the rotation and dynamic-registration suites above capture `notionWasAlreadyConfigured` from the + * leak, correctly decline to clean what looks like the deployment's own row, and leave the + * unconditional delete as the only thing that removes it. + * + * Exactly what this file created, and only when the guard cleared the run to own these ids. + */ +afterAll(async () => { + if (!ownsFixtureIds) return; + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, ["gmail", "notion"])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["gmail", "notion"])); + await database + .delete(composioConnections) + .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + await database.delete(agents).where(eq(agents.id, "bot_helper")); + await database.delete(users).where(eq(users.id, "user_leaver")); +}); + test("a Composio connection row survives the person being deleted", async () => { const database = await freshDatabase(); From 5d92666e72e3f00e5b225e6e73c01d18edb9d767 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:24:31 +0200 Subject: [PATCH 015/215] Require positive evidence before this suite deletes a row it may not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serverWasAlreadyConfigured` and `toolWasAlreadyAdvertised` were captured in a file-level `beforeAll` and read by the file-level `afterAll`, which deleted the `google-drive` server row and its `search_files` tool row when they were `false`. Both were initialised to `false`, so the value that authorised the delete was also the value they held before anything had looked. A flag meaning "delete this" must not default to the value that authorises deletion, because a setup that aborts leaves every flag sitting at its default. The guard added alongside them is what made that path routine rather than exceptional. It is a file-level `beforeAll` ahead of the capturing one, and it throws by design whenever the database already holds the fixture ids this suite inserts at — a documented outcome rather than a crash. bun runs `afterAll` anyway, and the capturing hook never got to run, so the teardown concluded it had created rows it had never looked at and removed them. Verified against a database holding an operator's own `google-drive` connector and its advertised `search_files` tool, plus a `gmail` row to trip the guard: the run refused as designed, `gmail` survived, and the two rows the suite had no business touching were gone. The cascade off `mcp_servers.id` then takes every person's per-user credential row and strands their encrypted vault rows behind a dangling reference, invisible to `retireConnectionsFor` — the exact harm the guard's own comment cites as its reason for existing. So the flags now count creations. `suiteCreatedServerRow` and `suiteCreatedToolRow` are set to `true` only where the capture actually ran and found the row absent, and the teardown deletes on `true`. `false` covers both "the deployment already had it" and "nobody ever looked", which is the right answer for both: neither is this suite's row to remove. An early throw leaves both at their initialisers and nothing is deleted. Gating that `afterAll` on `ownsFixtureIds` would have worked today and answers a different ownership question — which ids the guard cleared, not which rows this run wrote — and would break silently if either hook moved. The two describe-scoped `notionWasAlreadyConfigured` teardowns keep the inverted shape; a file-level `beforeAll` that throws stops describe-scoped hooks from running at all, so that path cannot reach them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index d4230a5b7..359f32e9e 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -75,21 +75,31 @@ const siblingToolName = `not_granted_${suite}`; let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; /** - * Whether this deployment already had the server before the test ran. + * Whether THIS RUN is what put the server row there, and so is what should take it away. * * The id is a real catalogue key rather than a suite-scoped one, because what is under test includes * the vendor's own read/write classification. On a database somebody is using, that key is their * configured server, so it is removed only when the test is what created it. + * + * Which is why the flag counts creations rather than the absences it used to. `afterAll` runs even + * when a `beforeAll` above it has thrown, and every flag is then still sitting at its initialiser — + * so a teardown must not be authorised by a setup that never completed. Only a capture that ran and + * found the row missing can write the value the delete needs; `false` covers both "the deployment + * already had it" and "nobody ever looked", and neither of those is this suite's row to remove. */ -let serverWasAlreadyConfigured = false; +let suiteCreatedServerRow = false; /** - * Whether this deployment already advertised the tool this suite inserts. + * Whether THIS RUN is what advertised the tool, and so is what should stop advertising it. * * The vendor really does advertise `search_files`, so the row may be a refreshed fact about the * vendor rather than the suite's fixture. Deleting by name regardless would take a real one; leaving * it always would leave a fixture that reads on screen as a tool the vendor offers. + * + * Set the same way round as {@link suiteCreatedServerRow} and for the same reason: the delete waits + * on evidence that this run inserted the row, not on the mere absence of evidence that somebody else + * did. */ -let toolWasAlreadyAdvertised = false; +let suiteCreatedToolRow = false; const revokedCredentialIds: string[] = []; const issuedCredentialIds: string[] = []; @@ -164,7 +174,7 @@ let ownsFixtureIds = false; * Refuse to run at all against a database that already holds the ids this suite inserts at. * * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a - * delete is enough for them — that is what `serverWasAlreadyConfigured` and its siblings are for. + * delete is enough for them — that is what `suiteCreatedServerRow` and its siblings are for. * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug @@ -237,15 +247,15 @@ beforeAll(async () => { .onConflictDoNothing(); } - serverWasAlreadyConfigured = + suiteCreatedServerRow = ( await database .select({ id: mcpServers.id }) .from(mcpServers) .where(eq(mcpServers.id, serverId)) - ).length > 0; + ).length === 0; - toolWasAlreadyAdvertised = + suiteCreatedToolRow = ( await database .select({ name: mcpTools.name }) @@ -253,7 +263,7 @@ beforeAll(async () => { .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) - ).length > 0; + ).length === 0; // The server row is written directly rather than through addServer, so the test needs no vendor // to be reachable. What is under test is the decision, not the listing. @@ -316,12 +326,12 @@ afterAll(async () => { ); // A server row is deployment configuration, so it belongs to the deployment rather than here. // The fixture tool goes whether or not this suite owns the server, but only if it put it there. - if (!toolWasAlreadyAdvertised) { + if (suiteCreatedToolRow) { await database .delete(mcpTools) .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName))); } - if (!serverWasAlreadyConfigured) { + if (suiteCreatedServerRow) { await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId)); await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); } From 39918f90402ccea25618ee90fa0460af68b55a63 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:31:08 +0200 Subject: [PATCH 016/215] Resolve once how a server is reached, and let the trail read it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 63d611f34..0a7929847 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -39,6 +39,7 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; +import { accessFor, type ServerAccess } from "./access"; import { inspectToolArguments } from "./content-governance"; import { McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -312,21 +313,15 @@ const iso = (value: Date | string | null): string | null => value === null ? null : value instanceof Date ? value.toISOString() : value; /** - * Whose credential reaches this server, as the trail names it. + * Whose account this call went out as, for the trail. * - * One definition, because this was two: `connectionTokenFor` returned it and the audit payload - * recomputed the same condition a few lines later. Two expressions for one fact can disagree, and - * the one place that would show is an audit row claiming a call ran as somebody it did not — which is - * the row a per-person connector exists to be able to trust. - * - * `deployment` for a shared token; the asker's own id for a server reached as the person asking. - * `builtin` is the third case and the only one with no credential at all — the actor is not whose - * token was used, it is whose rows were touched. + * Reads the resolved descriptor rather than re-deriving from the entry's auth kind. That derivation + * had no answer for a Composio app — the entry is null, so it fell through to `deployment` for a call + * that ran in one person's own mailbox, which is the trail being wrong about the one thing a + * per-person connector exists for. */ -const reachedAsFor = (entry: CatalogueEntry | null, actorId: string): string => - entry?.auth.kind === "user-oauth" || entry?.auth.kind === "builtin" - ? actorId - : "deployment"; +const reachedAsFor = (access: ServerAccess, actorId: string): string => + access.reachedAs === "person" ? actorId : "deployment"; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -1594,7 +1589,14 @@ export function createPluginStore(options: PluginStoreOptions) { throw new CatalogueEntryUnknownError(row.id); } // Null for a custom server, and every caller handles that by assuming the worst about it. - return { row, entry }; + /* + * Resolved here so every caller reads the same answer. + * + * Three call sites used to derive their own — the transport, the credential and the audit row — + * and a Composio app made all three of them wrong at once. One derivation means they cannot + * disagree, and `access.ts` is the only place a new kind of server has to be taught about. + */ + return { row, entry, access: accessFor(row, entry) }; } return { @@ -2906,7 +2908,7 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(decision.reason, null); } - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); const advertised = await database .select({ name: mcpTools.name, inputSchema: mcpTools.inputSchema }) @@ -2978,7 +2980,7 @@ export function createPluginStore(options: PluginStoreOptions) { * a per-person connector raises — two rows for the same tool and the same Bot can legitimately * have seen entirely different documents, and nothing else in the row says why. */ - reachedAs: reachedAsFor(entry, input.actorId), + reachedAs: reachedAsFor(access, input.actorId), decision: { allowed: verdict.allowed, mode: verdict.mode, From 532a6432bd6d723d213e7d6453ee4778e7f4680c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:39:56 +0200 Subject: [PATCH 017/215] Key whose account a call reached on the auth kind, not on the credential behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reachedAs` was derived from the collapsed `CredentialSource`, and `CREDENTIAL_BY_AUTH` maps both `none` and `builtin` to `"none"` — so by the time the answer was chosen, the two kinds were indistinguishable. They do not share an answer. A credential-free endpoint is public: it touches nobody's account and answers every person identically, so naming the asker asserts a per-person attribution that does not exist. The builtin one has no credential for the opposite reason — the call runs against this deployment's own tables as the person whose turn it is, so the person is exactly who it reached. Collapsing both to one credential source erased that distinction at precisely the point it mattered, and gave `person` to a public endpoint. `REACHED_AS_BY_AUTH` is keyed on the auth kind instead, which is the field the answer actually depends on, and being a `Record` over that union it makes the compiler ask the question for any auth kind added later. That is what the module already claimed and could not deliver: the existing `Record` only forced a new kind to declare a credential source, and `reachedAs` fell out of that, so the first `none` entry would have got no compile error, no failing test, and a wrong audit row. No catalogue slug uses `none` today, so nothing changes at runtime — Drive and Notion are `user-oauth`, Routines is `builtin`, and all three still resolve to the person. The new test constructs a `none` entry directly so the file's stated exhaustiveness property holds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 41 +++++++++++++++++++++++----- server/tests/composio-access.test.ts | 23 ++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 7c20d0a30..da0edb302 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -33,10 +33,14 @@ export type ServerAccess = { /** * Whose account the call reached, as the audit row names it. * - * `person` is the asking person's id and `deployment` is a shared token. Three of the four - * credential sources reach somebody's own account — including `brokered`, where the deployment - * holds the key but the call runs in one person's mailbox, which is the whole point of the - * connector and therefore the only useful thing the trail can say about it. + * `person` is the asking person's id, and it is only correct where the call actually landed + * somewhere that person alone can see: their own OAuth grant, their own mailbox behind a broker — + * where the deployment holds the key but the call runs in one person's mailbox, which is the whole + * point of the connector and therefore the only useful thing the trail can say about it — or this + * deployment's own tables read as them. `deployment` means the opposite: not any one person's + * account. That covers a shared token, a server an administrator added by URL, and a public + * endpoint reached with no credential at all, where every person's call sees the same data and + * naming the asker would assert an attribution that does not exist. */ reachedAs: "person" | "deployment"; }; @@ -51,6 +55,30 @@ const CREDENTIAL_BY_AUTH: Record< builtin: "none", }; +/** + * Whose account each auth kind reaches. Keyed on the auth kind, NOT on the credential source above. + * + * `none` and `builtin` collapse to the same credential source — there is no credential either way — + * and they do not share an answer. A public endpoint touches nobody's account, so the trail says + * `deployment`, the same thing it says for a server added by URL. The builtin one runs against this + * deployment's own tables as the person whose turn it is, so the trail says `person`. Deriving this + * from `CREDENTIAL_BY_AUTH` made the two indistinguishable at exactly the point they differ, and + * answered `person` for both. + * + * A second table rather than a branch, so the compiler forces the question to be answered for any + * auth kind added later — which is what this module claims above and could not deliver while this + * field was inferred from something coarser than the thing it depends on. + */ +const REACHED_AS_BY_AUTH: Record< + CatalogueEntry["auth"]["kind"], + ServerAccess["reachedAs"] +> = { + none: "deployment", + "deployment-bearer": "deployment", + "user-oauth": "person", + builtin: "person", +}; + /** * A reviewed entry decides for itself; otherwise the row decides. * @@ -67,11 +95,10 @@ export function accessFor( entry: CatalogueEntry | null, ): ServerAccess { if (entry) { - const credential = CREDENTIAL_BY_AUTH[entry.auth.kind]; return { transport: entry.transport ?? "mcp", - credential, - reachedAs: credential === "deployment-token" ? "deployment" : "person", + credential: CREDENTIAL_BY_AUTH[entry.auth.kind], + reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], }; } diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index cf0f77b0c..8ff8e7c60 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; /** @@ -65,6 +66,28 @@ describe("accessFor", () => { }); }); + test("an entry that needs no credential reaches nobody's account, so the trail says the deployment", () => { + // Constructed here, because no catalogue slug is `auth: { kind: "none" }` yet. Whoever adds the + // first one gets this answer, and `none` sharing a credential source with `builtin` must not + // drag it to the person: a public endpoint answers everybody identically. + const publicEntry: CatalogueEntry = { + key: "public-thing", + title: "Public Thing", + vendor: "Somebody", + summary: "A server that answers without being told who is asking.", + host: "mcp.example.com", + path: "/mcp", + auth: { kind: "none" }, + writeTools: [], + docsUrl: "https://example.com/docs", + }; + expect(accessFor({ provenance: "first-party" }, publicEntry)).toEqual({ + transport: "mcp", + credential: "none", + reachedAs: "deployment", + }); + }); + test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); if (!notion) return; From dd76143ec265b84991a5ea77d66808e6254e501e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 18:51:10 +0200 Subject: [PATCH 018/215] Look a transport up by the kind that was resolved, not by guessing from an entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transportFor` took a catalogue entry and read `entry?.transport ?? "mcp"` off it. That was complete while every server either had a frozen entry or was somebody's MCP endpoint. A Composio app is neither — no entry, so the fallback answered MCP and `composio://gmail` would have been dialled as an HTTP server. `accessFor` already resolves the kind once for every row shape. Both call sites in the store now read it off `access` and pass the kind, so this file only does the lookup. The Drive test asserted the same fallback through `transportFor(null)`. It now composes through `accessFor`, which is where the absent-entry decision moved; the property it checks is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 7 ++++--- server/src/plugins/transport.ts | 16 ++++++++-------- server/tests/google-drive-rest.test.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0a7929847..b58abdded 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1988,11 +1988,11 @@ export function createPluginStore(options: PluginStoreOptions) { serverId: string, actorId = "", ): Promise<{ tools: number }> { - const { row, entry } = await requireServer(serverId); + const { row, entry, access } = await requireServer(serverId); try { // The entry decides the protocol. For a custom server there is no entry, and MCP is right. - const transport = transportFor(entry); + const transport = transportFor(access.transport); /* * A credential only when listing actually needs one. @@ -3069,7 +3069,8 @@ export function createPluginStore(options: PluginStoreOptions) { */ try { const { token } = await connectionTokenFor(row, entry, input.actorId); - const vendor = injectedVendor ?? transportFor(entry).callTool; + const vendor = + injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( { url: effectiveUrl(row, entry), diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 7e7e1dcd2..1dba9cc89 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,5 +1,4 @@ import * as builtinRoutines from "./builtin-routines"; -import type { CatalogueEntry } from "./catalogue"; import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; import type { ListedTool, McpCallResult } from "./mcp"; @@ -94,13 +93,14 @@ const TRANSPORTS: Record = { }; /** - * Which transport serves this entry. + * The transport for a resolved kind. * - * MCP for anything that does not say otherwise, which covers every catalogue entry that omits the - * field and — importantly — every server an administrator added by URL, where there is no entry at - * all. A custom server is somebody else's MCP endpoint by definition, so the absent case and the - * default case are the same answer for the same reason. + * A kind rather than a catalogue entry, because deciding the kind is no longer this file's business. + * It used to read `entry?.transport ?? "mcp"`, which was complete while every server either had an + * entry or was somebody's MCP endpoint — and silently wrong for a Composio app, which has no entry + * and would have had `composio://gmail` dialled as an HTTP server. `./access` decides now, once, for + * every row shape; this is the lookup that follows. */ -export function transportFor(entry: CatalogueEntry | null): VendorTransport { - return TRANSPORTS[entry?.transport ?? "mcp"]; +export function transportFor(kind: TransportKind): VendorTransport { + return TRANSPORTS[kind]; } diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 0cf1cbdf1..e863cbc87 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; import { transportFor } from "../src/plugins/transport"; @@ -46,12 +47,19 @@ describe("the adapter is the transport the catalogue asks for", () => { const entry = catalogueEntry("google-drive"); expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. - expect(transportFor(entry).callTool).toBe(callTool); + expect( + transportFor(accessFor({ provenance: "first-party" }, entry).transport) + .callTool, + ).toBe(callTool); }); test("a server with no catalogue entry falls back to MCP", () => { // A custom server an administrator added by URL is somebody else's MCP endpoint by definition. - expect(transportFor(null).callTool).not.toBe(callTool); + // Composed through `accessFor`, which is where the absent-entry fallback now lives. + expect( + transportFor(accessFor({ provenance: "custom" }, null).transport) + .callTool, + ).not.toBe(callTool); }); test("every advertised tool is one the dispatcher handles", async () => { From a368aa352b06cbf130ea095a5a4a3f1d718db156 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:04:22 +0200 Subject: [PATCH 019/215] Correct four comments that describe the rule this branch replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four stated that the catalogue entry decides the protocol, which is precisely the defect this branch removed: a Composio app has no entry, so deriving the transport from one dialled `composio://gmail` as an HTTP MCP server. A reader reconstructing the rule from those comments reconstructs the bug. `accessFor` decides now, once, for every row shape, and the comments say so. The fourth comment was not wrong, only orphaned: it was written about `entry`, and a later commit inserted the `access` block between it and the return, so it had come to read as a preamble to something that is never null. Moved back above the line it describes, unchanged. The listing path now has a test. It asserts that a Composio row reaches the Composio client, and it goes red when `refreshTools` resolves the transport from the entry instead — verified by making that change and watching it fail. The calling path cannot be covered until a version argument is threaded through, so it is left for later. A Drive test's negative assertion became a positive one. Asserting "not the Drive adapter" for an entry-less server would have been satisfied by any wrongly resolved transport, including the very defect under repair; it now asserts the MCP adapter by identity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/google-drive-rest.ts | 2 +- server/src/plugins/store.ts | 5 ++-- server/src/plugins/transport.ts | 7 ++--- server/tests/google-drive-rest.test.ts | 6 +++-- server/tests/plugin-store.integration.test.ts | 26 +++++++++++++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 99b49bb35..6d3965017 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -15,7 +15,7 @@ import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had — * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore * not the default with an exception carved out of it; both are implementations of the same contract, - * chosen per catalogue entry by {@link ./transport}. Going back to the MCP server when the preview + * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to the MCP server when the preview * opens is one field on one entry, with nothing else in the system aware it changed. * * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character. diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index b58abdded..dbdc3d499 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1580,6 +1580,7 @@ export function createPluginStore(options: PluginStoreOptions) { .limit(1); if (!row) throw new CatalogueEntryUnknownError(serverId); + // Null for a custom server, and every caller handles that by assuming the worst about it. const entry = catalogueEntry(row.id); if (row.provenance === "first-party" && !entry) { // The row outlived its catalogue entry, which means a build removed a vendor while a @@ -1588,7 +1589,6 @@ export function createPluginStore(options: PluginStoreOptions) { // is one we agreed to talk to. throw new CatalogueEntryUnknownError(row.id); } - // Null for a custom server, and every caller handles that by assuming the worst about it. /* * Resolved here so every caller reads the same answer. * @@ -1991,7 +1991,8 @@ export function createPluginStore(options: PluginStoreOptions) { const { row, entry, access } = await requireServer(serverId); try { - // The entry decides the protocol. For a custom server there is no entry, and MCP is right. + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, + // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. const transport = transportFor(access.transport); /* diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 1dba9cc89..469105d0b 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -5,7 +5,7 @@ import type { ListedTool, McpCallResult } from "./mcp"; import * as mcp from "./mcp"; /** - * How this deployment reaches one vendor: which protocol, chosen per catalogue entry. + * How this deployment reaches one vendor: which protocol, from the kind `./access` resolved. * * WHY THIS EXISTS. Every connector used to be MCP, so "the transport" was an import. Google's Drive * MCP server turned out to be gated behind a developer preview, and the same product's ordinary REST @@ -74,10 +74,11 @@ export type VendorTransport = { }; /** - * The protocols a catalogue entry may name. + * The protocols this deployment can dial. * * A closed union rather than a string, so adding one is a change to this file and to the registry - * below together. An entry naming a transport that does not exist should not typecheck. + * below together. Named by a catalogue entry for a curated vendor and by `./access` from the row's + * provenance for a Composio app; either way, a kind that does not exist should not typecheck. */ export type TransportKind = | "mcp" diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index e863cbc87..ac6ae0166 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; +import { callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -55,11 +56,12 @@ describe("the adapter is the transport the catalogue asks for", () => { test("a server with no catalogue entry falls back to MCP", () => { // A custom server an administrator added by URL is somebody else's MCP endpoint by definition. - // Composed through `accessFor`, which is where the absent-entry fallback now lives. + // Composed through `accessFor`, which is where the absent-entry fallback now lives — and asserted + // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. expect( transportFor(accessFor({ provenance: "custom" }, null).transport) .callTool, - ).not.toBe(callTool); + ).toBe(mcpCallTool); }); test("every advertised tool is one the dispatcher handles", async () => { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 359f32e9e..9884ae9a2 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3457,6 +3457,32 @@ test("a Composio connection row survives the person being deleted", async () => expect(rows).toEqual([{ toolkit: "gmail" }]); }); +test("a Composio app is listed through the Composio transport, not dialled as MCP", async () => { + const { store, database } = await freshStore(); + const asked: string[] = []; + useComposioClient({ + listActions: async (toolkit) => { + asked.push(toolkit); + return []; + }, + execute: async () => ({}), + }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + // The transport comes from the resolved kind, not from the absent entry. Derived from the entry, + // this reached the MCP module instead and dialled `composio://gmail` as an HTTP server — which + // `refreshTools` swallows into `lastError`, so nothing but this reaches the vendor stub. + expect(asked).toEqual(["gmail"]); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From d780faef5b144fc47c352813e2b0f8c3859f0e42 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:11:07 +0200 Subject: [PATCH 020/215] Reflow the comment the last commit corrected but left 133 columns wide The phrase substitution landed without rewrapping the paragraph around it, so a commit whose whole purpose was making four comments readable left one of them the widest line in its file by twenty-nine columns, wrapping mid-link in any ordinary editor. Biome does not reflow prose, so no gate caught it. Whitespace only: the sentence is unchanged and the paragraph now wraps at the same width as the two above it. --- server/src/plugins/google-drive-rest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 6d3965017..b7a5865b9 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -15,8 +15,9 @@ import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp"; * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had — * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore * not the default with an exception carved out of it; both are implementations of the same contract, - * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to the MCP server when the preview - * opens is one field on one entry, with nothing else in the system aware it changed. + * chosen per catalogue entry by {@link ./access} and looked up in {@link ./transport}. Going back to + * the MCP server when the preview opens is one field on one entry, with nothing else in the system + * aware it changed. * * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character. * A grant is stored as `google-drive/search_files`, so keeping the names identical means every grant From 88193ff6a9422c028ee5f844a5c9130b580465f2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:16:26 +0200 Subject: [PATCH 021/215] Refuse a brokered call nobody is attributed for, and one whose asker has not connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brokered app holds one deployment key and relies on Composio to keep people's accounts apart, so the only thing that separates them is the person id the call runs under. Two states therefore have to be refused before anything is spent at the vendor: a run attributed to nobody, and a person who has not connected that app at all. Both are refused in `connectionTokenFor`, beside the `user-oauth` refusals they are modelled on, for the same two reasons — the person gets a sentence naming the step they can take, and no call is spent finding out. The transport refuses again as a last line. `connectionTokenFor` now takes the `ServerAccess` descriptor its callers already hold, and the per-person branch reads `access.credential` rather than the catalogue entry's auth kind, so both branches decide from one source instead of two that can disagree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 78 +++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index dbdc3d499..7e424417d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -21,6 +21,7 @@ import type { Database } from "../db/client"; import { agentProfiles, agents, + composioConnections, // Aliased: `credentials` is already the injected vault interface in this module, and the table and // the interface are two different things to reach for. credentials as credentialRows, @@ -764,11 +765,57 @@ export function createPluginStore(options: PluginStoreOptions) { * of revocation being complete by construction rather than by cleanup. */ async function connectionTokenFor( - row: { id: string; url: string; credentialId: string | null }, + row: { + id: string; + url: string; + title: string; + credentialId: string | null; + }, entry: CatalogueEntry | null, actorId: string, + access: ServerAccess, ): Promise<{ token?: string }> { - if (entry?.auth.kind !== "user-oauth") { + /* + * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. + * + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below is: + * a person gets a sentence naming the step they can take, and no call is spent at the vendor + * finding out. The transport refuses again as a last line, which is the belt to this braces — + * deleting either one has to turn a test red. + * + * There is no token. The key belongs to the transport and never travels through this function, so + * nothing here can leak it into a connection object, an error or an audit row. + */ + if (access.credential === "brokered") { + if (!actorId) { + throw new PluginRefusedError( + `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, + null, + ); + } + + const [connected] = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, row.id), + eq(composioConnections.userId, actorId), + ), + ) + .limit(1); + + if (!connected) { + throw new PluginRefusedError( + `You have not connected your ${row.title} account. Connect it in Settings and ask again.`, + null, + ); + } + + return {}; + } + + if (access.credential !== "person-oauth") { const token = row.credentialId ? await secretFor( row.credentialId, @@ -778,6 +825,24 @@ export function createPluginStore(options: PluginStoreOptions) { return { token }; } + /* + * Narrowing, not a second decision. + * + * `access.credential === "person-oauth"` is derived in `access.ts` from exactly this auth kind, + * so the branch above has already established it — but the derivation runs through a lookup + * table the compiler cannot follow back to `entry`. Nothing below re-decides whether this is a + * per-person server; it only reads the OAuth details that kind carries. + * + * A throw rather than a fallback. If the descriptor and the entry ever did disagree, answering + * out of the deployment's own credential is precisely the failure the comment above this function + * says must be impossible. + */ + if (entry?.auth.kind !== "user-oauth") { + throw new Error( + `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, + ); + } + /* * The anonymous actor is the empty string, and an empty string must never match a row. * @@ -2011,7 +2076,7 @@ export function createPluginStore(options: PluginStoreOptions) { * a function that discards it. The gate outlived the reason for it. */ const token = transport.listNeedsCredential - ? (await connectionTokenFor(row, entry, actorId)).token + ? (await connectionTokenFor(row, entry, actorId, access)).token : undefined; const tools = await transport.listTools({ @@ -3069,7 +3134,12 @@ export function createPluginStore(options: PluginStoreOptions) { * it did. */ try { - const { token } = await connectionTokenFor(row, entry, input.actorId); + const { token } = await connectionTokenFor( + row, + entry, + input.actorId, + access, + ); const vendor = injectedVendor ?? transportFor(access.transport).callTool; const result = await vendor( From 9045a52192bd08627c512f2a03df7a7b24d61316 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:35:00 +0200 Subject: [PATCH 022/215] Test the two refusals that stand between a stray run and somebody else's mailbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brokered branch of `connectionTokenFor` refuses two things: a run that is not attributed to anybody, and an asker who has not connected the app. Both shipped with nothing in the suite exercising them. A throw planted as the first statement inside the `if (access.credential === "brokered")` branch left the whole suite green at 1883 pass — nothing entered that arm, so either refusal could have been deleted without a single test noticing. Silently deletable is the worst state for a security refusal to be in. Two tests now enter it, and each goes red when its own refusal is deleted. Removing the `if (!actorId)` block makes the unattributed test fail on the wrong refusal reaching it; removing the `if (!connected)` block makes the not- connected test fail because the call resolves and the stub records a slug, which is a call spent at the broker to find out what the row already knew. Both assert that the vendor stub was never reached, so the refusal is proven to happen before a call is spent rather than merely somewhere. The docblock above the function had stopped enumerating the branches it governs while the code appealed to it as authority. It described a two-case function keyed on auth kinds the body no longer reads, said nothing about the brokered arm that carries both refusals, and stated unconditionally that a refresh token is exchanged per call — telling a reader on the brokered path that something is exchanged where no refresh token exists at all. It now names all four kinds, and says which paths have nothing to cache versus the one where not caching is a decision. `row.url` went with it. It was read nowhere in the function, and dropping it fits the parameter back onto one line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 35 ++++++----- server/tests/plugin-store.integration.test.ts | 61 +++++++++++++++++-- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 7e424417d..edd425881 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -748,29 +748,36 @@ export function createPluginStore(options: PluginStoreOptions) { } /** - * The token one call goes out with, and whose it is. + * The token one call goes out with, and whose it is — decided from `access.credential`, so that + * this function and the audit row cannot disagree about whose account a call ran in. * - * For a `deployment-bearer` server this is what it always was: the one credential an administrator - * gave the server, used for everybody. + * For a `deployment-token` server this is what it always was: the one credential an administrator + * gave the server, used for everybody. A `none` server reaches the same branch and finds nothing + * to decrypt, which is the right answer for an endpoint that takes no credential at all. * - * For a `user-oauth` server it is the asker's own, and every branch that cannot prove it has the + * For a `brokered` server there is no token here AT ALL. The deployment's one key belongs to the + * transport and never travels through this function, so nothing here can leak it into a connection + * object, an error or an audit row. What this function contributes instead is the two refusals + * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an + * asker who has not connected the app — so a person is told their own next step rather than shown + * the broker's error about an account it cannot find. + * + * For a `person-oauth` server it is the asker's own, and every branch that cannot prove it has the * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design * exists to make impossible: answering out of whatever the deployment, or the last person to * connect, happened to be able to see — which returns a confident answer assembled from documents * the person asking cannot open, and looks exactly like a correct answer. * - * Nothing is cached. The refresh token is exchanged for an access token per call and the access - * token is thrown away, so there is no stored copy of anybody's access for a disconnect to have to - * find. That costs a round trip to the vendor's token endpoint on every call, which is the price - * of revocation being complete by construction rather than by cleanup. + * Nothing is cached on any path, and only on the `person-oauth` one is that a decision. There, the + * refresh token is exchanged for an access token per call and the access token is thrown away, so + * there is no stored copy of anybody's access for a disconnect to have to find. That costs a round + * trip to the vendor's token endpoint on every call, which is the price of revocation being + * complete by construction rather than by cleanup. The other two paths have nothing to cache: a + * `deployment-token` is decrypted out of the vault per call, and a `brokered` key is never held + * here at all. */ async function connectionTokenFor( - row: { - id: string; - url: string; - title: string; - credentialId: string | null; - }, + row: { id: string; title: string; credentialId: string | null }, entry: CatalogueEntry | null, actorId: string, access: ServerAccess, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 9884ae9a2..aed24413c 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3245,8 +3245,8 @@ describe("a vendor reply that is not a token", () => { * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; * the `afterAll` below is what stops the last test's fixtures from outliving the run. * - * The fixtures below this one are `export`ed for one reason: they are shared scaffolding, and a - * fixture whose first caller has not been written yet reads to the linter as dead code. + * {@link seedNotionServer} below is `export`ed for one reason: it is shared scaffolding whose first + * caller has not been written yet, and a fixture nothing calls reads to the linter as dead code. */ async function freshDatabase(): Promise { /* @@ -3291,7 +3291,7 @@ async function freshDatabase(): Promise { * properties under test, and both are decided on the way to the vendor — so the real path has to * run, and the vendor is stubbed further out at {@link useComposioClient}. */ -export async function freshStore() { +async function freshStore() { const database = await freshDatabase(); const persisting = createAuditStore(database); const events: Parameters[0][] = []; @@ -3315,7 +3315,7 @@ export async function freshStore() { } /** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ -export async function seedComposioGmail( +async function seedComposioGmail( database: Database, store: PluginStore, options: { connect?: boolean } = {}, @@ -3483,6 +3483,59 @@ test("a Composio app is listed through the Composio transport, not dialled as MC expect(asked).toEqual(["gmail"]); }); +test("a Composio call with nobody attributed is refused before it reaches the vendor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store); + + // The empty string is what the actor resolves to when nobody could be identified. Reaching the + // vendor with it would run in whatever account Composio has against "", or in nobody's, and either + // way the run is unattributable — the state every identity defect in OpenTag started from. + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); +}); + +test("a Composio call by somebody who has not connected the app is refused with a sentence they can act on", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store, { connect: false }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Refused here rather than at Composio, so a person is told what to do instead of being shown + // somebody else's error, and so no call is spent finding out. + expect(reached).toEqual([]); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From 1cc7b82372b2d3a5d727d0fc7642f76a21ab322e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 19:50:43 +0200 Subject: [PATCH 023/215] Hand the recorded effect to the classifier wherever a tool is classified Both call sites in the plugin store still called classifyTool with three arguments, so every Composio action was classified by the catalogue's hand-written write list. A Composio app has no such list and no catalogue entry at all, so every action read as a write regardless of the effect the vendor recorded when the action was listed. callTool now selects the recorded effect alongside the input schema and passes it; listServers already selected every column, so its tool map only needed the argument. destructive and version are selected in callTool but not yet read: version is consumed by a later change and destructive by the confirmation card, and selecting them now keeps that a one-line diff rather than a re-shaped query. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index edd425881..0c4c48663 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2278,7 +2278,7 @@ export function createPluginStore(options: PluginStoreOptions) { description: tool.description, inputSchema: tool.inputSchema as Record, ref, - effect: classifyTool(entry, tool.name, true), + effect: classifyTool(entry, tool.name, true, tool.effect), grantedTo: grants.get(ref) ?? [], }; }), @@ -2984,14 +2984,25 @@ export function createPluginStore(options: PluginStoreOptions) { const { row, entry, access } = await requireServer(serverId); const advertised = await database - .select({ name: mcpTools.name, inputSchema: mcpTools.inputSchema }) + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) .from(mcpTools) .where( and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)), ) .limit(1); - const effect = classifyTool(entry, toolName, advertised.length > 0); + const effect = classifyTool( + entry, + toolName, + advertised.length > 0, + advertised[0]?.effect, + ); const args = withoutEmptyOptionals( input.args, From 9b1b060502ba84c8b2cc758e21bbb93151696b87 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:10:48 +0200 Subject: [PATCH 024/215] Test both places a brokered action's effect is read, not just the call path The recorded effect was already being selected and passed at both call sites, but nothing asserted the result at either one. The call path's audit row and the admin page's listing both depended on it and neither would have said so if the argument went away. The admin-page site had no coverage at all, and it needed nothing deferred to test it: listServers reads the seeded row directly, so a plain assertion on the returned tool's effect is enough. Each test now fails only when its own call site regresses. Reverting the callTool call to three arguments fails the audit-row test alone; reverting the listServers call fails the listing test alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index aed24413c..374fe7b4d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3623,3 +3623,56 @@ test("an action listed before these columns existed reads as unclassified and un // reclassify every already-listed Notion read as a write the moment the migration ran. expect(row).toEqual({ effect: null, destructive: false, version: null }); }); + +test("a brokered call is judged by the effect the vendor recorded, not by the absent catalogue entry", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => ({}), + }); + // `effect: "read"` on the seeded action, and no catalogue entry for `gmail` at all — so the two + // sources disagree and the row records which one decided. + expect(catalogueEntry("gmail")).toBeNull(); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The call itself does not complete: nothing hands the transport the recorded version yet, so it + // refuses rather than guessing one. Irrelevant to what is under test — `effect` is decided before + // the vendor is dialled and the failure row carries the decision either way, which is the whole + // point of holding `decided` rather than writing it. + expect(result.isError).toBe(true); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_failed"); + // "read", because Composio labelled the action and the classifier prefers that label. "write" is + // what an unlisted-in-`writeTools` tool on a server with no entry behind it comes out as, and that + // is what this row said while the recorded effect was being selected and never passed on: every + // Gmail read gated as a write, and `intent` in the policy context reading `write_tool`. + expect((call?.payload as { effect?: string } | undefined)?.effect).toBe( + "read", + ); +}); + +test("the Plugins page shows a brokered action with the effect the vendor recorded", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Same disagreement as the call path, on the surface an administrator reads: no catalogue entry + // behind `gmail`, so the reviewed-list branch has nothing to say and would call every one of the + // app's actions a write. Shown as a write, this page tells an administrator that granting a Bot + // "fetch emails" grants it something that changes their mailbox. + expect( + gmail?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); +}); From 20c738c190723a034cb8c73e61af60987eb5855c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:13:11 +0200 Subject: [PATCH 025/215] Say in the classifier's own doc that a vendor's label can earn a read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block claimed a server with no catalogue entry behind it was a write throughout, because nothing reviewed said any tool of theirs only reads. A test in this same branch asserts the opposite: composio-classify.test.ts takes a recorded `read` on a null entry and expects a read, as does the store integration test just added for the brokered call path. It also described the advertised-and-absent-from-the-write-list case as the only way to produce a read, and said so as "it is the only one". A second read-producing case has existed unmentioned since the recorded effect was introduced: an advertised action whose recorded effect is exactly `read`, which is a read regardless of the write list and regardless of whether an entry exists. The doc now names both sources, the order they are consulted in, and both ways a read can be earned. The reconciliation helper's justification inherited the same stale premise. Its conclusion is still right — an entry-less server is not reconciled — but the reason is no longer that all of its tools are already writes. It is that a brokered app is classified from the vendor's per-action label rather than from a hand-written list, so there is no under-inclusion here to find. Comments only; no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 20 +++++++++++++------- server/src/plugins/store.ts | 5 +++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index ab44f70be..4205fd959 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -361,14 +361,20 @@ export function resolveServerUrl( /** * What this tool does, in the only two categories a policy author cares about. * - * Unknown counts as a write. A tool named in {@link CatalogueEntry.writeTools} is a write. A tool - * the server never advertised at all is a write, because the only thing that produced the name was - * a model. A server with no catalogue entry behind it is a write throughout, because nothing - * reviewed says any tool of theirs only reads. + * TWO SOURCES, CONSULTED IN ORDER. What the vendor recorded about this action when it was listed + * comes first: exactly `read` is a read, and anything else — a recorded write, an unrecognised + * value, a different case — is a write. Only where nothing was recorded does the reviewed write list + * decide, and there a tool the entry names is a write while one it does not name is a read. * - * Only a tool the server itself listed AND that is absent from the write list is treated as a read. - * That is the one case where both sources agree, and it is the only one where guessing permissively - * is recoverable. + * Unknown counts as a write throughout. A tool the server never advertised at all is a write, + * because the only thing that produced the name was a model. A server with no catalogue entry behind + * it is a write unless the vendor recorded a read for that action — there is no reviewed list to + * consult, so an unlabelled action of theirs has nothing saying it is safe. + * + * So there are two ways to earn a read, and both require somebody to have said so. Either the vendor + * labelled the action a read, or the server advertised it and a reviewed list declined to call it a + * write. Guessing permissively is recoverable only in those two cases; everywhere else the answer is + * a write. */ export function classifyTool( entry: CatalogueEntry | null, diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0c4c48663..8af7a3e21 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -293,8 +293,9 @@ export function refFromToolName(toolName: string): string | null { * the vendor. Naming those would be noise in front of the one case that has no second barrier at all * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty. * - * A server with no catalogue entry is not reconciled either, and for the opposite reason: nothing - * reviewed says any tool of theirs only reads, so all of them are already writes. + * A server with no catalogue entry is not reconciled either, and for a different reason: a brokered + * app's actions are classified from the vendor's own per-action label rather than from a list here, + * so there is no hand-written under-inclusion to find. * * Sorted, so two readings of the same listing produce the same row. */ From ac45f16781fead492f44a1e631b674dbfe9b2890 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:28:49 +0200 Subject: [PATCH 026/215] Name both entry-less shapes in the reconciliation helper's reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph justifying the early return covered one shape at a time and swapped which one it was wrong about. The sentence this branch deleted was true of a server an administrator added by URL and false of a brokered app; its replacement was true of the brokered app and silent-to-misleading for the other, while its opening clause still matched both. A reader with a custom server in mind matched on the opening and was handed a reason that does not hold for them — nothing populates a per-action label for an MCP server, and nothing will, so every tool it offers stays a write. Both shapes are now named with the reason that actually applies to each. Comment only; the return itself was right throughout. --- server/src/plugins/store.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 8af7a3e21..5b88817da 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -293,9 +293,11 @@ export function refFromToolName(toolName: string): string | null { * the vendor. Naming those would be noise in front of the one case that has no second barrier at all * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty. * - * A server with no catalogue entry is not reconciled either, and for a different reason: a brokered - * app's actions are classified from the vendor's own per-action label rather than from a list here, - * so there is no hand-written under-inclusion to find. + * A server with no catalogue entry is not reconciled either, and the two shapes that reach here do + * so for different reasons. A brokered app's actions are classified from the vendor's own + * per-action label rather than from a list here, so there is no hand-written under-inclusion to + * find. A server an administrator added by URL has neither a label nor a list, so every tool it + * offers is already a write and there is no wrongly-permitted read to reconcile. * * Sorted, so two readings of the same listing produce the same row. */ From f7a99112db0219dd6e0bf060df76bf3b7c605439 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 20:37:26 +0200 Subject: [PATCH 027/215] Record what a refresh learned about each action Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5b88817da..84494c0ca 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2121,6 +2121,16 @@ export function createPluginStore(options: PluginStoreOptions) { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes any of this today, so all three stay null or false for + * every other transport — which is what keeps `classifyTool` falling back to the + * curated write list for Notion and Drive exactly as it did before. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version ?? null, })), ); } From f8f95a9a71b3d8e3c78a0e6f18c3757ef674c889 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:49:32 +0200 Subject: [PATCH 028/215] Gate the three columns a refresh now writes, and guard the two vendors it must not touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path shipped with nothing exercising it. `refreshTools` records an effect, a destructive marker and a version for every action a listing returns, and all three keys could be deleted from that insert one at a time with the whole suite staying green. Each deletion, and a revert of the block entire, is now a failure of the Composio refresh test: it asserts the three answers the vendor's listing gave — a label, a marker, and the version a call is impossible without — against the three columns the rows come back with. The nearest thing that already existed inserts into `mcp_tools` directly and never calls the refresh, so what it asserted was that the column round-trips a value, not that the refresh is what puts one there. A column that round-trips is not a column anything writes to. The second test is not a gate for this change but for its opposite. No Notion or Drive refresh may come out of that insert any differently than it did before: those transports publish none of the three, so their rows have to keep a null recorded effect, and `classifyTool` has to go on deciding from the reviewed write list — a read for the name it does not cover, a write for the one it does. So it passes both with the hunk and without it, and goes red only when the coalesce is altered to record a write where the vendor said nothing, which is the mutation that would silently reclassify every Notion and Drive read and change what the policy engine refuses for connectors nobody touched. Across the store, plugin-routes, Drive, routines and Composio-transport suites it is the only test that catches that. It sits inside the evicted-dynamic-client suite because that is the only place in this file where a `user-oauth` listing can be made to really happen. The plan's version of it refreshed a per-person server as somebody holding no grant, so the refresh refused, no rows were written, and an `every`-style assertion over an empty list was trivially true — it was green under the very mutation above. This one connects a grant first, asserts the listing returned two tools, and compares both rows by value, so an empty list fails. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 374fe7b4d..6c56c19a0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2612,6 +2612,126 @@ describe("a dynamic client the vendor has evicted", () => { expect(named).not.toContain("notion-create-pages"); }); + /** + * The classification an MCP server has always had, over a real listing that really happened. + * + * The three columns the refresh now writes are Composio's, and every other transport has to keep + * coming out of that insert as null and false — because `classifyTool` prefers a recorded effect to + * the reviewed write list, so a value appearing here for Notion would silently reclassify every one + * of its reads as a write, on a connector nobody touched. Asserted on the rows AND on what the + * Plugins page derives from them, because it is the second one that an administrator reads. + * + * It lives in this suite because this is the only place a `user-oauth` listing can actually be + * made to happen: the refresh runs on the grant of whoever pressed the button, so a Notion row with + * nobody connected records a refusal in `lastError` and writes no tools at all — which is a test + * that passes by having nothing to check. + */ + test("a refreshed MCP server records no effect, no marker and no version", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + const mock = new MCPMock(); + mock + .addTool({ + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, + }) + .addTool({ + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }); + const mockUrl = await mock.start(); + + // What the deployment currently advertises for this server, because a refresh replaces the list + // wholesale and this one is pointing the vendor at a mock. + const advertisedBefore = await database + .select() + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)); + const [stampBefore] = await database + .select({ + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + lastError: mcpServers.lastError, + }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + try { + // Two tools listed, so the assertions below have something to be about: `every` over an empty + // list is true, and a refusal recorded in `lastError` would leave exactly that. + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "notion-create-pages", + effect: null, + destructive: false, + version: null, + }, + { + name: "notion-fetch", + effect: null, + destructive: false, + version: null, + }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // The reviewed write list, still deciding: the name it covers is a write and the name it does + // not is a read. A recorded effect on either row is what would take this over. + expect( + listed?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + } finally { + globalThis.fetch = realFetch; + await mock.stop?.(); + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)); + if (advertisedBefore.length > 0) { + await database.insert(mcpTools).values(advertisedBefore); + } + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, + lastError: stampBefore?.lastError ?? null, + }) + .where(eq(mcpServers.id, dynamicServerId)); + } + }); + /** * What a failed refresh writes into `lastError`, and how much of it. * @@ -3676,3 +3796,78 @@ test("the Plugins page shows a brokered action with the effect the vendor record gmail?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); }); + +test("refreshing a Composio app records each action's effect, destructive marker and version", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + inputParameters: { type: "object", properties: {} }, + tags: ["destructiveHint"], + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + }); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")) + .orderBy(asc(mcpTools.name)); + + // The vendor's own three answers, as the listing gave them: a label, a marker, and the version a + // call is impossible without. `createHint` is a write with no marker — an ordinary write is not + // dangerous, and marking it so teaches an approver to click through the colour. + expect(rows).toEqual([ + { + name: "GMAIL_DELETE_MESSAGE", + effect: "write", + destructive: true, + version: "20260903_00", + }, + { + name: "GMAIL_FETCH_EMAILS", + effect: "read", + destructive: false, + version: "20260903_00", + }, + { + name: "GMAIL_SEND_EMAIL", + effect: "write", + destructive: false, + version: "20260903_00", + }, + ]); +}); From b1170926c1a747a5c0172ba82b637fc055dcaab0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:54:24 +0200 Subject: [PATCH 029/215] Call a Composio action at the version it was listed at The transport has refused every brokered call for want of a version since it started requiring one: the recorded version was selected in `callTool` and never passed on. It now travels to the vendor under the reserved key the Composio transport strips before dispatch. Merged last, after the model's own arguments, so a `__version` a model supplied cannot decide which revision of an action runs. Absent when the app has not been refreshed since the column existed, which the transport still refuses on rather than guessing. The audit payload is unchanged and derives from the decision, not from the arguments, so the reserved key never reaches the trail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 84494c0ca..3e98e1f4d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -41,6 +41,7 @@ import { serverCredentialKind, } from "./catalogue"; import { accessFor, type ServerAccess } from "./access"; +import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -3022,6 +3023,25 @@ export function createPluginStore(options: PluginStoreOptions) { advertised[0]?.inputSchema as Record | undefined, ); + /* + * The version this action was listed at, handed to the transport that needs one. + * + * Under a reserved key rather than as a parameter on the shared signature, because that + * signature is MCP's and three other transports implement it. The Composio transport strips the + * key before anything reaches the vendor, and asserts that it did. + * + * The recorded version is merged LAST, so a `__version` a model supplied in its own arguments + * cannot win. Reversing the spread would let a model choose which revision of an action runs — + * a revision that was never listed, classified or granted. + * + * Absent when the app has not been refreshed since the column existed, which the transport + * refuses on rather than guessing — a guessed version is a call against an action's other + * behaviour. + */ + const vendorArgs = advertised[0]?.version + ? { ...args, [VERSION_ARG]: advertised[0].version } + : args; + /** * The same policy the computer actions are judged by, asked about a tool call. * @@ -3181,7 +3201,7 @@ export function createPluginStore(options: PluginStoreOptions) { botId: input.botId, }, toolName, - args, + vendorArgs, ); await recordAuditEvent(auditStore, { eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", From b253c99c5748362909b55293635a149733517be4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 21:54:32 +0200 Subject: [PATCH 030/215] Close the two audit gates that were waiting on a call that could complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests were specified five and four waves ago and could not pass: no Composio call could complete for want of a version, so every brokered call ended in a refusal from the transport and the event written was a failure rather than a success. The audit gate has therefore been green-under-mutation since it was written — nothing could reach the success row it asserts about. The version test catches passing `args` instead of `vendorArgs` to the vendor call. The audit test catches `reachedAsFor` deriving whose account was reached from the entry's auth kind rather than from the resolved descriptor, and `callTool` resolving its transport from the entry-derived shape rather than from `access.transport`. The effect test above them asserted the refusal as an aside and said so in a comment; with a version now sent, the call completes and the row it reads is `mcp.call_succeeded`. The property it tests is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 6c56c19a0..90e0ea7b8 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3656,6 +3656,56 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call sends the version recorded for that action", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + +test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => ({ messages: [] }), + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + const call = auditStore + .recorded() + .find((event) => event.eventType === "mcp.call_succeeded"); + + // The single question a per-person connector raises: whose account did this reach. Recorded as + // "deployment", the trail is wrong about exactly the thing this connector exists for — two rows for + // the same action and the same Bot can have touched two different mailboxes, and nothing else in + // the row says which. + expect(call?.payload).toMatchObject({ reachedAs: "user_asker" }); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); @@ -3762,15 +3812,14 @@ test("a brokered call is judged by the effect the vendor recorded, not by the ab actorId: "user_asker", }); - // The call itself does not complete: nothing hands the transport the recorded version yet, so it - // refuses rather than guessing one. Irrelevant to what is under test — `effect` is decided before - // the vendor is dialled and the failure row carries the decision either way, which is the whole - // point of holding `decided` rather than writing it. - expect(result.isError).toBe(true); + // The call completes: the transport is handed the version this action was listed at. Irrelevant to + // what is under test — `effect` is decided before the vendor is dialled and the row carries the + // decision on either outcome, which is the whole point of holding `decided` rather than writing it. + expect(result.isError).toBe(false); const call = auditStore .recorded() - .find((event) => event.eventType === "mcp.call_failed"); + .find((event) => event.eventType === "mcp.call_succeeded"); // "read", because Composio labelled the action and the classifier prefers that label. "write" is // what an unlisted-in-`writeTools` tool on a server with no entry behind it comes out as, and that // is what this row said while the recorded effect was being selected and never passed on: every From ab46ac58afec927503259b3df61822e8c2975eb7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:02:27 +0200 Subject: [PATCH 031/215] Stop a model choosing which revision of an action it calls `callTool` hands the Composio transport the version the action was listed at by merging it into the caller's arguments. The merge order is the whole of the protection: the recorded version goes in LAST, so a `__version` a model put into its own arguments is overwritten rather than honoured. Nothing strips a non-empty value on the way in, so that spread is the only thing standing between a model's chosen version and the vendor. Nothing in the repository detected the reversal. Flipping it to `{ [VERSION_ARG]: advertised[0].version, ...args }` left the store integration and transport suites entirely green -- no test put a `__version` into the caller's own arguments through the store, and the tests that mention the key call the transport directly, so they bypass the merge. Reversed, a model could pick which revision of a granted action runs: a revision that was never listed, never classified and never granted, whose arguments and behaviour are not the ones an administrator reviewed. The audit row would name the action and the person it ran as, but not which revision of it actually executed -- so the trail could not answer the one question the defect raises. This test asks for a seeded action whose recorded version is `20260903_00` while supplying `19700101_00` in the model's own arguments, and asserts the vendor was called with the recorded one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 90e0ea7b8..f26b84fa3 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3680,6 +3680,36 @@ test("a Composio call sends the version recorded for that action", async () => { ]); }); +test("a version a model supplied in its own arguments cannot beat the recorded one", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model filling in the reserved key itself. It reaches `args` intact — nothing strips a + // non-empty value — so the only thing standing between it and the vendor is that the recorded + // version is merged after it. + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The listed revision, not the one the model asked for. Reversed, this call would run against a + // revision that was never listed, never classified and never granted — and the audit row would + // name the action without naming which of its revisions actually ran. + expect(calls).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 80d411771c96038dec441705686a8350fc016953 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:22:28 +0200 Subject: [PATCH 032/215] Strip the reserved version key before trusting either branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge order that guards `__version` covered only one of the two branches: where a version was recorded, merging it last meant a `__version` a model put in its own arguments could not win. Where none was recorded, `vendorArgs` was `args` itself, so the model's key was the only version present and the transport honoured it — nothing strips it on the way in, since `withoutEmptyOptionals` filters only on the empty string. That ran a granted action at a revision that was never listed, never classified and never granted, with arguments and behaviour that are not the ones an administrator reviewed. The audit row carries no version field, so the trail named the action and the person but could not say which revision actually ran. Reachable rather than a migration artifact: `ComposioAction.version` is optional and the transport maps it conditionally, so any action Composio lists without a version leaves a null column on a row that stays granted and callable. The key is now dropped unconditionally, before the recorded one is merged, so the absent-version case refuses at the transport exactly as its comment always claimed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 3e98e1f4d..fd7434b97 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3034,13 +3034,21 @@ export function createPluginStore(options: PluginStoreOptions) { * cannot win. Reversing the spread would let a model choose which revision of an action runs — * a revision that was never listed, classified or granted. * - * Absent when the app has not been refreshed since the column existed, which the transport - * refuses on rather than guessing — a guessed version is a call against an action's other + * Absent when the app has not been refreshed since the column existed, and because the key is + * stripped below there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's other * behaviour. */ + + /* + * A `__version` in the model's own arguments is not an argument: it is this key, and no vendor + * publishes it. Dropped before the recorded one is merged, so the absent-version case refuses at + * the transport rather than running at a revision a model chose. + */ + const { [VERSION_ARG]: _dropped, ...modelArgs } = args; const vendorArgs = advertised[0]?.version - ? { ...args, [VERSION_ARG]: advertised[0].version } - : args; + ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } + : modelArgs; /** * The same policy the computer actions are judged by, asked about a tool call. From bfd8506c2a2ba79c3a29a132828b52ea2b45cc04 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:22:36 +0200 Subject: [PATCH 033/215] Gate the branch where no version was recorded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test covers the branch that has a recorded version: a model's `__version` loses to it because the recorded one is merged last. The other branch had nothing merged after the model's key, and no test asked what happened there. Seeded with `version: null` — the row an action Composio lists without a version leaves behind — the call must come back as the transport's refusal naming the refresh, and the vendor stub's `execute` must never be reached. Before the fix it was reached, at `19700101_00`: the version the model named, not one this deployment ever listed. `seedComposioGmail` takes the version as an option beside `connect`, so the fixture ids the suite owns outright stay the ones the ownership guard and teardown cover. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index f26b84fa3..5ae6fa0c1 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3434,11 +3434,17 @@ async function freshStore() { return { store, database, auditStore }; } -/** A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. */ +/** + * A Composio Gmail app, one granted read action, one Bot, and optionally a connected person. + * + * `version: null` is the action Composio listed without one — a granted, callable row whose version + * column is null, which is a state the vendor's own optional field produces rather than a leftover + * from before the column existed. + */ async function seedComposioGmail( database: Database, store: PluginStore, - options: { connect?: boolean } = {}, + options: { connect?: boolean; version?: string | null } = {}, ) { await database.insert(mcpServers).values({ id: "gmail", @@ -3452,7 +3458,7 @@ async function seedComposioGmail( name: "GMAIL_FETCH_EMAILS", description: "Fetch emails.", effect: "read", - version: "20260903_00", + version: options.version === undefined ? "20260903_00" : options.version, }); await database.insert(agents).values({ id: "bot_helper", @@ -3710,6 +3716,39 @@ test("a version a model supplied in its own arguments cannot beat the recorded o ]); }); +test("a version a model supplied cannot stand in for an action with none recorded", async () => { + const { store, database } = await freshStore(); + const calls: { slug: string; version: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, _userId, version) => { + calls.push({ slug, version }); + return {}; + }, + }); + // The action with no recorded version, which is the branch the test above does not cover: there is + // nothing to merge last, so the merge order guards nothing and the model's key is the only version + // in the arguments. + await seedComposioGmail(database, store, { version: null }); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: { __version: "19700101_00" }, + botId: "bot_helper", + actorId: "user_asker", + }); + + // The transport's refusal, which is the advertised answer for an action with no recorded version — + // and an operator's one-click fix, rather than a call against a revision a model named. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); + + // Never dialled. Honoured, the model's version runs a granted action at a revision that was never + // listed, never classified and never granted, and the audit row carries no version field to say + // which revision that was. + expect(calls).toEqual([]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 010e7e8ea22c6c647bdb9cf16dcc2c0677108e56 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 9 Sep 2026 22:34:21 +0200 Subject: [PATCH 034/215] Name the strip as what protects the version, not the merge order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unconditional strip of the reserved key made both spread orders identical: the recorded version is merged into arguments that provably cannot carry the key, so merge order no longer has a reachable failure mode. Three comments still presented that order as the protection. A reader who believes it concludes the strip is redundant belt-and-braces, removes it, and reopens a real hole — with the suite green. They now name the strip as the mechanism and the ordering as the redundant half. The test guarding the order is consequently inert: it fails under none of a full revert of the strip, a strip-present-but-fallback-raw, or a reversal, and its assertion is already covered elsewhere. It is kept for the property it documents, and now says plainly that it is not a mutation gate rather than reading as one. In the remaining gate, the never-dialled assertion moves above the two refusal assertions, so a regression names the version that reached the vendor instead of reporting `Expected: true / Received: false`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 28 +++++++------ server/tests/plugin-store.integration.test.ts | 39 ++++++++++++------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index fd7434b97..edbec295e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3027,23 +3027,21 @@ export function createPluginStore(options: PluginStoreOptions) { * The version this action was listed at, handed to the transport that needs one. * * Under a reserved key rather than as a parameter on the shared signature, because that - * signature is MCP's and three other transports implement it. The Composio transport strips the - * key before anything reaches the vendor, and asserts that it did. + * signature is MCP's and three other transports implement it. The Composio transport strips + * the key before anything reaches the vendor, and asserts that it did. * - * The recorded version is merged LAST, so a `__version` a model supplied in its own arguments - * cannot win. Reversing the spread would let a model choose which revision of an action runs — - * a revision that was never listed, classified or granted. + * A `__version` in the model's own arguments is not an argument: it is this key, and no + * vendor publishes it. So it is stripped unconditionally, whatever its value, and that strip + * is the whole protection. The recorded version is then merged into arguments that provably + * cannot carry the key, which makes both spread orders identical: the merge order has no + * reachable failure mode. Do not read the strip as belt-and-braces on top of an ordering + * guarantee — the ordering is the redundant half, and removing the strip is what would let a + * model choose which revision of an action runs. * - * Absent when the app has not been refreshed since the column existed, and because the key is - * stripped below there is then no version at all for the transport to read, which is what makes - * its refusal hold rather than guessing — a guessed version is a call against an action's other - * behaviour. - */ - - /* - * A `__version` in the model's own arguments is not an argument: it is this key, and no vendor - * publishes it. Dropped before the recorded one is merged, so the absent-version case refuses at - * the transport rather than running at a revision a model chose. + * Absent when the app has not been refreshed since the column existed, and because the key + * was stripped there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's + * other behaviour. */ const { [VERSION_ARG]: _dropped, ...modelArgs } = args; const vendorArgs = advertised[0]?.version diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5ae6fa0c1..ca1e19775 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3686,6 +3686,15 @@ test("a Composio call sends the version recorded for that action", async () => { ]); }); +/* + * The composite end-to-end outcome: a model that supplies the reserved key itself does not change + * which revision runs. What holds it is the unconditional strip above the merge in `store.ts`. + * + * This is not a mutation gate, and no single mutation isolates it: reverting the strip, keeping the + * strip but falling back to the raw arguments, and reversing the spread all leave it green, and its + * assertion is already covered by `a Composio call sends the version recorded for that action`. It + * is kept because the property is one somebody will want to confirm, not because it guards it. + */ test("a version a model supplied in its own arguments cannot beat the recorded one", async () => { const { store, database } = await freshStore(); const calls: { slug: string; version: string }[] = []; @@ -3700,17 +3709,16 @@ test("a version a model supplied in its own arguments cannot beat the recorded o await store.callTool({ ref: "gmail/GMAIL_FETCH_EMAILS", - // A model filling in the reserved key itself. It reaches `args` intact — nothing strips a - // non-empty value — so the only thing standing between it and the vendor is that the recorded - // version is merged after it. + // A model filling in the reserved key itself. Stripped unconditionally, non-empty value and + // all, before the recorded version is merged — so it never reaches the vendor under either + // spread order. args: { __version: "19700101_00" }, botId: "bot_helper", actorId: "user_asker", }); - // The listed revision, not the one the model asked for. Reversed, this call would run against a - // revision that was never listed, never classified and never granted — and the audit row would - // name the action without naming which of its revisions actually ran. + // The listed revision, not the one the model asked for: supplying the reserved key changed + // nothing about which revision ran. expect(calls).toEqual([ { slug: "GMAIL_FETCH_EMAILS", version: "20260903_00" }, ]); @@ -3726,9 +3734,9 @@ test("a version a model supplied cannot stand in for an action with none recorde return {}; }, }); - // The action with no recorded version, which is the branch the test above does not cover: there is - // nothing to merge last, so the merge order guards nothing and the model's key is the only version - // in the arguments. + // The action with no recorded version, which is the branch the test above does not cover: there + // is nothing to merge in, and because the model's key was stripped there is no version in the + // arguments at all — which is what the transport refuses on. await seedComposioGmail(database, store, { version: null }); const result = await store.callTool({ @@ -3738,15 +3746,18 @@ test("a version a model supplied cannot stand in for an action with none recorde actorId: "user_asker", }); - // The transport's refusal, which is the advertised answer for an action with no recorded version — - // and an operator's one-click fix, rather than a call against a revision a model named. - expect(result.isError).toBe(true); - expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); - // Never dialled. Honoured, the model's version runs a granted action at a revision that was never // listed, never classified and never granted, and the audit row carries no version field to say // which revision that was. + // + // Asserted before the refusal, so a regression fails here and names the version that reached the + // vendor, rather than failing on a boolean that names nothing. expect(calls).toEqual([]); + + // The transport's refusal, which is the advertised answer for an action with no recorded version — + // and an operator's one-click fix, rather than a call against a revision a model named. + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); }); test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { From 1447a5513f25f389ca84544bc6716024cd6e4b1f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:03:27 +0200 Subject: [PATCH 035/215] Make the unlabelled-action canary able to fail Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts index fa499d87d..39d478d24 100644 --- a/server/tests/composio-live.test.ts +++ b/server/tests/composio-live.test.ts @@ -53,9 +53,9 @@ describe.skipIf(!live)("Composio, for real", () => { (action) => !(action.tags ?? []).some( (tag) => tag === "readOnlyHint" || tag === "destructiveHint", - ) && effectOf(action.tags).effect === "write", + ), ); - expect(unlabelled.length).toBeGreaterThanOrEqual(0); + expect(unlabelled).toEqual([]); const reads = actions.filter( (action) => effectOf(action.tags).effect === "read", From 72a84a5e55496b21566245eb3ed872ff72522f32 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:06:16 +0200 Subject: [PATCH 036/215] Resolve which Composio app a row is once, from the url the call dials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app was derived three ways — the row id in the connection gate, the url in the transport, and a column of its own in the schema — with nothing comparing them, so a row named `gmail` at `composio://slack` was checked against a Gmail connection and then run as Slack. It now comes off the url beside the rest of the descriptor, because the url is what the transport dials, and a brokered row without one is refused rather than falling back to the id. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 29 +++++++- server/src/plugins/store.ts | 28 ++++++- server/tests/composio-access.test.ts | 73 ++++++++++++++++--- server/tests/google-drive-rest.test.ts | 16 +++- server/tests/plugin-store.integration.test.ts | 38 +++++++++- 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index da0edb302..15c339377 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -1,8 +1,10 @@ import type { CatalogueEntry } from "./catalogue"; +import { toolkitOf } from "./composio"; import type { TransportKind } from "./transport"; /** - * How one server row is reached: which protocol, whose credential, and whose name the trail records. + * How one server row is reached: which protocol, whose credential, which app at a broker, and whose + * name the trail records. * * WHY THIS EXISTS AS ONE THING. These three questions were asked separately, in three places, each * deriving its own answer from whichever field was nearest. That was complete while every server @@ -10,6 +12,12 @@ import type { TransportKind } from "./transport"; * is a row an operator enabled, with no entry to carry a transport field and no OAuth kind to read, * so all three questions answered wrongly by default and each failed silently in its own direction. * + * The fourth question arrived the same way. Which app a brokered row is was read from the row id by + * the gate that checks whether a person has connected it, from the url by the transport that dials + * it, and described as a third thing by the schema column that records the connection — with nothing + * comparing the three, so a row whose id and url slug differed was checked against one app and run + * against another. + * * Resolved once, here, and read as a field everywhere else. A fourth kind of server cannot be added * without filling in this function, and the test beside it enumerates every row shape that exists — * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three @@ -43,6 +51,20 @@ export type ServerAccess = { * naming the asker would assert an attribution that does not exist. */ reachedAs: "person" | "deployment"; + /** + * Which app at the broker this row is, and null for a row that is not brokered at all. + * + * Read from the URL, because the URL is what the transport dials — so the app a person is checked + * against is the same app the call runs in, by construction rather than by two spellings agreeing. + * The row id is a display key: it is what an operator sees and what a grant names, and nothing + * keeps it equal to the slug in the URL. Deriving the app from it meant a row could pass the "has + * this person connected this app" gate on one spelling and run against another. + * + * Null everywhere else, because there is no app: an MCP endpoint and a per-person OAuth vendor are + * reached at an address, not at a broker, and a caller that finds null where it needs a toolkit is + * looking at a row it should not be brokering. + */ + toolkit: string | null; }; const CREDENTIAL_BY_AUTH: Record< @@ -91,7 +113,7 @@ const REACHED_AS_BY_AUTH: Record< * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. */ export function accessFor( - row: { provenance: string }, + row: { provenance: string; url: string }, entry: CatalogueEntry | null, ): ServerAccess { if (entry) { @@ -99,6 +121,7 @@ export function accessFor( transport: entry.transport ?? "mcp", credential: CREDENTIAL_BY_AUTH[entry.auth.kind], reachedAs: REACHED_AS_BY_AUTH[entry.auth.kind], + toolkit: null, }; } @@ -107,6 +130,7 @@ export function accessFor( transport: "composio", credential: "brokered", reachedAs: "person", + toolkit: toolkitOf(row.url), }; } @@ -114,5 +138,6 @@ export function accessFor( transport: "mcp", credential: "deployment-token", reachedAs: "deployment", + toolkit: null, }; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index edbec295e..66419d91e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -805,12 +805,38 @@ export function createPluginStore(options: PluginStoreOptions) { ); } + /* + * Narrowing, not a second decision. + * + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is non-null + * for every row whose credential resolved to `brokered` — but the descriptor declares it + * nullable because every other kind of row has no broker to name an app at, and the compiler + * cannot follow the credential back to the url it was derived beside. + * + * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both + * alternatives fail open. Falling back to `row.id` checks the connection against a spelling + * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose + * whole purpose is to keep one person's account out of another's. + */ + if (!access.toolkit) { + throw new Error( + `${row.id} resolves to a brokered credential with no Composio app in its url.`, + ); + } + + /* + * Keyed on the app the call will run in, which is the one the url names. + * + * `row.id` is a display key and nothing holds it equal to the slug in the url, so a row named + * `gmail` at `composio://slack` passed this gate on a Gmail connection and then ran a Slack + * action — the person having connected an app they were never asked about. + */ const [connected] = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) .where( and( - eq(composioConnections.toolkit, row.id), + eq(composioConnections.toolkit, access.toolkit), eq(composioConnections.userId, actorId), ), ) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index 8ff8e7c60..bcee2393d 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -20,18 +20,27 @@ import { catalogueEntry } from "../src/plugins/catalogue"; describe("accessFor", () => { test("a Composio app is dialled through Composio, brokered, and reached as the person", () => { // No entry, because an app an operator enabled is a row and not something we shipped. - expect(accessFor({ provenance: "composio" }, null)).toEqual({ + expect( + accessFor({ provenance: "composio", url: "composio://gmail" }, null), + ).toEqual({ transport: "composio", credential: "brokered", reachedAs: "person", + toolkit: "gmail", }); }); test("a server somebody added by URL is MCP, on the deployment's own token", () => { - expect(accessFor({ provenance: "custom" }, null)).toEqual({ + expect( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ), + ).toEqual({ transport: "mcp", credential: "deployment-token", reachedAs: "deployment", + toolkit: null, }); }); @@ -39,30 +48,44 @@ describe("accessFor", () => { const notion = catalogueEntry("notion"); expect(notion).not.toBeNull(); if (!notion) return; - expect(accessFor({ provenance: "first-party" }, notion)).toEqual({ + const notionUrl = `https://${notion.host}${notion.path}`; + expect( + accessFor({ provenance: "first-party", url: notionUrl }, notion), + ).toEqual({ transport: "mcp", credential: "person-oauth", reachedAs: "person", + toolkit: null, }); }); test("Drive is its REST adapter, on the asking person's own grant", () => { const drive = catalogueEntry("google-drive"); if (!drive) return; - expect(accessFor({ provenance: "first-party" }, drive)).toEqual({ + const driveUrl = `https://${drive.host}${drive.path}`; + expect( + accessFor({ provenance: "first-party", url: driveUrl }, drive), + ).toEqual({ transport: "google-drive-rest", credential: "person-oauth", reachedAs: "person", + toolkit: null, }); }); test("Routines is in-process, with no credential, and acts as the person", () => { const routines = catalogueEntry("routines"); if (!routines) return; - expect(accessFor({ provenance: "first-party" }, routines)).toEqual({ + expect( + accessFor( + { provenance: "first-party", url: "openbot://routines" }, + routines, + ), + ).toEqual({ transport: "builtin-routines", credential: "none", reachedAs: "person", + toolkit: null, }); }); @@ -81,20 +104,50 @@ describe("accessFor", () => { writeTools: [], docsUrl: "https://example.com/docs", }; - expect(accessFor({ provenance: "first-party" }, publicEntry)).toEqual({ + expect( + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + publicEntry, + ), + ).toEqual({ transport: "mcp", credential: "none", reachedAs: "deployment", + toolkit: null, }); }); test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); if (!notion) return; - // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one. - expect(accessFor({ provenance: "composio" }, notion).transport).toBe("mcp"); - expect(accessFor({ provenance: "composio" }, notion).credential).toBe( - "person-oauth", + // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, + // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a + // curated slug is the same tampering by another field. + const shadowed = accessFor( + { provenance: "composio", url: "composio://gmail" }, + notion, ); + expect(shadowed.transport).toBe("mcp"); + expect(shadowed.credential).toBe("person-oauth"); + expect(shadowed.toolkit).toBeNull(); + }); + + test("which app a Composio row is comes from its url, not from its id", () => { + // The id is a display key and the url is what the transport dials, so the url is what decides. + // A row named `gmail` at `composio://slack` used to be checked against a Gmail connection and + // then run as Slack, because three places derived this fact and none of them compared answers. + expect( + accessFor({ provenance: "composio", url: "composio://slack" }, null) + .toolkit, + ).toBe("slack"); + + // No app in the url is no app at all. `store.ts` refuses a brokered row that reaches it, rather + // than falling back to the id — see the narrowing throw beside its connection gate. + expect( + accessFor( + { provenance: "composio", url: "https://example.com/mcp" }, + null, + ).toolkit, + ).toBeNull(); }); }); diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index ac6ae0166..2700c5293 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -49,8 +49,12 @@ describe("the adapter is the transport the catalogue asks for", () => { expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. expect( - transportFor(accessFor({ provenance: "first-party" }, entry).transport) - .callTool, + transportFor( + accessFor( + { provenance: "first-party", url: "https://www.googleapis.com" }, + entry, + ).transport, + ).callTool, ).toBe(callTool); }); @@ -59,8 +63,12 @@ describe("the adapter is the transport the catalogue asks for", () => { // Composed through `accessFor`, which is where the absent-entry fallback now lives — and asserted // as MCP rather than as "not Drive", which any wrongly resolved kind would also satisfy. expect( - transportFor(accessFor({ provenance: "custom" }, null).transport) - .callTool, + transportFor( + accessFor( + { provenance: "custom", url: "https://mcp.example.com/mcp" }, + null, + ).transport, + ).callTool, ).toBe(mcpCallTool); }); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index ca1e19775..1a884074b 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3440,17 +3440,21 @@ async function freshStore() { * `version: null` is the action Composio listed without one — a granted, callable row whose version * column is null, which is a state the vendor's own optional field produces rather than a leftover * from before the column existed. + * + * `url` is an option because the id and the url are two fields and nothing holds them equal: a row + * called `gmail` at `composio://slack` is the shape that used to pass the connection gate on one + * spelling and run against the other. The connected person is still connected to `gmail`. */ async function seedComposioGmail( database: Database, store: PluginStore, - options: { connect?: boolean; version?: string | null } = {}, + options: { connect?: boolean; version?: string | null; url?: string } = {}, ) { await database.insert(mcpServers).values({ id: "gmail", title: "Gmail", vendor: "Composio", - url: "composio://gmail", + url: options.url ?? "composio://gmail", provenance: "composio", }); await database.insert(mcpTools).values({ @@ -3662,6 +3666,36 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call whose row id and url name different apps is refused", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is + // the app the call would actually run in. + await seedComposioGmail(database, store, { url: "composio://slack" }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // The gate keys on the app the url names, because that is the app the transport dials. Keyed on + // the row id, this call completed: it ran a Slack action against a Gmail connection, sent the + // version recorded for the Gmail action, and was audited as having reached the asker's own + // account — a person granted one app and dialled into another with nothing noticing. + expect(reached).toEqual([]); +}); + test("a Composio call sends the version recorded for that action", async () => { const { store, database } = await freshStore(); const calls: { slug: string; version: string }[] = []; From 9162ae1169aca7f4df2b8684fc63a967e69f30c0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:07:21 +0200 Subject: [PATCH 037/215] Say which of the two brokered refusals the transport actually repeats The comment claimed a last line in the transport for both, and the transport has no notion of a connection at all: only the unattributed run is refused twice. It now names which case has the twin and why refusing an unconnected asker here still earns its place, since Composio's own error names an account it cannot find rather than the person's next step. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 66419d91e..20dd55cec 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -789,10 +789,17 @@ export function createPluginStore(options: PluginStoreOptions) { /* * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. * - * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below is: - * a person gets a sentence naming the step they can take, and no call is spent at the vendor - * finding out. The transport refuses again as a last line, which is the belt to this braces — - * deleting either one has to turn a test red. + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below + * is: a person gets a sentence naming the step they can take, and no call is spent at the + * vendor finding out. The transport refuses an unattributed run again as a last line, so + * deleting either that guard or this one has to turn a test red. The unconnected case has no + * such twin: the transport has no notion of a connection at all, so the last line there is + * Composio itself — which is what refusing locally earns its place for, since it turns the + * broker's error about an account it cannot find into a sentence naming the person's own next + * step. + * + * The throw between the two is not a third refusal. It is the narrowing that keeps this gate + * keyed on the app the url names, and its own comment says why neither fallback is available. * * There is no token. The key belongs to the transport and never travels through this function, so * nothing here can leak it into a connection object, an error or an audit row. From 112d884125b95604f7770a39a938bc6c1fb388c1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:16:20 +0200 Subject: [PATCH 038/215] Make the Notion listing mock restore what it overwrote, in one place Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 373 ++++++++---------- 1 file changed, 158 insertions(+), 215 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1a884074b..86fd6a221 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -8,7 +8,7 @@ import { test, } from "bun:test"; import { randomUUID } from "node:crypto"; -import { MCPMock } from "@copilotkit/aimock/mcp"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; @@ -175,13 +175,15 @@ let ownsFixtureIds = false; * * The suites above own suite-scoped ids and only ever READ the deployment's own rows, so skipping a * delete is enough for them — that is what `suiteCreatedServerRow` and its siblings are for. - * The Composio fixtures at the bottom of this file cannot do that: they INSERT at `gmail`, `notion`, - * `bot_helper` and `user_asker`, and those ids are not a choice. `seedNotionServer` needs - * `catalogueEntry("notion")` to resolve to the real catalogue entry, and `gmail` is the toolkit slug - * that gets sent to Composio, so neither can be suffixed. A fixture that inserts at an id cannot - * coexist with a real row at that id: skipping the delete would only turn the collision into a - * primary-key conflict, and capture-and-restore would be a lot of machinery whose failure mode is - * destroying the thing it protects, because the cascade has already run by the time it restores. + * The fixtures in this file cannot do that: they INSERT at `gmail`, `notion`, `bot_helper` and + * `user_asker`, and those ids are not a choice. `gmail` is the toolkit slug that gets sent to + * Composio. `notion` is fixed twice over: the dynamic-registration suite pins `dynamicServerId` to + * it because that is the catalogue entry which registers its own client, and the test for an action + * listed before the effect columns existed inserts a `notion` server row directly, because a + * first-party row is what it is about. A fixture that inserts at an id cannot coexist with a real + * row at that id: skipping the delete would only turn the collision into a primary-key conflict, + * and capture-and-restore would be a lot of machinery whose failure mode is destroying the thing + * it protects, because the cascade has already run by the time it restores. * * What the cascade takes is why this is a refusal rather than a warning. `mcp_user_credentials` * references `mcp_servers.id`, so removing a real `notion` row takes every person's per-user @@ -1703,6 +1705,71 @@ describe("refresh token rotation", () => { }); }); +/** + * A real MCP server on localhost answering as the pinned Notion host, and everything a refresh + * against it overwrites put back afterwards. + * + * The seam is `fetch`: the host is pinned and nothing in the store will take a URL from a caller, so + * pointing the pinned host at the mock is what lets a real listing over the real protocol happen. + * What a refresh then overwrites is the deployment's own row — it replaces the advertised tool list + * wholesale and stamps `toolsRefreshedAt` and `lastError` — so the list and both stamps are read + * first and put back in a `finally`. + * + * A `finally` rather than a paragraph copied per test, because a restore is the part that a test + * still passes without: skip it and the cost lands on whatever runs next, reading a tool list this + * test invented. + */ +async function withMockedNotionListing( + notionServerId: string, + tools: MCPToolDefinition[], + body: () => Promise, +) { + const mock = new MCPMock(); + for (const tool of tools) mock.addTool(tool); + const mockUrl = await mock.start(); + + const advertisedBefore = await database + .select() + .from(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + const [stampBefore] = await database + .select({ + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + lastError: mcpServers.lastError, + }) + .from(mcpServers) + .where(eq(mcpServers.id, notionServerId)); + + const realFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const target = String(input instanceof Request ? input.url : input); + return realFetch( + target.startsWith("https://mcp.notion.com") ? mockUrl : input, + init, + ); + }) as typeof fetch; + + try { + await body(); + } finally { + globalThis.fetch = realFetch; + await mock.stop?.(); + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, notionServerId)); + if (advertisedBefore.length > 0) { + await database.insert(mcpTools).values(advertisedBefore); + } + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, + lastError: stampBefore?.lastError ?? null, + }) + .where(eq(mcpServers.id, notionServerId)); + } +} + /** * A client this deployment registered for itself, which the vendor has since forgotten. * @@ -2524,9 +2591,8 @@ describe("a dynamic client the vendor has evicted", () => { * silent. * * The vendor here is a real MCP server on localhost, reached by pointing the pinned host at it for - * the length of this test. The host is pinned for good reasons and nothing in the store will take a - * URL from a caller, so the seam is fetch — which is also the honest one: what is under test is - * what a real listing over the real protocol produces. + * the length of this test — see {@link withMockedNotionListing}, which also puts back what the + * refresh overwrites. What is under test is what a real listing over the real protocol produces. */ test("a refresh names the advertised tools no write list covers", async () => { await putClient(EVICTED); @@ -2535,65 +2601,30 @@ describe("a dynamic client the vendor has evicted", () => { /** Suite-scoped, so it cannot be a name Notion really advertises, nor a name in `writeTools`. */ const unlistedName = `notion-invent-${suite}`; - const mock = new MCPMock(); - mock - .addTool({ - name: "notion-create-pages", - description: "A write the list already names.", - inputSchema: { type: "object", properties: {} }, - }) - .addTool({ - name: unlistedName, - description: "Advertised, and named by no write list.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - // What the deployment currently advertises for this server, because a refresh replaces the list - // wholesale and this one is pointing the vendor at a mock. - const advertisedBefore = await database - .select() - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - const [stampBefore] = await database - .select({ - toolsRefreshedAt: mcpServers.toolsRefreshedAt, - lastError: mcpServers.lastError, - }) - .from(mcpServers) - .where(eq(mcpServers.id, dynamicServerId)); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - try { - expect( - await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), - ).toEqual({ tools: 2 }); - } finally { - globalThis.fetch = realFetch; - await mock.stop?.(); - await database - .delete(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - if (advertisedBefore.length > 0) { - await database.insert(mcpTools).values(advertisedBefore); - } - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, - lastError: stampBefore?.lastError ?? null, - }) - .where(eq(mcpServers.id, dynamicServerId)); - } + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: unlistedName, + description: "Advertised, and named by no write list.", + inputSchema: { type: "object", properties: {} }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + }, + ); + // Read after the restore, because the audit trail is what the refresh leaves that the restore + // does not take back. const named = ( await database .select({ payload: auditEvents.payload }) @@ -2631,105 +2662,70 @@ describe("a dynamic client the vendor has evicted", () => { await connect(); accepted = new Set([EVICTED.clientId]); - const mock = new MCPMock(); - mock - .addTool({ - name: "notion-fetch", - description: "A read no write list names.", - inputSchema: { type: "object", properties: {} }, - }) - .addTool({ - name: "notion-create-pages", - description: "A write the list already names.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - // What the deployment currently advertises for this server, because a refresh replaces the list - // wholesale and this one is pointing the vendor at a mock. - const advertisedBefore = await database - .select() - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - const [stampBefore] = await database - .select({ - toolsRefreshedAt: mcpServers.toolsRefreshedAt, - lastError: mcpServers.lastError, - }) - .from(mcpServers) - .where(eq(mcpServers.id, dynamicServerId)); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - - try { - // Two tools listed, so the assertions below have something to be about: `every` over an empty - // list is true, and a refusal recorded in `lastError` would leave exactly that. - expect( - await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), - ).toEqual({ tools: 2 }); - - const rows = await database - .select({ - name: mcpTools.name, - effect: mcpTools.effect, - destructive: mcpTools.destructive, - version: mcpTools.version, - }) - .from(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)) - .orderBy(asc(mcpTools.name)); - - expect(rows).toEqual([ + await withMockedNotionListing( + dynamicServerId, + [ { - name: "notion-create-pages", - effect: null, - destructive: false, - version: null, + name: "notion-fetch", + description: "A read no write list names.", + inputSchema: { type: "object", properties: {} }, }, { - name: "notion-fetch", - effect: null, - destructive: false, - version: null, + name: "notion-create-pages", + description: "A write the list already names.", + inputSchema: { type: "object", properties: {} }, }, - ]); - - const listed = (await dynamicStore.listServers()).find( - (server) => server.id === dynamicServerId, - ); + ], + async () => { + // Two tools listed, so the assertions below have something to be about: `every` over an + // empty list is true, and a refusal recorded in `lastError` would leave exactly that. + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 2 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + { + name: "notion-create-pages", + effect: null, + destructive: false, + version: null, + }, + { + name: "notion-fetch", + effect: null, + destructive: false, + version: null, + }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); - // The reviewed write list, still deciding: the name it covers is a write and the name it does - // not is a read. A recorded effect on either row is what would take this over. - expect( - listed?.tools.map((tool) => ({ name: tool.name, effect: tool.effect })), - ).toEqual([ - { name: "notion-create-pages", effect: "write" }, - { name: "notion-fetch", effect: "read" }, - ]); - } finally { - globalThis.fetch = realFetch; - await mock.stop?.(); - await database - .delete(mcpTools) - .where(eq(mcpTools.serverId, dynamicServerId)); - if (advertisedBefore.length > 0) { - await database.insert(mcpTools).values(advertisedBefore); - } - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null, - lastError: stampBefore?.lastError ?? null, - }) - .where(eq(mcpServers.id, dynamicServerId)); - } + // The reviewed write list, still deciding: the name it covers is a write and the name it + // does not is a read. A recorded effect on either row is what would take this over. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); }); /** @@ -3364,9 +3360,6 @@ describe("a vendor reply that is not a token", () => { * ids exists, which makes every row they remove one of this file's own. Cleaning before each test * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; * the `afterAll` below is what stops the last test's fixtures from outliving the run. - * - * {@link seedNotionServer} below is `export`ed for one reason: it is shared scaffolding whose first - * caller has not been written yet, and a fixture nothing calls reads to the linter as dead code. */ async function freshDatabase(): Promise { /* @@ -3483,60 +3476,10 @@ async function seedComposioGmail( ); } -/** Whatever a fixture installed into a global, undone after the test that installed it. */ -const installed: (() => Promise)[] = []; - -/** - * A first-party MCP server, with a real MCP server on localhost standing in for the vendor. - * - * The seam is `fetch`, the way the refresh test above does it: the host is pinned and nothing in the - * store will take a URL from a caller, so pointing the pinned host at the mock is what lets a real - * listing over the real protocol happen. `store` is taken and unused because this vendor needs no - * grant to be listed — the callers hand it over so the two seeds read the same way. - */ -export async function seedNotionServer( - database: Database, - _store: PluginStore, -) { - await database.insert(mcpServers).values({ - id: "notion", - title: "Notion", - vendor: "Notion", - url: "https://mcp.notion.com/mcp", - provenance: "first-party", - }); - - const mock = new MCPMock(); - mock.addTool({ - name: "notion-fetch", - description: "Fetch a page.", - inputSchema: { type: "object", properties: {} }, - }); - const mockUrl = await mock.start(); - - const realFetch = globalThis.fetch; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const target = String(input instanceof Request ? input.url : input); - return realFetch( - target.startsWith("https://mcp.notion.com") ? mockUrl : input, - init, - ); - }) as typeof fetch; - - installed.push(async () => { - globalThis.fetch = realFetch; - await mock.stop?.(); - }); -} - // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); -afterEach(async () => { - while (installed.length > 0) await installed.pop()?.(); -}); - /* * The last test's fixtures, which nothing else would remove. * From 29d91ab5b9533b2be848cdee5141db1654debd17 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:19:02 +0200 Subject: [PATCH 039/215] Gate the two brokered properties the plan named and nothing covered Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 86fd6a221..901cc4b68 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3583,6 +3583,56 @@ test("a Composio call with nobody attributed is refused before it reaches the ve expect(reached).toEqual([]); }); +/** + * The same refusal, with a legal row sitting at the anonymous actor. + * + * `composio_connections.user_id` is text notNull with NO foreign key — the property that lets a + * connection outlive its person, so offboarding can still find it and revoke it at the broker. And + * `notNull` does not exclude the empty string, so a row at `("gmail", "")` is legal: without the + * guard ahead of the lookup, that row IS the match, the connection gate passes, and the run goes + * out in whatever account Composio holds against "". The sibling `user-oauth` path cannot reach + * this state — `mcp_user_credentials.user_id` carries a foreign key to `users.id` — so its test + * asserts only the sentence, and borrowing that shape here would leave this property untested. + * + * A rejection, not a failed result, and that is the assertion doing the work: the transport repeats + * the refusal as its own last line, but it answers with `isError` rather than throwing. So a + * `rejects` here is what separates this gate from its twin downstream of the lookup. + */ +test("a Composio call with nobody attributed is refused even when a connection row exists for the empty actor", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + await seedComposioGmail(database, store); + await database + .insert(composioConnections) + .values({ toolkit: "gmail", userId: "" }); + + try { + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "", + }), + ).rejects.toThrow(/not attributed to anybody/i); + + expect(reached).toEqual([]); + } finally { + // Inline, because `freshDatabase` clears this table by the two people this file invents and the + // anonymous actor is neither — so nothing else in the run would ever remove this row. + await database + .delete(composioConnections) + .where(eq(composioConnections.userId, "")); + } +}); + test("a Composio call by somebody who has not connected the app is refused with a sentence they can act on", async () => { const { store, database } = await freshStore(); const reached: string[] = []; @@ -3977,3 +4027,53 @@ test("refreshing a Composio app records each action's effect, destructive marker }, ]); }); + +/** + * A grant on a Composio action the vendor has stopped listing, still reported as held. + * + * Confirmed for a brokered row rather than built: `listServers` derives `withdrawn` from the grants + * no advertised action covers, with no transport-specific branch, so the Drive suite above gates + * the mechanism. What a Composio row adds is that it inherits it — the app's page lists what the + * last refresh advertised, so with nothing reporting the gap a permission on an action Composio + * dropped is invisible and the Bot loses a capability nobody revoked. + * + * The refreshed listing names a DIFFERENT action rather than none at all, because an empty list is + * also what a refresh the vendor refused leaves behind: every grant would come out withdrawn and + * this would hold for a reason that has nothing to do with the action being gone. + */ +test("a granted Composio action that the vendor withdrew is still shown as granted", async () => { + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + await seedComposioGmail(database, store); + + // The granted action is absent from what the vendor now lists, and another action is not. + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + expect(gmail?.withdrawn).toEqual([ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + name: "GMAIL_FETCH_EMAILS", + grantedTo: ["bot_helper"], + }, + ]); + // The advertised action came through as a tool, which is what says the refresh actually listed + // rather than failing into the empty list that would withdraw everything. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_SEND_EMAIL", + ]); +}); From abca7a7398a10693d6f692d06c48a6db1b0f4800 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:40:14 +0200 Subject: [PATCH 040/215] Say that the brokered narrowing throw is reachable, and gate the fallback it forbids Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 25 ++++++++++------- server/tests/plugin-store.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 20dd55cec..fca3fd8da 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -764,7 +764,9 @@ export function createPluginStore(options: PluginStoreOptions) { * object, an error or an audit row. What this function contributes instead is the two refusals * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an * asker who has not connected the app — so a person is told their own next step rather than shown - * the broker's error about an account it cannot find. + * the broker's error about an account it cannot find. A third refusal sits between those two, for + * a brokered row whose url names no Composio app; nothing in the product creates such a row, so no + * person's situation reaches it. * * For a `person-oauth` server it is the asker's own, and every branch that cannot prove it has the * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design @@ -798,8 +800,10 @@ export function createPluginStore(options: PluginStoreOptions) { * broker's error about an account it cannot find into a sentence naming the person's own next * step. * - * The throw between the two is not a third refusal. It is the narrowing that keeps this gate - * keyed on the app the url names, and its own comment says why neither fallback is available. + * The throw between the two is a third refusal, but not one anybody can act on: it fires only + * for a brokered row whose url names no Composio app, which nothing in the product can create. + * It is what keeps this gate keyed on the app the url names, and its own comment says why + * neither fallback is available. * * There is no token. The key belongs to the transport and never travels through this function, so * nothing here can leak it into a connection object, an error or an audit row. @@ -813,17 +817,20 @@ export function createPluginStore(options: PluginStoreOptions) { } /* - * Narrowing, not a second decision. + * Narrowing, and a refusal that is genuinely reachable. * - * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is non-null - * for every row whose credential resolved to `brokered` — but the descriptor declares it - * nullable because every other kind of row has no broker to name an app at, and the compiler - * cannot follow the credential back to the url it was derived beside. + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is NULL + * whenever that url does not name a Composio app — `accessFor` still answers `brokered` for + * any row whose provenance column says composio, so `{ credential: "brokered", toolkit: null }` + * is a state a hand-edited or restored row really produces. The test beside `accessFor` + * asserts it, and `plugin-store.integration.test.ts` gates this branch end to end. * * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both * alternatives fail open. Falling back to `row.id` checks the connection against a spelling * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose - * whole purpose is to keep one person's account out of another's. + * whole purpose is to keep one person's account out of another's. The compiler forces SOME + * narrowing here — drizzle's `eq` will not take `string | null` — but only the test named + * above stops that narrowing from being the fallback. */ if (!access.toolkit) { throw new Error( diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 901cc4b68..f4c5778d4 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3659,6 +3659,33 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +test("a Composio call whose url names no app is refused rather than falling back to the row id", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return {}; + }, + }); + // Brokered by provenance, with a url that names no Composio app: `accessFor` answers + // `{ credential: "brokered", toolkit: null }`, and falling back to the row id would check a + // Gmail connection and then dial a hostname. + await seedComposioGmail(database, store, { url: "https://example.com/mcp" }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/no Composio app in its url/i); + + expect(reached).toEqual([]); +}); + test("a Composio call whose row id and url name different apps is refused", async () => { const { store, database } = await freshStore(); const reached: string[] = []; From 7c9ea0fc52c2e9da74c2710c07a4d68213b4c572 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 02:40:14 +0200 Subject: [PATCH 041/215] Build the catalogue urls in the access tests from hosts that already carry a scheme Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-access.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index bcee2393d..be9dcdebf 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -48,7 +48,7 @@ describe("accessFor", () => { const notion = catalogueEntry("notion"); expect(notion).not.toBeNull(); if (!notion) return; - const notionUrl = `https://${notion.host}${notion.path}`; + const notionUrl = `${notion.host}${notion.path}`; expect( accessFor({ provenance: "first-party", url: notionUrl }, notion), ).toEqual({ @@ -62,7 +62,7 @@ describe("accessFor", () => { test("Drive is its REST adapter, on the asking person's own grant", () => { const drive = catalogueEntry("google-drive"); if (!drive) return; - const driveUrl = `https://${drive.host}${drive.path}`; + const driveUrl = `${drive.host}${drive.path}`; expect( accessFor({ provenance: "first-party", url: driveUrl }, drive), ).toEqual({ From b6f7b0ce8a780cfbe52ca423c19306a46d88314b Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:47 +0200 Subject: [PATCH 042/215] Stop a value in a vendor column taking an action off the reviewed write list classifyTool consulted the recorded effect before entry.writeTools, so a recorded `read` overrode a hand-reviewed write: notion-update-page classified as a read. The recorded effect is plain `text` with no check constraint, and `writeTools` is what a person read before it shipped, so the order is now advertised, then the reviewed list, then the column. A recorded effect may narrow what a Bot may do and may never widen it. `if (recorded)` also read the empty string as "nothing was recorded", so an advertised action absent from the write list fell through to a read. Presence rather than truthiness decides now, and only the exact string `read` earns a read. Three comments claimed the old behaviour and now say what the code does. The pattern case in plugin-catalogue.test.ts borrowed a real catalogue key, so it passed only because no entry declares a hostPattern today; it uses a key no entry holds and asserts that. The recorded-write case returned early on a null entry without asserting it was not null. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/catalogue.ts | 80 ++++++++++++------ server/tests/composio-classify.test.ts | 112 +++++++++++++++++++++++-- server/tests/plugin-catalogue.test.ts | 44 ++++++++-- 3 files changed, 200 insertions(+), 36 deletions(-) diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index 4205fd959..c3a91fdbc 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -116,8 +116,13 @@ export type CatalogueEntry = { * unknown tool as a write rather than as a read: a tool the server never advertised, so nothing * here could have named it, is safe to over-scrutinize as a write. The opposite direction is the * one that matters for this list: a tool the server DOES advertise but that is missing from here - * classifies as a read, so an incomplete list is the failure mode, not a safe default — this list - * has to lean over-inclusive. + * classifies as a read unless the vendor itself recorded an effect for it, so for a vendor that + * records nothing — which is every one here today — an incomplete list is the failure mode rather + * than a safe default, and this list has to lean over-inclusive. + * + * Nothing outside review can shorten it, either. A name this list holds is a write no matter what + * a vendor recorded for that action, because this list is what a person read and the recorded + * effect is whatever was last written into an unconstrained column. */ writeTools: readonly string[]; /** @@ -244,9 +249,11 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ * The writing tools as the hosted server advertises them today. The hosted server advertises * its tools, so a name here that does not match an advertised tool is not the risk — an * advertised tool that is missing from this list is: {@link classifyTool} reads an unlisted - * but advertised name as a read, never as a write. That makes under-inclusion the failure - * mode, so this list has to lean over-inclusive rather than minimal, and reconciling it - * against the live tool list on the first Refresh tools is required, not cosmetic. + * but advertised name as a read, never as a write. Notion's MCP listing carries no per-action + * effect, so this list is the only thing that can say otherwise and nothing backstops it. That + * makes under-inclusion the failure mode, so this list has to lean over-inclusive rather than + * minimal, and reconciling it against the live tool list on the first Refresh tools is + * required, not cosmetic. */ writeTools: Object.freeze([ "notion-convert-page-to-skill", @@ -361,20 +368,29 @@ export function resolveServerUrl( /** * What this tool does, in the only two categories a policy author cares about. * - * TWO SOURCES, CONSULTED IN ORDER. What the vendor recorded about this action when it was listed - * comes first: exactly `read` is a read, and anything else — a recorded write, an unrecognised - * value, a different case — is a write. Only where nothing was recorded does the reviewed write list - * decide, and there a tool the entry names is a write while one it does not name is a read. + * A RECORDED EFFECT MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. That is the criterion the + * order below is built from, and it is why the reviewed write list is consulted FIRST. Both sources + * are trusted to make an action a write; only the reviewed one is trusted to make an action a read + * where the other says write. `writeTools` was read by a person before it shipped. A recorded effect + * arrives from a vendor listing into a plain `text` column with no check constraint, so it is + * whatever was last written there, by a refresh or by a hand on a psql prompt. A value in that column + * therefore cannot take an action off the reviewed list. + * + * THREE SOURCES, CONSULTED IN THIS ORDER. Whether the server advertised the name at all: it did not, + * the name came from a model and the answer is write, and nothing later overrides that. Then the + * reviewed write list, which settles a name it holds as a write. Then what the vendor recorded, where + * exactly `read` is a read and every other value it holds — a recorded write, an unrecognised label, + * a different case, the empty string — is a write. Where the column holds nothing at all the reviewed + * list finishes the job: an advertised name it declines to call a write is a read. * - * Unknown counts as a write throughout. A tool the server never advertised at all is a write, - * because the only thing that produced the name was a model. A server with no catalogue entry behind - * it is a write unless the vendor recorded a read for that action — there is no reviewed list to - * consult, so an unlabelled action of theirs has nothing saying it is safe. + * Unknown counts as a write throughout. A server with no catalogue entry behind it is a write unless + * the vendor recorded a read for that action — there is no reviewed list to consult, so an unlabelled + * action of theirs has nothing saying it is safe. * * So there are two ways to earn a read, and both require somebody to have said so. Either the vendor - * labelled the action a read, or the server advertised it and a reviewed list declined to call it a - * write. Guessing permissively is recoverable only in those two cases; everywhere else the answer is - * a write. + * labelled the action a read and no reviewed list contradicts them, or the server advertised it and a + * reviewed list declined to call it a write. Guessing permissively is recoverable only in those two + * cases; everywhere else the answer is a write. */ export function classifyTool( entry: CatalogueEntry | null, @@ -383,25 +399,37 @@ export function classifyTool( /** * What the vendor said about this action when it was listed, or null when nothing did. * - * Consulted BEFORE the entry's write list, because it is the better source and the only one that - * can exist for a broker's catalogue: Composio labels every one of Gmail's sixty-three actions, and - * no reviewed list here could keep pace with several hundred apps that change weekly. + * Consulted AFTER the entry's write list, so it can only agree with review or add to it. It is the + * only source that can exist for a broker's catalogue — Composio labels every one of Gmail's + * sixty-three actions, and no reviewed list here could keep pace with several hundred apps that + * change weekly — so where review said nothing it is the whole answer. * - * Only the exact string `read` produces a read. A recorded write, an unrecognised value, a - * different case, an empty string and null are all writes, so a column somebody typed into by hand, - * or a label a vendor adds later that this code has never heard of, cannot widen what a Bot may do - * unasked. + * PRESENCE, NOT TRUTHINESS, is what makes it consulted, and only the exact string `read` produces a + * read. A recorded write, an unrecognised value, a different case and the EMPTY STRING are all + * writes: the empty string is a value the column holds rather than a silence, and reading it as + * "nothing was recorded" would send an advertised action no reviewed list names down the read + * branch. Null and undefined are the column saying nothing, and fall through to the reviewed list. + * So a column somebody typed into by hand, or a label a vendor adds later that this code has never + * heard of, cannot widen what a Bot may do unasked. */ recorded?: string | null, ): "read" | "write" { // A name the server never listed came from a model, and nothing reviewed says it only reads — - // checked first, so a recorded effect cannot rescue a name that was never advertised. + // checked first, so neither later source can rescue a name that was never advertised. if (!advertised) return "write"; - if (recorded) return recorded === "read" ? "read" : "write"; + // The reviewed list outranks the column, and only in this direction: a name a person reviewed as a + // write stays a write whatever the listing recorded about it. + if (entry?.writeTools.includes(toolName)) return "write"; + // Anything the column holds settles the rest. `typeof` rather than truthiness so the empty string + // is treated as the value it is instead of as an absence of one. + if (typeof recorded === "string") + return recorded === "read" ? "read" : "write"; // A server an administrator added by URL has no reviewed tool catalogue behind it, so nothing here // can say a tool of theirs only reads. Everything it offers is a write. if (!entry) return "write"; - return entry.writeTools.includes(toolName) ? "write" : "read"; + // Advertised, not on the reviewed write list, and nothing recorded. Two sources had the chance to + // call it a write and neither did. + return "read"; } /** diff --git a/server/tests/composio-classify.test.ts b/server/tests/composio-classify.test.ts index 28d08f7ae..a0d6ab9a4 100644 --- a/server/tests/composio-classify.test.ts +++ b/server/tests/composio-classify.test.ts @@ -1,13 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { classifyTool } from "../src/plugins/catalogue"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; /** * What an action does, when the vendor said so and when nobody did. * - * The property under test is the direction of the failure. A recorded `read` is the only input that - * can produce a read; everything else — a recorded write, an unrecognised value, null, an empty - * string — is a write. That asymmetry is the point: an action wrongly gated as a write costs a - * confirmation, and one wrongly waved through as a read costs somebody's mailbox. + * The property under test is the direction of the failure. Exactly two things can earn a read, and + * both require somebody to have said so: the vendor recorded exactly `read`, or a curated entry + * advertised the action and a reviewed list declined to call it a write. Everything else — a + * recorded write, an unrecognised value, a different case, an empty string, a name no server + * advertised, a server nobody reviewed — is a write. That asymmetry is the point: an action wrongly + * gated as a write costs a confirmation, and one wrongly waved through as a read costs somebody's + * mailbox. + * + * THE RECORDED VALUE MAY NARROW WHAT A BOT MAY DO AND MAY NEVER WIDEN IT. It arrives from a vendor + * listing into a plain `text` column with no constraint on its contents, so it outranks nothing that + * a human reviewed. Both halves are pinned below: a recorded write settles an action the curated + * list forgot, and a recorded read cannot unsettle one the curated list named. + * + * The first block passes a null entry throughout, which is the brokered shape — a Composio app has + * no curated entry behind it. The second block passes a real one, because a null entry is itself a + * blanket write and would let the precedence cases below pass without exercising the precedence. */ describe("classifyTool with a recorded effect", () => { test("a recorded read is a read", () => { @@ -42,3 +54,93 @@ describe("classifyTool with a recorded effect", () => { expect(classifyTool(null, "GMAIL_INVENTED", false, "read")).toBe("write"); }); }); + +describe("a recorded effect against a curated entry", () => { + const notion = catalogueEntry("notion"); + + /* + * The two names every case below is built on, asserted once. + * + * `notion-update-page` has to be ON the write list and `notion-fetch` has to be OFF it, or the + * expectations stop meaning what they say: a reviewed write whose name drifted off the list would + * turn the precedence cases into ordinary unlisted-tool cases and they would keep passing. + */ + test("the entry these cases are about names one of them a write and not the other", () => { + expect(notion).not.toBeNull(); + expect(notion?.writeTools).toContain("notion-update-page"); + expect(notion?.writeTools).not.toContain("notion-fetch"); + }); + + test("a recorded read cannot override a curated entry's write list", () => { + /* + * THE CASE THIS WHOLE BLOCK EXISTS FOR. `effect` is vendor-supplied text in a column with no + * check constraint and no product writer other than the refresh path, so a `read` in it is + * reachable by a hand edit or a restore. `writeTools` was reviewed by a person. Letting the + * column win here would buy an action LESS scrutiny than review already gave it, which is the + * one direction this classifier must never move in. + */ + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-create-pages", true, "read")).toBe( + "write", + ); + expect(classifyTool(notion, "notion-move-pages", true, "read")).toBe( + "write", + ); + }); + + test("a recorded read still settles an action the write list does not name", () => { + // The permitted direction, and the reason the column is consulted at all: where review said + // nothing, the vendor's own label is the better source and is taken at its word. + expect(classifyTool(notion, "notion-fetch", true, "read")).toBe("read"); + }); + + test("a recorded write settles an action the write list forgot", () => { + // The other permitted direction. The write list is known-incomplete, so a vendor saying an + // action writes narrows what a Bot may do and is honoured. + expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); + }); + + test("an empty recorded effect is a write, not an absence of opinion", () => { + /* + * A `text` column holding the empty string is a value, not a null. Reading it as "nothing was + * recorded" sends an advertised action that no reviewed list names down the read branch, which + * is the widening this classifier exists to refuse. Both sides of the write list are pinned so + * the answer cannot depend on which one the name falls on. + */ + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, "")).toBe("write"); + }); + + test("a recorded value nothing recognises is a write", () => { + // A label a vendor invents later, a typo, or the wrong case. None of them is `read`, so none of + // them earns a read, whether or not the reviewed list names the action. + for (const value of ["readonly", "READ", "Read", "destructive", "none"]) { + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("write"); + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + } + }); + + test("nothing recorded leaves the curated write list deciding", () => { + // The behaviour that shipped before the column existed, unchanged for every row written before + // it. Null and undefined are the column saying nothing, which is not a value. + for (const value of [null, undefined]) { + expect(classifyTool(notion, "notion-update-page", true, value)).toBe( + "write", + ); + expect(classifyTool(notion, "notion-fetch", true, value)).toBe("read"); + } + }); + + test("a recorded read cannot rescue a name the entry's server never advertised", () => { + // Checked before either source, so the model-invented name is refused whatever the column says + // and whatever the reviewed list says. + expect(classifyTool(notion, "notion-fetch", false, "read")).toBe("write"); + expect(classifyTool(notion, "notion-invented", false, "read")).toBe( + "write", + ); + }); +}); diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index fc3f39d48..7aa8c49f7 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -44,15 +44,23 @@ describe("which servers this deployment will talk to", () => { /* * WHAT THIS NO LONGER COVERS. ServiceNow was the only per-instance entry, and removing it took * the anchored-pattern assertions with it — that a prefix, a suffix and a subdomain are each - * refused. `PATTERNS` is compiled from the catalogue by key, so a synthetic entry cannot reach a - * pattern and there is no way left to exercise the matching itself through the public API. + * refused. `PATTERNS` is compiled from the catalogue by KEY, so an entry this build never + * compiled a pattern for reaches no pattern, and there is no way left to exercise the matching + * itself through the public API. * * What survives is the fail-closed half, which is worth keeping on its own: an entry claiming to * be per-instance that this build has no pattern for is refused rather than admitted. Whoever * adds the next per-instance vendor should restore the anchoring cases with it. + * + * THE KEY IS DELIBERATELY ONE NO ENTRY HOLDS, and asserted to be. This case used to borrow + * `google-drive`, which passed only because no catalogue entry declares a `hostPattern` today: + * `PATTERNS` is keyed, not identity-checked, so a synthetic entry that reuses a real key reaches + * whatever pattern that key compiled. Giving Drive a pattern made the old version admit + * `https://acme.service-now.com`. With an unheld key the lookup misses for the reason the test + * names, whatever the catalogue later declares. */ const perInstance = { - key: "google-drive", + key: "no-entry-holds-this-key", title: "Per-instance vendor", vendor: "Example", summary: "", @@ -65,8 +73,9 @@ describe("which servers this deployment will talk to", () => { docsUrl: "", } as const; - // `PATTERNS` is compiled from the catalogue by key, so a synthetic entry reaches no pattern and - // is refused outright. That is itself the fail-closed property: no pattern means no. + expect(catalogueEntry(perInstance.key)).toBeNull(); + // No compiled pattern for this key, so the entry's own `hostPattern` is never consulted and the + // host is refused outright. That is itself the fail-closed property: no pattern means no. expect(hostAdmissible(perInstance, "https://acme.service-now.com")).toBe( false, ); @@ -556,9 +565,34 @@ test("a curated entry keeps classifying from its write list when nothing was rec test("a recorded write overrides a curated entry that omits the action", () => { const notion = catalogueEntry("notion"); + // Asserted rather than only guarded. The early return below reads as a pass, so a renamed or + // dropped key would retire this case silently instead of failing. + expect(notion).not.toBeNull(); if (!notion) return; // The write list is known-incomplete. A vendor saying an action writes settles it, and the list // being out of date stops mattering. expect(classifyTool(notion, "notion-fetch", true, "write")).toBe("write"); }); + +test("a recorded read cannot take an action off a curated entry's write list", () => { + const notion = catalogueEntry("notion"); + expect(notion).not.toBeNull(); + if (!notion) return; + + /* + * The mirror image of the case above, and the only direction that is refused. A recorded effect + * may NARROW what a Bot may do — the `write` case above — and may never widen it: `effect` is + * vendor-supplied text in an unconstrained column, `writeTools` was reviewed by a person, and a + * value in the column must not buy an action less scrutiny than review already gave it. The + * per-value cases live in composio-classify.test.ts; this pins the property beside the write list + * it protects, so dropping a name from that list fails here too. + */ + expect(notion.writeTools).toContain("notion-update-page"); + expect(classifyTool(notion, "notion-update-page", true, "read")).toBe( + "write", + ); + // And the empty string is a recorded value rather than a silence, so it does not fall through to + // the write list and read as a read for an action the list omits. + expect(classifyTool(notion, "notion-fetch", true, "")).toBe("write"); +}); From 4e9e9c0b53bd8e53793e76e0722c408db46246c6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:32 +0200 Subject: [PATCH 043/215] Say where the actor actually authorizes a transport call, which is not the listing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 65 +++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 469105d0b..cead6af40 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -12,23 +12,38 @@ import * as mcp from "./mcp"; * API is generally available — so one vendor needed a second way in, and a second way in wants a * seam rather than a branch at each call site. * - * The interface is MCP's OWN, unchanged: `listTools` and `callTool`, the two functions - * {@link ./mcp} already exported, with the shapes it already used. That direction matters. Had the - * REST adapter been given its own interface with MCP adapted to fit, MCP would have become a special - * case of a shape invented for Drive. As it is, MCP is the contract and the adapter conforms to it, - * which is why swapping back is one field on one entry and not a refactor. + * The interface STARTED as MCP's OWN: `listTools` and `callTool`, the two functions {@link ./mcp} + * already exported, with the shapes it already used. That direction matters. Had the REST adapter + * been given its own interface with MCP adapted to fit, MCP would have become a special case of a + * shape invented for Drive. As it is, MCP is the contract the adapters conform to, which is why + * swapping Drive back is one field on one catalogue entry and not a refactor. + * + * What has been added since is a SUPERSET of that shape rather than a departure from it, so an + * MCP-shaped implementation still satisfies the seam unchanged. `listTools` answers `ListedTool[]`, + * which is `McpTool` plus optional fields a broker publishes and an MCP server does not; the + * connection carries an `actorId` and a `botId` for the transports whose authorization is the + * actor rather than a credential; and one reserved key on `args` hands a transport the recorded + * version of the action being called. Every addition is optional, which is why `mcp.ts` reads none + * of them and is still an implementation of this interface rather than an exception to it. * * There are exactly two call sites in the whole system — the tool listing and the tool call — and - * both take a transport from here. Nothing else, including the OAuth flow, the per-person credential - * selection, the grants, the policy engine and the audit trail, knows which protocol is underneath. + * both take a transport from here. Nothing else reads a `TransportKind` at all: the OAuth flow, + * the grants, the policy engine and the audit trail are written without one. Whose credential a + * row goes out on is a SEPARATE axis — `./access`'s `CredentialSource` — and that one is NOT + * protocol-blind, since the brokered branch of the credential selection looks a person's + * connection up in `composio_connections` by name. Read the blindness as a claim about this union + * and not about the store. */ export type VendorTransport = { /** * Whether discovering the tool list needs somebody's credential. * * True for MCP, where the list is an answer from a remote server that will not give it up - * unauthenticated. False for an adapter whose tool list is this code, where there is nothing to ask - * and nobody to ask it of. + * unauthenticated. False whenever no credential has to be SELECTED for the listing: either + * because the list is this code, as it is for Drive and Routines, or because the transport + * already holds the one key it lists on and never receives it through the connection, as + * Composio does — a broker publishes an action's schema to anybody who asks with the + * deployment's own key. * * It is on the transport rather than assumed by the caller because getting it wrong is a whole * broken setup flow. Assumed true, an administrator configuring Drive was sent to their own @@ -41,15 +56,21 @@ export type VendorTransport = { url: string; token?: string; /** - * Who this call is for, and which Bot is making it. + * Declared by the shared connection shape, and never supplied on THIS path. + * + * `refreshTools` is the only caller of `listTools` in the system, and it passes `{url, token}`. + * No implementation here even accepts either field: `builtin-routines` takes no argument at + * all, Composio takes only `url`, and MCP and Drive take `{url, token}`. So a transport that + * read one would read `undefined` every time, and nothing on the listing path may be + * authorized by them. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * The actor is the authorization on the CALLING path instead — see {@link callTool} below, + * where Routines and Composio each refuse a run attributed to nobody. Listing is not + * somebody's: it is what this deployment offers everybody. A list that insisted on an actor + * would be asked without one, store zero tools, and leave the vendor advertising nothing to + * anybody. */ actorId?: string; - /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ botId?: string; }): Promise; callTool( @@ -59,10 +80,16 @@ export type VendorTransport = { /** * Who this call is for, and which Bot is making it. * - * Ignored by every transport that dials a vendor: MCP and Drive answer to a credential, and who - * holds it is already decided by the time the connection is built. The builtin transport has no - * credential and no vendor — it acts on this deployment's own tables — so the actor is not - * context, it is the authorization, and it refuses without one. A routine is somebody's. + * Ignored where a CREDENTIAL is the authorization: MCP and Drive answer to a token, and whose + * it is was settled before the connection was built, so neither module's `Connection` type + * carries these at all. Read where the ACTOR is the authorization: Routines acts on this + * deployment's own tables, and Composio opens one person's account with a key the deployment + * holds for everybody, so both refuse a run attributed to nobody rather than run it as + * somebody. A routine is somebody's; so is a mailbox. + * + * They come off the connection, which the call path derives from the session, and are never + * read out of `args`. A model that could name either could schedule work as another person or + * read another person's mail. */ actorId?: string; /** The Bot the run belongs to. A routine runs as its Bot, which is never a name a model supplies. */ From 840b3d7c2ed1930786760bf3f15d949239a62fc1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:37:55 +0200 Subject: [PATCH 044/215] Keep the Composio SDK out of the runtime image, where nothing imports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its only importer in the repo is server/tests/composio-live.test.ts; no file under server/src reaches it, because the transport is written against a structural type and takes its client through useComposioClient. The Dockerfile builds the runtime tree with `bun install --production`, so declaring the SDK a dependency put it and five transitive packages — @composio/client, @composio/json-schema-to-zod, is-fs-case-sensitive, pusher-js, tweetnacl, about 18 MB — into every container that runs this product and never calls Composio. The repo's other test-only packages, @copilotkit/aimock and eventsource, are already devDependencies. The lockfile change is the same two lines mirrored: the resolution graph is unchanged, so semver stays hoisted at 7.8.5 and @composio/core stays 0.18.1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- bun.lock | 2 +- server/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index bf28de4f6..462f1e57f 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,6 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", - "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -80,6 +79,7 @@ "zod": "^4.4.3", }, "devDependencies": { + "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7", diff --git a/server/package.json b/server/package.json index 75153261f..8a963497c 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,6 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", - "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -29,6 +28,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7" From 14e7b87a9fbc817081faf14627b7a8def54f58d7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:38:35 +0200 Subject: [PATCH 045/215] Refuse the live Composio run that would skip every case and exit 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script set OPENBOT_LIVE_COMPOSIO=1 and nothing else, but the suite is wrapped in describe.skipIf on that flag AND a non-empty COMPOSIO_API_KEY, so `bun run test:live-composio` without a key ran zero cases and reported success — the one outcome a live test must never produce, because it looks exactly like the vendor's shapes having been checked. test:live-screen has no second condition, which is why it does not need this. The guard is in the script rather than the test: the suite must still skip quietly for CI and for a contributor with no Composio account, and only the person who asked for a live run deserves an error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13f93f99f..cbb29bf31 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", - "test:live-composio": "OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", + "test:live-composio": "bun -e \"if (!process.env.COMPOSIO_API_KEY?.trim()) { console.error('COMPOSIO_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as COMPOSIO_API_KEY=... bun run test:live-composio.'); process.exit(1) }\" && OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, From 9c8353fbdb86608a58567bfe01555bc9d24e91d9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:38:58 +0200 Subject: [PATCH 046/215] Stop offering operators a deployment setting no deployment reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry in .env.example is a promise that the running product reads that variable, because .env.example is what a deployment copies to make its .env. COMPOSIO_API_KEY was not one: server/src/config.ts never reads it, nothing under server/src mentions it, docs/configuration.md's table omits it, and neither docker-compose.yml nor the Helm env helper forwards it — so an operator who set it got nothing at all, and no error saying so. The comment also claimed absence meant no app could be enabled, which the next line contradicted by admitting no app can be enabled either way. The only thing that reads the key is the opt-in live test, and that is where this repo already documents such a prerequisite: test:live-screen's needs are not in .env.example either. It is now named in the test's own header and in the error the live-Composio script prints without it. Put the entry back when server/src/config.ts reads the key — and in the same change add the row to docs/configuration.md, the passthrough to docker-compose.yml, and the env var to charts/openbot/templates/_helpers.tpl, so the promise is true on every deployment path at once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .env.example | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.env.example b/.env.example index 1cb267422..6d46581f2 100644 --- a/.env.example +++ b/.env.example @@ -338,8 +338,3 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= - -# Composio, the broker that holds each person's connections to Gmail, Slack, Linear and the rest. -# Absent means absent rather than disabled: no app can be enabled and nothing is registered. -# Only used by the live test in steps 1 and 2; the product does not read it until the app picker ships. -COMPOSIO_API_KEY= From dac4962d2d5a0690452a760fde9a902c024e2c5e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:40:22 +0200 Subject: [PATCH 047/215] Say why an action list could not be read, in a sentence rather than a Zod dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor answer that fails the SDK's own `ToolSchema` threw straight out of `listTools`, so the Zod issue array became the row's `lastError` on the admin page and reached a model's context. It now propagates rather than answering empty — `refreshTools` records a throw and leaves the tools already held alone, whereas an empty list is indistinguishable from an app that publishes no actions and would report a success while stranding every grant — and what propagates names the fix, with the vendor's object kept as `cause`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 62 ++++++++++++++++++++++++- server/tests/composio-transport.test.ts | 35 ++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index e0b0183ca..c7e6def5a 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -133,6 +133,11 @@ export function effectOf(tags: readonly string[] | undefined): { * An action with no schema is still listed, with an open one. The vendor is the right party to reject a * bad argument, and an action silently missing from the list reads to an administrator as an app that * does not have it. + * + * A listing that could not be read at all is a THROW rather than an empty list, for the same reason + * turned around: an empty list is what an app with no actions looks like, so answering emptily would + * report a success and strand every grant. What throws is a sentence, never a vendor object. See the + * catch below. */ export async function listTools(connection: { url: string; @@ -140,7 +145,26 @@ export async function listTools(connection: { const toolkit = toolkitOf(connection.url); if (!toolkit || !installed) return []; - const actions = await installed.listActions(toolkit); + let actions: ComposioAction[]; + try { + actions = await installed.listActions(toolkit); + } catch (error) { + /* + * THROWN, NOT ANSWERED EMPTY, and with a sentence rather than the vendor's raw object. + * + * The two candidate behaviours are not equivalent. `refreshTools` records a throw in the row's + * `lastError` and leaves the tools it already holds alone; an empty answer is indistinguishable + * from an app that genuinely publishes no actions, so it would report a success and leave every + * grant pointing at a name nothing advertises. So a listing this deployment could not read must + * propagate. + * + * What propagates is a sentence. `refreshTools` puts `error.message` on the admin page, and a + * `ToolSchema` mismatch's message is the Zod issue array as JSON — an operator reading 400 + * characters of `{"code":"invalid_type","path":[...]}` learns nothing they can act on, and the + * same string was reaching a model's context. The original is kept as `cause` for a log. + */ + throw new Error(listingSentence(toolkit, error), { cause: error }); + } return actions.map((action) => { const { effect, destructive } = effectOf(action.tags); @@ -179,6 +203,42 @@ export function vendorSentence(error: unknown): string | null { return typeof message === "string" && message.trim() !== "" ? message : null; } +/** + * Whether a thrown listing failure is the SDK's own schema refusing the vendor's answer. + * + * Duck-typed rather than `instanceof ZodError` so this file keeps no dependency on the vendor's + * package: `@composio/core` reaches it only through {@link useComposioClient}, and importing `zod` + * here would tie the transport to whichever major version the vendor happens to bundle — which is + * exactly the coupling that makes a schema mismatch possible in the first place. + */ +function isSchemaMismatch(error: unknown): boolean { + const shaped = error as + | { name?: unknown; issues?: unknown } + | null + | undefined; + return shaped?.name === "ZodError" || Array.isArray(shaped?.issues); +} + +/** + * Why an app's action list could not be read, as one sentence an operator can act on. + * + * The schema case names the fix, because it is a vendor change rather than a misconfiguration: the + * answer arrived and this deployment's copy of their SDK would not accept it, so nothing an + * administrator can do to this row will help and upgrading the package will. + */ +function listingSentence(toolkit: string, error: unknown): string { + if (isSchemaMismatch(error)) { + return `Composio's action list for ${toolkit} did not match the shape this deployment's @composio/core accepts, so the list was not refreshed and the tools already held are untouched. That is a vendor change rather than a setting: upgrading the package is the fix.`; + } + const thrown = error instanceof Error ? error.message.trim() : ""; + return ( + vendorSentence(error) ?? + (thrown === "" + ? `Composio did not answer with an action list for ${toolkit}.` + : thrown) + ); +} + const failure = (message: string): McpCallResult => ({ text: message, isError: true, diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 2b35af995..5617ec7d8 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -211,6 +211,41 @@ describe("listing an app's actions", () => { test("listing with no client installed is empty rather than a crash", async () => { expect(await listTools({ url: "composio://gmail" })).toEqual([]); }); + + test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { + const issues = [ + { + code: "invalid_type", + expected: "string", + received: "number", + path: ["slug"], + message: "Expected string, received number", + }, + ]; + useComposioClient( + recording({ + listActions: async () => { + // What `ToolSchema` throws: `message` is the issue array as JSON, which is what would land + // in `lastError` and, before `refreshTools` existed, in a model's context. + throw Object.assign(new Error(JSON.stringify(issues, null, 2)), { + name: "ZodError", + issues, + }); + }, + }).client, + ); + + const listing = listTools({ url: "composio://gmail" }); + + // Propagated rather than answered empty, because `refreshTools` records a throw in `lastError` + // and leaves the tools it already holds alone. An empty answer would read as an app that has no + // actions, and every grant would point at a name nothing advertises. + await expect(listing).rejects.toThrow(/did not match/i); + + const thrown = await listing.catch((error: unknown) => error); + expect(String((thrown as Error).message)).not.toContain("invalid_type"); + expect(String((thrown as Error).message)).toContain("gmail"); + }); }); describe("calling one action", () => { From 17c7c377226c6a1b621c2cce760c187c4f4db9b5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:40:40 +0200 Subject: [PATCH 048/215] Report the outcome the vendor actually reported, and our own failures as ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ToolExecuteResponseSchema` in the installed `@composio/core` 0.18.1 makes `successful` a required field beside a required `data` record and a required nullable `error`, so a 200 answer can carry a failure. This module's comment asserted the opposite — "resolves or throws, with no error field to check" — and `resultOf` was written to match the comment rather than the library, so such an answer came back as `isError: false`, was audited as `mcp.call_succeeded`, and was handed to the model as though the failure were content. The type now says what arrives and the code reads it. Three further ways the same reporting was wrong, all of them here: `failure` skipped the result cap and claimed `truncated: false`, so an unbounded vendor sentence entered a model's context while the field said nothing had been cut; `resultOf` ran inside the vendor's `try`, so a `JSON.stringify` throw of ours was reported as the tool having failed after it ran, which is a different event and now reads as one; and a vendor failure with no nested sentence was reported as "Error executing the tool X", the exact placeholder this module's own comment calls useless, in place of something a person can act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 168 ++++++++++++++++++--- server/tests/composio-transport.test.ts | 189 +++++++++++++++++++++++- 2 files changed, 329 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index c7e6def5a..6f53174bd 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -47,15 +47,39 @@ export type ComposioAction = { version?: string; }; +/** + * What Composio answers an execute with, as its own SDK defines it. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` + * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field + * on a resolution and not only a thrown exception. Named here rather than imported so this module + * keeps no compile-time dependency on the vendor's package; the adapter that installs the real + * client is the one place their types belong. + * + * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement + * about what arrives. Nothing here reads them and nothing here shows them to a model. + */ +export type ComposioResult = { + data: Record; + error: string | null; + successful: boolean; + logId?: string; + sessionInfo?: unknown; +}; + /** * What this module needs of Composio, and nothing more. * * A narrow projection rather than their client, so a test satisfies it with two functions and the * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. * - * `execute` RESOLVES OR THROWS, with no error field to check. That is not a simplification — it is - * what the live API does, confirmed by calling it. An earlier draft of this module checked a - * `{ data, error }` shape that never occurs, so every stubbed test passed against a fiction. + * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the + * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to + * match the comment rather than the library, which is how a 200 answer carrying `successful: false` + * came back from this transport as `isError: false`, was audited as `mcp.call_succeeded`, and was + * handed to the model as though the failure were content. The installed schema is the authority: + * `successful` is required. Throws still happen too, for a transport fault or a 4xx, so both a + * resolution and an exception have to be read. */ export type ComposioActions = { listActions(toolkit: string): Promise; @@ -64,7 +88,7 @@ export type ComposioActions = { userId: string, version: string, args: Record, - ): Promise; + ): Promise; }; let installed: ComposioActions | null = null; @@ -192,8 +216,9 @@ export async function listTools(connection: { * openbot already had this lesson from Drive, where a generic message cost a round of probing and the * vendor's own "The caller does not have permission" named the problem immediately. * - * Null when there is no such sentence, so the caller falls back to the thrown message rather than - * inventing one. + * Null when there is no such sentence, which leaves the caller to choose a fallback rather than + * inventing one here. That choice is not simply "the thrown message": the thrown message is often the + * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. */ export function vendorSentence(error: unknown): string | null { const cause = (error as { cause?: unknown } | null | undefined)?.cause; @@ -203,6 +228,27 @@ export function vendorSentence(error: unknown): string | null { return typeof message === "string" && message.trim() !== "" ? message : null; } +/** + * The vendor's placeholder, which is the one sentence never worth passing on. + * + * "Error executing the tool GMAIL_FETCH_EMAILS" tells a reader only the name of the thing they asked + * for. Matched on its opening rather than on the whole string, because the slug varies and the + * punctuation after it has not been stable across vendor versions. + */ +const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; + +/** + * What to say when the vendor reported a failure and said nothing about it. + * + * A sentence naming the one thing the reader can actually do, because the alternative is echoing the + * placeholder above — and a model handed "Error executing the tool X" will either retry the identical + * call or invent a reason. The likely cause by a wide margin is a connection that has lapsed, which + * is a person's own two-click fix on the page named here. + */ +function unexplained(toolName: string): string { + return `${toolName} failed and Composio did not say why. Check that this app is still connected on its Plugins page, then try again.`; +} + /** * Whether a thrown listing failure is the SDK's own schema refusing the vendor's answer. * @@ -239,39 +285,83 @@ function listingSentence(toolkit: string, error: unknown): string { ); } +/** + * The cap every string this module puts in front of a model goes through. + * + * Its own function because BOTH ANSWERS NEED IT, and only one of them used to get it. A refusal lands + * in a model's context exactly as a result does, and a vendor's sentence is no shorter for being a + * failure — so {@link failure} capping nothing and reporting `truncated: false` was the silent + * truncation's mirror image: unbounded text, plus a field stating that nothing had been cut. + */ +function cap(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_RESULT_CHARS) return { text, truncated: false }; + return { + text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]`, + truncated: true, + }; +} + const failure = (message: string): McpCallResult => ({ - text: message, + ...cap(message), isError: true, - truncated: false, }); +/** + * The serializations that mean the action had nothing to say. + * + * `{}` is in here because `data` is a required RECORD: an action that matched nothing answers with an + * empty object, so if that did not count as nothing the branch below would be unreachable and its + * promise a fiction. `""` and `"null"` stay for a client whose projection is looser than the schema. + */ +const NOTHING = new Set(["", "null", "{}"]); + /** * What the model reads, capped visibly. * + * THE ACTION'S DATA, NOT THE WHOLE ENVELOPE. `error`, `successful` and `logId` are what + * {@link callTool} reads to decide the outcome; repeating them as content spends a model's context on + * this transport's own bookkeeping and invites the model to draw its own conclusion from a field it + * should never have seen. + * * The same cap the MCP transport applies and for the same reason: a tool result goes straight into a * model's context, so an unbounded one is somebody else's server deciding how much of our context * window to spend. Truncated visibly, never silently. An empty answer is stated in words rather than * returned empty — an empty string reads as "the action had nothing to say" rather than "there is * nothing there", and a model closes that gap from memory. + * + * CAN THROW, and is called from outside the vendor's `try` for that reason. See {@link callTool}. */ -function resultOf(data: unknown): McpCallResult { - const text = - typeof data === "string" ? data : JSON.stringify(data ?? null, null, 2); - const truncated = text.length > MAX_RESULT_CHARS; - if (!truncated && (text === "" || text === "null")) { +function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { + const text = JSON.stringify(data ?? null, null, 2); + if (NOTHING.has(text)) { return { text: "The action returned nothing.", isError: false, truncated: false, }; } - return { - text: truncated - ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated]` - : text, - isError: false, - truncated, - }; + return { ...cap(text), isError: false }; +} + +/** + * What the vendor said about its own call, read from the field its schema requires it to send. + * + * The criterion is that the vendor SAID the call did not succeed, which is `successful === false` and + * not a falsy `successful`. An absent field is not the vendor reporting a failure — the schema makes + * it impossible from the real client, and reading it as a failure would turn a projection looser than + * the schema into a refusal of a call that worked. + * + * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". + */ +function reportedFailure( + answer: ComposioResult, + toolName: string, +): string | null { + if (answer.successful !== false) return null; + const sentence = typeof answer.error === "string" ? answer.error.trim() : ""; + return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) + ? unexplained(toolName) + : sentence; } /** @@ -284,6 +374,12 @@ function resultOf(data: unknown): McpCallResult { * A failure comes back as a result rather than a throw, matching `builtin-routines`. The model is * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in * the audit trail either way. + * + * THREE KINDS OF FAILURE, all of them `isError: true` and each with its own sentence, because + * `store.ts` records that sentence beside the audit row: this transport refused before dialling, the + * vendor reported a failure — by throwing, or in the `successful` field of a 200 answer — or the + * vendor answered and this deployment could not read what it said. Only the last of those is ours, + * and it must not arrive wearing the vendor's words. */ export async function callTool( connection: { url: string; actorId?: string }, @@ -324,15 +420,39 @@ export async function callTool( ); } + /* + * THE VENDOR'S TRY HOLDS THE VENDOR'S CALL AND NOTHING ELSE. + * + * `resultOf` used to be invoked inside it, so a `JSON.stringify` throw of ours — a circular + * reference, a BigInt, a RangeError on something enormous — was reported as the action having + * failed after it ran. Those are two different events: in one the vendor refused, in the other the + * vendor did its part and this deployment could not read the answer. The audit trail has to be able + * to tell them apart, and it cannot if both arrive wearing the vendor's words. + */ + let answer: ComposioResult; try { - return resultOf(await installed.execute(toolName, userId, version, rest)); + answer = await installed.execute(toolName, userId, version, rest); } catch (error) { // The vendor's own sentence when there is one, because a generic message costs a diagnosis. + const thrown = error instanceof Error ? error.message.trim() : ""; return failure( vendorSentence(error) ?? - (error instanceof Error - ? error.message - : "Composio did not answer this action."), + (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) + ? unexplained(toolName) + : thrown), + ); + } + + const reported = reportedFailure(answer, toolName); + if (reported !== null) return failure(reported); + + try { + return resultOf(answer.data); + } catch (error) { + return failure( + `${toolName} ran and Composio answered, but this deployment could not turn that answer into text: ${ + error instanceof Error ? error.message : String(error) + }`, ); } } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 5617ec7d8..3090f9d8e 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -28,6 +28,25 @@ afterEach(() => useComposioClient(null)); type Recorded = { slug: string; userId: string; version: string }; +/** + * An answer in the shape `ToolExecuteResponseSchema` actually permits. + * + * Every stub here goes through this rather than returning a shape of its own, because the SDK's + * schema makes `data`, `error` and `successful` all REQUIRED — so a stub that resolves `null`, or a + * bare string, is testing a case the library cannot produce, and a test built on an impossible input + * proves nothing about the code that reads a real one. + */ +function answered( + data: Record, + outcome: { error?: string | null; successful?: boolean } = {}, +) { + return { + data, + error: outcome.error ?? null, + successful: outcome.successful ?? true, + }; +} + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -41,7 +60,7 @@ function recording(answers: Partial = {}): { answers.execute ?? (async (slug, userId, version) => { calls.push({ slug, userId, version }); - return { ok: true }; + return answered({ ok: true }); }), }, }; @@ -275,7 +294,7 @@ describe("calling one action", () => { recording({ execute: async (_slug, _userId, _version, args) => { seen.push(args); - return {}; + return answered({}); }, }).client, ); @@ -407,7 +426,9 @@ describe("calling one action", () => { test("a result is capped visibly rather than silently", async () => { useComposioClient( - recording({ execute: async () => "x".repeat(60_000) }).client, + recording({ + execute: async () => answered({ body: "x".repeat(60_000) }), + }).client, ); const result = await callTool( @@ -421,7 +442,7 @@ describe("calling one action", () => { }); test("an empty answer says so in words rather than being empty", async () => { - useComposioClient(recording({ execute: async () => null }).client); + useComposioClient(recording({ execute: async () => answered({}) }).client); const result = await callTool( { url: "composio://gmail", actorId: "user_asker" }, @@ -431,6 +452,166 @@ describe("calling one action", () => { // An empty string in front of a model reads as "the action had nothing to say" rather than "there // is nothing there", and the model closes the gap from memory. Same reasoning as `resultText`. + // `data` is a required record, so the empty answer the SDK can actually produce is `{}` — if that + // did not count, this branch would be unreachable and its promise would be a fiction. expect(result.text).toMatch(/returned nothing/i); }); + + test("an answer the vendor marked unsuccessful is a failure, not content", async () => { + // `ToolExecuteResponseSchema` makes `successful` REQUIRED and resolves `{ data, error, + // successful }`, so a 200 answer can carry a failure. Reported as a success it is audited as + // `mcp.call_succeeded` and the failure is handed to the model as though it were content. + useComposioClient( + recording({ + execute: async () => + answered( + {}, + { + successful: false, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + }); + + test("a successful answer hands the model the action's data and not the envelope", async () => { + useComposioClient( + recording({ + execute: async () => ({ + ...answered({ messages: [{ id: "m1" }] }), + logId: "log_must_not_appear", + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(false); + expect(result.text).toContain("m1"); + // `successful`, `error` and `logId` are the envelope this transport reads to decide the outcome. + // Reporting them as content spends a model's context on our own bookkeeping. + expect(result.text).not.toContain("log_must_not_appear"); + expect(result.text).not.toContain("successful"); + }); + + test("an unsuccessful answer with no sentence still says something actionable", async () => { + useComposioClient( + recording({ + execute: async () => answered({}, { successful: false, error: null }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + }); + + test("a failure carrying only the vendor's placeholder says something actionable", async () => { + // "Error executing the tool X" is the string this module's own comment calls useless. Echoing it + // tells a person nothing they did not already know: they asked for that tool. + useComposioClient( + recording({ + execute: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toBe("Error executing the tool GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + }); + + test("an enormous vendor sentence is capped in a refusal too, and says so", async () => { + useComposioClient( + recording({ + execute: async () => + answered({}, { successful: false, error: "x".repeat(60_000) }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + // A refusal goes into a model's context exactly as a result does, so an uncapped vendor sentence + // is the same unbounded spend the success path already refuses to make. + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("an enormous thrown message is capped in a refusal too", async () => { + useComposioClient( + recording({ + execute: async () => { + throw new Error("y".repeat(60_000)); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(25_000); + }); + + test("our own serialization failure is not reported as the action having failed", async () => { + useComposioClient( + recording({ + execute: async () => { + const data: Record = { subject: "hello" }; + // A circular reference, which `JSON.stringify` refuses. The action already ran and the + // vendor already answered; what fails is this deployment reading that answer. + data.itself = data; + return answered(data); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // Two different events, and the audit trail has to be able to tell them apart: the vendor did + // its part here. + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + }); }); From bed3dbd9adde3de70ecc39398dcaeca2a6a2aa41 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:42:45 +0200 Subject: [PATCH 049/215] Keep an app's actions when the refresh listed nothing because it asked nobody Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 382 ++++++++++++------ server/tests/plugin-store.integration.test.ts | 218 +++++++++- 2 files changed, 476 insertions(+), 124 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index fca3fd8da..bf50b722a 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -43,7 +43,7 @@ import { import { accessFor, type ServerAccess } from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; -import { McpServerError } from "./mcp"; +import { type ListedTool, McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; import { transportFor } from "./transport"; @@ -224,6 +224,32 @@ export class CustomServerRefusedError extends Error { } } +/** + * A state this deployment's own code says cannot exist, found existing. + * + * CRITERION. Nothing here is a vendor's doing, a credential's doing or anything a person asking can + * act on, so no path may record one of these as though a vendor had misbehaved. + * + * REASON. `refreshTools` wrapped the listing, the replace and both audit writes in one `catch` that + * copied every message into `lastError` and answered `{ tools: 0 }`. A plain `Error` is what the + * narrowing throws in {@link createPluginStore}'s `connectionTokenFor` raise, so a row that resolved + * to a brokered credential with no app in its url — or to a per-person credential with no + * `user-oauth` entry — came out on the Plugins page as a sentence about the vendor, next to a + * refresh that looked like it had merely failed. An operator reading that is sent to somebody else's + * status page over a contradiction in our own tables. + * + * A class rather than a message, because telling these apart by prose is telling them apart by a + * substring that a reword would silently change. Distinct from {@link PluginRefusedError}, which is + * a refusal somebody CAN act on and which does belong in `lastError` — an administrator who has not + * connected their account is the honest reason a listing did not happen. + */ +export class PluginInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginInvariantError"; + } +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * @@ -833,7 +859,7 @@ export function createPluginStore(options: PluginStoreOptions) { * above stops that narrowing from being the fallback. */ if (!access.toolkit) { - throw new Error( + throw new PluginInvariantError( `${row.id} resolves to a brokered credential with no Composio app in its url.`, ); } @@ -889,7 +915,7 @@ export function createPluginStore(options: PluginStoreOptions) { * says must be impossible. */ if (entry?.auth.kind !== "user-oauth") { - throw new Error( + throw new PluginInvariantError( `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, ); } @@ -2106,11 +2132,54 @@ export function createPluginStore(options: PluginStoreOptions) { ): Promise<{ tools: number }> { const { row, entry, access } = await requireServer(serverId); - try { - // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, - // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. - const transport = transportFor(access.transport); + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, + // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. + const transport = transportFor(access.transport); + + /* + * A brokered row with no app in its url has nobody to ask, and saying so is not the transport's + * job. + * + * CRITERION. A listing this deployment could not even attempt must not be committed as a + * refresh, and must not be written down as a vendor's answer. + * + * REASON. `accessFor` answers `brokered` for every row whose provenance column says so, and + * reads the app slug off the url — so `{ credential: "brokered", toolkit: null }` is a real + * state, which a hand edit or a restored backup produces and nothing in the product does. + * `connectionTokenFor` already refuses it, but only where listing needs a credential, and a + * brokered listing needs none: the broker publishes an action's schema to anybody. So the gate + * was skipped on exactly the path that reaches the vendor with no app named, and the transport + * answered `[]` — indistinguishable, one line later, from an app that advertises nothing. + * + * READ AS FIELDS, not as a transport. `credential` and `toolkit` are both resolved in + * `access.ts` and this asks nothing about which protocol is underneath: any broker reached + * without an app named is unroutable, which is the property `toolkit` is documented to carry. + * A `transport === "composio"` test here would put back the per-call-site derivation that + * module exists to have removed. + */ + if (access.credential === "brokered" && !access.toolkit) { + throw new PluginInvariantError( + `${row.id} resolves to a brokered credential with no app in its url, so there is nothing to ask what it offers.`, + ); + } + /* + * ASKING THE VENDOR, and the only part of this method whose failure is a vendor's. + * + * CRITERION. What lands in `lastError` must be something a vendor, a credential or a person + * could have caused. An invariant this deployment violated and a fault in its own database must + * not read as a vendor misbehaving. + * + * REASON. This used to be one `try` around everything below as well — the wholesale replace, + * the server-row update and both audit writes — with a `catch` that copied any message into + * `lastError` and answered `{ tools: 0 }`. So a statement timeout, a duplicate-key refusal or an + * `audit_events` insert that would not go in all reported a vendor that had in fact answered + * correctly, and reported it beside actions the refresh had already committed. Narrowing that + * by error class would be narrowing by prose; narrowing it by SHAPE is what this split does, so + * a line added below cannot quietly acquire a vendor's excuse. + */ + let listed: ListedTool[]; + try { /* * A credential only when listing actually needs one. * @@ -2130,127 +2199,21 @@ export function createPluginStore(options: PluginStoreOptions) { ? (await connectionTokenFor(row, entry, actorId, access)).token : undefined; - const tools = await transport.listTools({ + listed = await transport.listTools({ url: effectiveUrl(row, entry), token, }); - - /* - * ONE STEP, because the catch below promises that it is one. - * - * "The tools already held are left alone" is only true while nothing has been written yet. - * As two auto-committed statements the delete landed on its own whenever the insert did not: - * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at - * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' - * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so - * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever - * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The - * connector kept every grant an administrator had made and offered none of them, and - * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. - * - * Rolled back together, the vendor's bad answer is recorded in `lastError` and the Bots go - * on using what they were granted, which is what the comment said all along. - */ - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (tools.length > 0) { - await transaction.insert(mcpTools).values( - tools.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - /* - * What the vendor said, when the vendor said anything. - * - * Only Composio publishes any of this today, so all three stay null or false for - * every other transport — which is what keeps `classifyTool` falling back to the - * curated write list for Notion and Drive exactly as it did before. - */ - effect: tool.effect ?? null, - destructive: tool.destructive ?? false, - version: tool.version ?? null, - })), - ); - } - }); - - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: new Date(), - lastError: null, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - - /* - * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at - * nothing. - * - * Reporting it on a screen answers "what is true now", which somebody has to go and look at. - * This answers "when did it stop being offered, and what was holding it" — the question asked - * after a transport is swapped back and a name starts resolving again. Without the row, the - * only record of the gap is its absence. - * - * Not a refusal and not an error, so `configuration.changed` rather than a new event type: - * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so - * what it names is what is actually left over. - */ - const advertised = new Set(tools.map((tool) => tool.name)); - const stranded = [...(await mcpGrantsForServers([serverId])).entries()] - .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) - .sort(([left], [right]) => left.localeCompare(right)); - - if (stranded.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "grants_not_advertised", - server: serverId, - // The refs, because that is what a grant is keyed on and what an administrator revokes. - refs: stranded.map(([ref]) => ref), - bots: [...new Set(stranded.flatMap(([, agents]) => agents))], - note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", - }, - }); - } - + } catch (error) { /* - * Tools the vendor advertises that this deployment's write list does not name. - * - * The mechanical half of the reconciliation Notion's catalogue entry says is required. See - * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an - * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list - * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * The narrowing throws in `connectionTokenFor` are ours, not a vendor's. * - * `configuration.changed` rather than a type of its own, the same as the stranded grants - * above and for the same reason: nothing was denied and the refresh succeeded. What changed - * is that the deployment now knows a name it had not classified. + * They fire for a row that resolved to a brokered credential with no app in its url, or to a + * per-person credential with no `user-oauth` entry — contradictions between this deployment's + * own tables and its own code. Raised rather than recorded, so the page does not send whoever + * reads it to somebody else's status page. */ - const unlisted = unlistedAdvertisedTools(entry, [...advertised]); - if (unlisted.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: actorId, - change: "unlisted_tools_advertised", - server: serverId, - tools: unlisted, - note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", - }, - }); - } + if (error instanceof PluginInvariantError) throw error; - return { tools: tools.length }; - } catch (error) { const message = error instanceof McpServerError || error instanceof Error ? error.message @@ -2275,6 +2238,183 @@ export function createPluginStore(options: PluginStoreOptions) { .where(eq(mcpServers.id, serverId)); return { tools: 0 }; } + + /* + * An empty listing never destroys what a real listing recorded. + * + * CRITERION ONE. An empty answer must not be committed as a healthy refresh where doing so + * would delete actions this deployment holds, and must not clear `lastError`. + * + * CRITERION TWO. "This app advertises nothing" stays recordable: an app with nothing held has + * nothing to lose, so the empty answer falls through to the replace below and commits — a + * refresh stamp, no error, no actions. + * + * REASON. `listTools` returning `[]` is supposed to mean "the vendor was asked and advertises + * none", and for three of the four transports it does. `composio.listTools` breaks it: it + * answers `[]` when no client is installed, and NOTHING IN THE SHIPPED PRODUCT CALLS + * `useComposioClient` — so on every real deployment that is the only answer a Composio refresh + * can produce. Committed, it deleted every `mcp_tools` row for the app, taking the recorded + * `effect`, `destructive` and `version` with it. `version` is the one that cannot be + * reconstructed: `callTool` refuses an action without it, so a refresh that reported success + * broke every subsequent call. The grants survived, pointing at rows that no longer existed — + * absent from `listServers`, and revived by a later refresh that worked. + * + * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor + * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the + * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and + * only a refresh from a deployment that can actually reach the vendor puts it back. + * + * THE SEAM REQUIREMENT this leans on, which is not satisfied today: a transport that could not + * ask anybody must THROW rather than return `[]`. `composio.ts:141` returns `[]` for a missing + * client and for a url that names no app, and until it throws instead there is no field on the + * listing that tells the two apart. Nothing here asks which transport it is talking to, so the + * fix belongs in that one line and not in a branch on `access.transport`. + */ + if (listed.length === 0) { + const held = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + + if (held.length > 0) { + await database + .update(mcpServers) + .set({ + // Named as the state it is, because "listed nothing" and "would not answer" send an + // operator to different places. No `toolsRefreshedAt`: that column says when this + // deployment last learned what the app offers, and it did not learn it here. + lastError: `This app listed no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check that the connector is configured for this deployment, then refresh again.`, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + // What the app advertises, which is what it advertised before: the honest count, because + // nothing was replaced. + return { tools: held.length }; + } + } + + /* + * COMMITTING WHAT THE VENDOR SAID. Nothing from here down is a vendor's doing, so nothing from + * here down is caught — see the criterion on the `try` above. + * + * ONE STEP, because the paragraph above promises the held actions are left alone. + * + * "The tools already held are left alone" is only true while nothing has been written yet. + * As two auto-committed statements the delete landed on its own whenever the insert did not: + * a pod killed mid-refresh, a dropped connection, a statement timeout — or, with no crash at + * all, a server that answers `tools/list` with the same `name` twice, which `mcp_tools`' + * `(server_id, name)` primary key refuses as one multi-row insert. `mcp_tools` is shared, so + * that is every replica at once, and nothing repopulates it: `refreshTools` is only ever + * called by `addServer`, `addCustomServer` and an administrator pressing Refresh. The + * connector kept every grant an administrator had made and offered none of them, and + * `grantedToolGuidance` then told the Bot outright that it holds none of that vendor's tools. + * + * Rolled back together, the Bots go on using what they were granted — and the fault raises + * rather than being copied into `lastError`, because a transaction this database would not take + * is not something the vendor did. + */ + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (listed.length > 0) { + await transaction.insert(mcpTools).values( + listed.map((tool) => ({ + serverId, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes any of this today, so all three stay null or false for + * every other transport — and `classifyTool` reads null as silence rather than as a + * value, which is what leaves Notion and Drive classified by their reviewed write list + * exactly as they were before. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version ?? null, + })), + ); + } + }); + + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: new Date(), + lastError: null, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + + /* + * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at + * nothing. + * + * Reporting it on a screen answers "what is true now", which somebody has to go and look at. + * This answers "when did it stop being offered, and what was holding it" — the question asked + * after a transport is swapped back and a name starts resolving again. Without the row, the + * only record of the gap is its absence. + * + * Not a refusal and not an error, so `configuration.changed` rather than a new event type: + * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so + * what it names is what is actually left over — and only ever after a listing that was + * committed, because the guard above returns before this on an empty answer that would have + * named every grant the app holds. + */ + const advertised = new Set(listed.map((tool) => tool.name)); + const stranded = [...(await mcpGrantsForServers([serverId])).entries()] + .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) + .sort(([left], [right]) => left.localeCompare(right)); + + if (stranded.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: actorId, + change: "grants_not_advertised", + server: serverId, + // The refs, because that is what a grant is keyed on and what an administrator revokes. + refs: stranded.map(([ref]) => ref), + bots: [...new Set(stranded.flatMap(([, agents]) => agents))], + note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", + }, + }); + } + + /* + * Tools the vendor advertises that this deployment's write list does not name. + * + * The mechanical half of the reconciliation Notion's catalogue entry says is required. See + * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an + * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list + * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * + * `configuration.changed` rather than a type of its own, the same as the stranded grants + * above and for the same reason: nothing was denied and the refresh succeeded. What changed + * is that the deployment now knows a name it had not classified. + */ + const unlisted = unlistedAdvertisedTools(entry, [...advertised]); + if (unlisted.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: actorId, + change: "unlisted_tools_advertised", + server: serverId, + tools: unlisted, + note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", + }, + }); + } + + return { tools: listed.length }; }, async listServers(): Promise { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index f4c5778d4..2abb4e0bd 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -41,6 +41,7 @@ import { exchangeRefreshTokenOverHttp, INVALID_CLIENT, type OAuthClient, + PluginInvariantError, PluginRefusedError, type PluginStore, TokenRefusedError, @@ -2647,9 +2648,14 @@ describe("a dynamic client the vendor has evicted", () => { * The classification an MCP server has always had, over a real listing that really happened. * * The three columns the refresh now writes are Composio's, and every other transport has to keep - * coming out of that insert as null and false — because `classifyTool` prefers a recorded effect to - * the reviewed write list, so a value appearing here for Notion would silently reclassify every one - * of its reads as a write, on a connector nobody touched. Asserted on the rows AND on what the + * coming out of that insert as null and false. `classifyTool` consults the reviewed `writeTools` + * list BEFORE the recorded `effect` column, on the criterion that a recorded value may narrow what + * a Bot is allowed and may never widen it — so a name the list covers stays a write whatever the + * column says, and a value appearing here can no longer turn one of Notion's reviewed writes into a + * read. The other direction is still open, which is what this test is for: a name the list does not + * cover falls through to the column, where PRESENCE rather than truthiness decides, so an effect + * recorded for Notion would silently reclassify every one of its reads as a write, on a connector + * nobody touched. Only `null` and `undefined` are silence. Asserted on the rows AND on what the * Plugins page derives from them, because it is the second one that an administrator reads. * * It lives in this suite because this is the only place a `user-oauth` listing can actually be @@ -4104,3 +4110,209 @@ test("a granted Composio action that the vendor withdrew is still shown as grant "gmail/GMAIL_SEND_EMAIL", ]); }); + +/* + * A refresh that listed nothing, on the path every real deployment takes. + * + * NOTHING IN THE SHIPPED PRODUCT CALLS `useComposioClient`, so `installed` is null on every live + * install and the Composio transport answers `[]` — not a throw — for want of a client to ask with. + * The tests below install no stub, which is that state exactly rather than a fiction about it. + * + * A wholesale replace on that answer deletes what the last real listing recorded, and `version` is + * the column that cannot be reconstructed: `callTool` refuses without it, so an app whose actions + * were dropped this way stops working for everybody until somebody presses Refresh on a deployment + * that can actually reach the vendor. The grants survive the delete pointing at rows that no longer + * exist, which is invisible on the Plugins page and revived by the next refresh that works. + */ +describe("a refresh whose transport could not ask anybody", () => { + test("leaves the actions the app already advertises, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("does not report the app as healthy", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + await database + .update(mcpServers) + .set({ lastError: "The vendor would not answer." }) + .where(eq(mcpServers.id, "gmail")); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + // Not cleared. An empty answer from a transport that reached no vendor is not evidence that + // whatever was wrong before has been put right. + expect(row?.lastError).not.toBeNull(); + // And no refresh stamp, because nothing was listed: the column says when this deployment last + // learned what the app offers, and it did not learn it here. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("does not strand the grants the app is holding", async () => { + const { store, database, auditStore } = await freshStore(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered, which would be the trail asserting a withdrawal + // the vendor never made. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); + + test("a brokered row whose url names no app raises rather than blaming the vendor", async () => { + const { store, database } = await freshStore(); + // `accessFor` still answers `brokered` for any row whose provenance says composio, so this is + // `{ credential: "brokered", toolkit: null }` — a row a hand edit or an old backup produces and + // nothing in the product can. There is no app to ask, so an empty answer is not the vendor's. + await seedComposioGmail(database, store, { + url: "https://example.com/mcp", + }); + + await expect(store.refreshTools("gmail", "admin_user")).rejects.toThrow( + PluginInvariantError, + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + // The state is this deployment's own, so it must not be written down as something a vendor did. + expect(row?.lastError).toBeNull(); + }); +}); + +/** + * The genuine empty listing, which has to stay recordable. + * + * The guard above must not turn "this app advertises nothing" into a state the deployment cannot + * hold, or an app that really offers no actions would read as broken for good. An app with nothing + * recorded against it has nothing to lose, so the empty answer commits: refreshed, no error and no + * actions, which is the honest reading of an app that advertises none. + */ +test("an app with nothing recorded against it can be refreshed to no actions at all", async () => { + const { store, database } = await freshStore(); + useComposioClient({ listActions: async () => [], execute: async () => ({}) }); + await database.insert(mcpServers).values({ + id: "gmail", + title: "Gmail", + vendor: "Composio", + url: "composio://gmail", + provenance: "composio", + lastError: "Whatever was wrong last time.", + }); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ tools: 0 }); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).not.toBeNull(); +}); + +/** + * An audit write that fails, which is not the vendor misbehaving. + * + * The refresh used to run the listing, the replace, the server-row update and both audit writes + * inside one `catch` that recorded everything as `lastError` and answered `{ tools: 0 }`. So a + * database that would not take an audit row reported a vendor which had in fact answered correctly, + * and reported it against actions the refresh had already committed — sending whoever reads the + * page to a vendor's status page over a fault in their own database. + * + * Its own store rather than {@link freshStore}, because the audit insert is the seam that has to + * fail and that fixture's is deliberately a real one. + */ +test("an audit write that fails is not recorded as the vendor misbehaving", async () => { + const database = await freshDatabase(); + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + if ( + (event.payload as { change?: string }).change === + "grants_not_advertised" + ) { + throw new Error("audit_events would not take the row"); + } + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => ({}), + }); + // The granted action is absent from what the vendor now lists, so the refresh reaches the audit + // write about grants nothing advertises — the one the stub above refuses. + await seedComposioGmail(database, failing); + + await expect(failing.refreshTools("gmail", "admin_user")).rejects.toThrow( + "audit_events would not take the row", + ); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + expect(row?.lastError).toBeNull(); +}); From 5015c9ff2adf20e5a6e745373857b8a5555fd12c Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:54:18 +0200 Subject: [PATCH 050/215] Fail the access tests when the slug they resolve is gone A guard that returned instead of asserting made two of these tests pass by doing nothing. The one that matters is the curated-entry-beats-provenance test: its early return silently vacated the property that stops a tampered `provenance = "composio"` turning a reviewed, host-pinned vendor into a brokered one, so a renamed slug would have left that protection unguarded behind a green suite. Both now throw, naming what stopped being checked. The routines row also carried `openbot://routines`, a scheme that exists nowhere else here, so the shape under test was not the shape a row has; it is resolved through `resolveServerUrl` now. The synthetic public entry grew the `https://` its catalogue invariant requires, and joins its row url from its own host and path so the two cannot drift into disagreeing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-access.test.ts | 36 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index be9dcdebf..2187a340c 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import type { CatalogueEntry } from "../src/plugins/catalogue"; -import { catalogueEntry } from "../src/plugins/catalogue"; +import { catalogueEntry, resolveServerUrl } from "../src/plugins/catalogue"; /** * How a server row is reached, resolved once. @@ -74,12 +74,19 @@ describe("accessFor", () => { }); test("Routines is in-process, with no credential, and acts as the person", () => { - const routines = catalogueEntry("routines"); - if (!routines) return; + // Resolved the way a row is written rather than spelled by hand. The url this used to carry — + // `openbot://routines` — is a scheme this codebase does not have anywhere, so the row shape the + // test claims to cover was not the one being passed in. + const routines = resolveServerUrl("routines"); + if (!routines) { + throw new Error( + "catalogue slug `routines` no longer resolves, so this test asserts nothing about it", + ); + } expect( accessFor( - { provenance: "first-party", url: "openbot://routines" }, - routines, + { provenance: "first-party", url: routines.url }, + routines.entry, ), ).toEqual({ transport: "builtin-routines", @@ -98,7 +105,10 @@ describe("accessFor", () => { title: "Public Thing", vendor: "Somebody", summary: "A server that answers without being told who is asking.", - host: "mcp.example.com", + // Scheme included, because every non-builtin entry carries one — pinned by + // `plugin-catalogue.test.ts`. A bare host here made this stand for an entry the catalogue + // would reject, and the row url below is joined from it so the two cannot drift apart. + host: "https://mcp.example.com", path: "/mcp", auth: { kind: "none" }, writeTools: [], @@ -106,7 +116,10 @@ describe("accessFor", () => { }; expect( accessFor( - { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + { + provenance: "first-party", + url: `${publicEntry.host}${publicEntry.path}`, + }, publicEntry, ), ).toEqual({ @@ -119,7 +132,14 @@ describe("accessFor", () => { test("a curated entry wins over provenance, so a slug cannot be shadowed into a broker", () => { const notion = catalogueEntry("notion"); - if (!notion) return; + // Thrown rather than returned. A missing slug here does not make the property hold, it makes + // this test stop checking it — and the whole point of the test is that the protection is never + // unguarded. Renaming the slug must break this file, not quietly empty it. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that an entry beats provenance", + ); + } // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a // curated slug is the same tampering by another field. From 02cc2ef87f026397eb86308dad0663966a05b8d1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:53 +0200 Subject: [PATCH 051/215] Arm the Drive test's fetch, so a call made before its stub cannot reach Google Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/google-drive-rest.test.ts | 121 +++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 2700c5293..9130febcc 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { accessFor } from "../src/plugins/access"; import { catalogueEntry } from "../src/plugins/catalogue"; import { callTool, listTools } from "../src/plugins/google-drive-rest"; -import { callTool as mcpCallTool } from "../src/plugins/mcp"; +import { type McpTool, callTool as mcpCallTool } from "../src/plugins/mcp"; import { transportFor } from "../src/plugins/transport"; /** @@ -19,8 +19,35 @@ const connection = { }; const realFetch = globalThis.fetch; + +/** + * Anything this file did not stub is an escape, and an escape fails the test that let it out. + * + * Ordering used to matter here: a test awaited `listTools` before installing its stub, and got away + * with it only because this adapter's list happens to be a local constant. Reordering that one call + * fixes it once; a call made before its stub would go out to Google again the moment any of these + * functions grows a request. So `fetch` is armed to refuse instead, before every test. + * + * Refusing is not enough on its own — the adapter catches its own transport errors and reports them + * as a sentence, which would turn an escape into a plausible-looking failure message. The escapes + * are therefore recorded and the ledger asserted empty afterwards, so one is named as what it is + * rather than read as Drive being unreachable. + */ +let escapedToNetwork: string[] = []; + +beforeEach(() => { + escapedToNetwork = []; + // Annotated as answering, though it never does: a function that only throws infers as returning + // `never`, which does not overlap `fetch` enough for the cast the stub below makes freely. + globalThis.fetch = (async (input: string | URL): Promise => { + escapedToNetwork.push(String(input)); + throw new Error(`unstubbed fetch escaped to the network: ${String(input)}`); + }) as typeof fetch; +}); + afterEach(() => { globalThis.fetch = realFetch; + expect(escapedToNetwork).toEqual([]); }); /** Records what was requested and answers with a fixed body. */ @@ -43,8 +70,31 @@ function stubFetch( return calls; } +/** + * Arguments a tool will accept, read off the tool's own schema rather than written out here. + * + * A tool added to the adapter with a required argument this file has never heard of still gets + * called with one, so the coverage below cannot quietly stop covering it. + */ +function argsFor(tool: McpTool): Record { + const required: unknown[] = Array.isArray(tool.inputSchema.required) + ? tool.inputSchema.required + : []; + return Object.fromEntries(required.map((name) => [String(name), "given"])); +} + +/** + * A body that answers every advertised tool: a listing for the searches, a text file for the reads. + */ +const anyToolsBody = { + files: [], + id: "given", + name: "notes.txt", + mimeType: "text/plain", +}; + describe("the adapter is the transport the catalogue asks for", () => { - test("the Drive entry resolves to this adapter, not to MCP", async () => { + test("the Drive entry resolves to this adapter, not to MCP", () => { const entry = catalogueEntry("google-drive"); expect(entry?.transport).toBe("google-drive-rest"); // Identity, not shape: proves the registry wired this module rather than something MCP-shaped. @@ -73,15 +123,70 @@ describe("the adapter is the transport the catalogue asks for", () => { }); test("every advertised tool is one the dispatcher handles", async () => { + // Stubbed before the first call of any kind, so nothing here depends on `listTools` staying + // local; the guard above turns a reintroduction of that order into a named failure. + stubFetch(anyToolsBody); const tools = await listTools(connection); - stubFetch({ files: [] }); + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { - // Called with no arguments on purpose. A handled tool complains about a missing argument or - // answers; an unhandled one says it is not implemented, which is the failure being excluded. - const result = await callTool(connection, tool.name, {}); - expect(result.text).not.toContain("is not a tool this connector"); + /* + * Called with what the tool asks for, and asserted on what a handled tool DOES: it reaches + * Drive and answers. The dispatcher's fallthrough is the failure being excluded, and it is + * excluded by never making a request — which stays true however that refusal is worded, and + * which a reworded, mistyped or entirely different error cannot satisfy. + */ + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, tool.name, argsFor(tool)); + expect(calls.length).toBeGreaterThan(0); + expect(result.isError).toBe(false); } }); + + test("a tool the dispatcher does not implement is refused without a request", async () => { + // The other half of the pair: the fallthrough exists, and is what a tool NOT in the list gets. + const calls = stubFetch(anyToolsBody); + const result = await callTool(connection, "delete_everything", {}); + + expect(result.isError).toBe(true); + expect(calls).toHaveLength(0); + }); +}); + +/* + * Drive is `user-oauth`, so the store refuses a call with nobody's credential long before this + * module is reached and the adapter's own check is the second lock. It is asserted anyway: it is + * the difference between a sentence saying so and a request to Google carrying `Bearer undefined`, + * which Drive answers with a 401 whose meaning is a great deal less obvious. + */ +describe("a call with no credential never leaves the process", () => { + const withoutToken = { url: connection.url }; + + test("every advertised tool refuses, and none of them requests anything", async () => { + stubFetch(anyToolsBody); + const tools = await listTools(withoutToken); + expect(tools.length).toBeGreaterThan(0); + + for (const tool of tools) { + const calls = stubFetch(anyToolsBody); + const result = await callTool(withoutToken, tool.name, argsFor(tool)); + // Silence first, and asserted as silence rather than as wording: nothing was requested, so + // no `Bearer undefined` went to Google to come back as a 401 about the wrong thing. + expect(calls).toHaveLength(0); + expect(result.isError).toBe(true); + } + }); + + test("listing what the adapter offers asks nobody, so it needs nothing", async () => { + /* + * Two properties in one call, and the second is why this test is left unstubbed. The gate that + * once stood here made connecting Drive a four-stop journey, so a tokenless listing has to + * answer in full — and the reason it can is that it asks nobody, which the armed `fetch` above + * is what proves. A `listTools` that grew a request would fail here by name. + */ + expect(await listTools(withoutToken)).toEqual(await listTools(connection)); + expect(escapedToNetwork).toEqual([]); + }); }); describe("a search becomes the right Drive request", () => { From 559dd99d008ac6db6b220dd3393545d3464323a0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:22 +0200 Subject: [PATCH 052/215] Make the live test watch the vendor instead of erasing what it watches Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-live.test.ts | 174 ++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 25 deletions(-) diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts index 39d478d24..62e35198c 100644 --- a/server/tests/composio-live.test.ts +++ b/server/tests/composio-live.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { Composio } from "@composio/core"; +import { + Composio, + ComposioError, + ComposioToolVersionRequiredError, + type ToolExecuteResponse, +} from "@composio/core"; import { effectOf, vendorSentence } from "../src/plugins/composio"; /** @@ -11,38 +16,61 @@ import { effectOf, vendorSentence } from "../src/plugins/composio"; * them passed the whole stubbed suite. A stub asserts what its author believed. This asserts what the * vendor does. * + * WRITTEN AGAINST THE VENDOR'S TYPES, WITH NO `as never`. `server/tsconfig.json` does not include + * `tests`, so nothing in the build type-checks this file — which means a cast here erases the only + * place in the repo where the real SDK surface is named, and erasing it defeats the one job the file + * has. Every call below is typed by `@composio/core` itself. Where a value has to be narrowed, it is + * narrowed by a runtime check that says what was missing, not by a cast that asserts it was there. + * * SKIPPED WITHOUT A KEY, so CI and a contributor with no Composio account are unaffected. Run it * deliberately: `OPENBOT_LIVE_COMPOSIO=1 COMPOSIO_API_KEY=... bun test tests/composio-live.test.ts`. * - * IT READS AND IT FAILS ON PURPOSE. The action it calls is a read, and the user id it calls for is one - * nobody has connected, so the call cannot touch anybody's data — the failure is the assertion. + * IT READS AND IT FAILS ON PURPOSE. Every action it calls is a read; the user id it calls for is one + * nobody has connected, and the one action it calls that is allowed to reach a third party is a + * no-auth public lookup of a record that does not exist. So the calls cannot touch anybody's data — + * the failure is the assertion. */ const key = process.env.COMPOSIO_API_KEY?.trim(); const live = process.env.OPENBOT_LIVE_COMPOSIO === "1" && Boolean(key); +/** A user id nobody has connected, so no call below can reach an account that belongs to somebody. */ +const NOBODY = "openbot-live-test-nobody"; + +/** + * The vendor's own no-auth example action, and a record it cannot find. + * + * `@composio/core` 0.18.1 uses `HACKERNEWS_GET_USER` in three of its own `tools.execute` doc examples + * with no connected account in sight, which is why it is the action picked to provoke a failure the + * vendor reports rather than throws. The test below re-checks the vendor's `isNoAuth` label before it + * calls, so a toolkit that stops being no-auth says so instead of quietly asking for a connection. + */ +const NO_AUTH_ACTION = "HACKERNEWS_GET_USER"; +const NO_SUCH_RECORD = "openbot-live-test-no-such-hacker-news-user"; + describe.skipIf(!live)("Composio, for real", () => { // Constructed inside each test rather than here, because Bun evaluates the body of a skipped // describe: the constructor throws without a key, which would make this file fail rather than skip. const client = () => new Composio({ - apiKey: key as string, + apiKey: key, // Their default telemetry installs its own interrupt handlers, and this is a self-hosted product // whose operator never opted into a third party's analytics. allowTracking: false, disableVersionCheck: true, - } as never); + // Pinned so the result is the same on every machine. `getToolkitVersionsFromEnv` folds any + // exported `COMPOSIO_TOOLKIT_VERSION_` into this config, and a version test whose answer + // depends on the operator's shell reports the operator's environment as a vendor change. + // Config wins over the environment, so naming the toolkits this file touches settles it. + toolkitVersions: { gmail: "latest", hackernews: "latest" }, + }); test("a listing carries a version and a behaviour label for every action", async () => { const composio = client(); - const actions = (await composio.tools.getRawComposioTools({ + const actions = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], // Explicit, because their default page is 20 and Gmail has 63. limit: 500, - } as never)) as { - slug: string; - version?: string; - tags?: string[]; - }[]; + }); expect(actions.length).toBeGreaterThan(50); expect(actions.every((action) => Boolean(action.version))).toBe(true); @@ -65,18 +93,33 @@ describe.skipIf(!live)("Composio, for real", () => { test("calling for somebody with no connection fails with a sentence naming that", async () => { const composio = client(); - const [action] = (await composio.tools.getRawComposioTools({ + const [action] = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], limit: 1, - } as never)) as { slug: string; version: string }[]; + }); + // Stated rather than destructured into a crash. A vendor that lists nothing has not disagreed + // with the assertion below, it has left the assertion unmade, and the two want opposite reactions. + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } + // `Tool.version` is optional in `ToolSchema`, so the concrete version this call needs is a + // precondition and not something to assert into existence with a cast. + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } let thrown: unknown; try { await composio.tools.execute(action.slug, { - userId: "openbot-live-test-nobody", + userId: NOBODY, arguments: {}, - version: action.version, - } as never); + version, + }); } catch (error) { thrown = error; } @@ -87,28 +130,109 @@ describe.skipIf(!live)("Composio, for real", () => { expect(vendorSentence(thrown)).toMatch(/no connected account/i); }); - test("a call without a version is refused, and 'latest' is not a version", async () => { + /** + * NOT A CHECK ON COMPOSIO, and named that way now because it used to be named as though it were. + * + * `executeComposioTool` throws `ComposioToolVersionRequiredError` on the statement directly above + * its own `try` — `@composio/core` 0.18.1, `dist/index.mjs:1728` — before the request body is built + * and before anything is sent. So no answer from Composio is involved and none can drift; what this + * watches is the SDK's local guard. + * + * Kept rather than deleted, because that guard is the reason the transport carries a version column + * at all. If the SDK ever stops refusing, `latest` becomes reachable and the column, and the refusal + * this transport inherits from it, are both worth revisiting. + */ + test("the SDK refuses locally, before dialling, when a version resolves to 'latest'", async () => { const composio = client(); - const [action] = (await composio.tools.getRawComposioTools({ + const [action] = await composio.tools.getRawComposioTools({ toolkits: ["gmail"], limit: 1, - } as never)) as { slug: string }[]; + }); + if (!action) { + throw new Error( + "Precondition not met: Composio listed no Gmail action, so there was nothing to call. Nothing below was exercised.", + ); + } - for (const version of [undefined, "latest"]) { + // Both spellings of "no concrete version": omitted, which the SDK resolves through the config to + // `latest`, and `latest` asked for by name. `body.version ?? getToolkitVersion(...)` reads an + // explicit `undefined` exactly as it reads an absent key, so passing it is the omitted case. + for (const version of [undefined, "latest"] as const) { let thrown: unknown; try { await composio.tools.execute(action.slug, { - userId: "openbot-live-test-nobody", + userId: NOBODY, arguments: {}, - ...(version ? { version } : {}), - } as never); + version, + }); } catch (error) { thrown = error; } - // If either of these ever stops throwing, the version column and its refusal can be revisited. - expect((thrown as { code?: string })?.code).toBe( + expect(thrown).toBeInstanceOf(ComposioToolVersionRequiredError); + // Read off a narrowed instance rather than cast onto `unknown`: `ComposioError.code` is + // declared `string | undefined`, and the `TS-SDK::` prefix is the SDK stamping its own errors. + expect(thrown instanceof ComposioError ? thrown.code : null).toBe( "TS-SDK::TOOL_VERSION_REQUIRED", ); } }); + + /** + * The one that mattered, and the one that was missing. + * + * A 200 carrying `successful: false` came back from this transport as `isError: false`, was audited + * as `mcp.call_succeeded`, and was handed to the model as though the failure were content. The fix + * reads the field; nothing here checked that the real API still sends it, so the exact drift the fix + * was about was unguarded against the vendor. + * + * `ToolExecuteResponseSchema` makes `successful` required and `transformToolExecuteResponse` parses + * every answer through it, so an SDK resolution is guaranteed to carry the field. What only a live + * call can show is the other half: that the vendor reports a failure by RESOLVING with that field + * set to false, and not only by throwing. If this ever throws instead, `reportedFailure` in the + * transport has become dead code and the throw path is carrying the whole load. + */ + test("a failure the vendor reports arrives as a resolution, not only as a throw", async () => { + const composio = client(); + const [action] = await composio.tools.getRawComposioTools({ + tools: [NO_AUTH_ACTION], + }); + if (!action) { + throw new Error( + `Precondition not met: Composio does not list ${NO_AUTH_ACTION}, so no unauthenticated call could be made. Nothing below was exercised.`, + ); + } + if (action.isNoAuth !== true) { + throw new Error( + `Precondition not met: Composio no longer marks ${NO_AUTH_ACTION} as no-auth, so calling it would need somebody's connected account. Pick another no-auth action rather than connecting one.`, + ); + } + const version = action.version; + if (!version) { + throw new Error( + `Precondition not met: Composio listed ${action.slug} with no version, so no versioned call could be made. Nothing below was exercised.`, + ); + } + + let thrown: unknown; + let answer: ToolExecuteResponse | undefined; + try { + answer = await composio.tools.execute(action.slug, { + userId: NOBODY, + // A public lookup of a record that does not exist: the request reaches the vendor, and the + // vendor has nothing of anybody's to return. + arguments: { userId: NO_SUCH_RECORD }, + version, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeUndefined(); + expect(answer?.successful).toBe(false); + // `ComposioResult` in the transport spells `error` as `string | null`, and a reported failure that + // says nothing is what `unexplained()` exists for — so null is legal here and a third type is drift. + expect(answer?.error === null || typeof answer?.error === "string").toBe( + true, + ); + }); }); From 54e63fa8dff825aa3c84ef13bac75db9e3362de6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:53:33 +0200 Subject: [PATCH 053/215] Give the Composio stubs answers the vendor could actually send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `execute` stub answered `{}` or `{ messages: [] }`, neither of which `ToolExecuteResponseSchema` in @composio/core 0.18.1 can produce: it spells `data`, `error` and `successful` all required. Nothing caught it because server/tsconfig.json excludes tests, and at runtime the shapes stayed green for the wrong reason — an absent `successful` is not `successful === false`, so the transport's failure branch was never the thing being exercised. A typed helper now holds the envelope, so the success path's greenness is about the success path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 2abb4e0bd..b398bf0a0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -32,7 +32,10 @@ import { users, } from "../src/db/schema"; import { catalogueEntry } from "../src/plugins/catalogue"; -import { useComposioClient } from "../src/plugins/composio"; +import { + type ComposioResult, + useComposioClient, +} from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; import { type AccessToken, @@ -3482,6 +3485,28 @@ async function seedComposioGmail( ); } +/** + * What Composio answers a call that worked, in the shape its own schema requires. + * + * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells `data`, `error` and `successful` + * REQUIRED. Every stub below used to answer `{}` or `{ messages: [] }`, which are shapes the vendor + * cannot produce, and nothing flagged it: `server/tsconfig.json` excludes `tests`, so no typecheck + * reads these files at all. They stayed green for a reason that is not the property under test — + * an ABSENT `successful` is not `successful === false`, so the transport's failure branch was + * simply never entered. A stub that can only answer things the vendor could actually say is what + * makes the success path's greenness mean something. + * + * Typed as {@link ComposioResult} rather than left to inference, so a vendor shape that drifts is a + * red squiggle here even though the suite is outside the typecheck's reach. + */ +const vendorAnswered = ( + data: Record = {}, +): ComposioResult => ({ + data, + error: null, + successful: true, +}); + // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); @@ -3544,7 +3569,7 @@ test("a Composio app is listed through the Composio transport, not dialled as MC asked.push(toolkit); return []; }, - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await database.insert(mcpServers).values({ id: "gmail", @@ -3569,7 +3594,7 @@ test("a Composio call with nobody attributed is refused before it reaches the ve listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3611,7 +3636,7 @@ test("a Composio call with nobody attributed is refused even when a connection r listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3646,7 +3671,7 @@ test("a Composio call by somebody who has not connected the app is refused with listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store, { connect: false }); @@ -3672,7 +3697,7 @@ test("a Composio call whose url names no app is refused rather than falling back listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); // Brokered by provenance, with a url that names no Composio app: `accessFor` answers @@ -3699,7 +3724,7 @@ test("a Composio call whose row id and url name different apps is refused", asyn listActions: async () => [], execute: async (slug) => { reached.push(slug); - return {}; + return vendorAnswered(); }, }); // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is @@ -3729,7 +3754,7 @@ test("a Composio call sends the version recorded for that action", async () => { listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3762,7 +3787,7 @@ test("a version a model supplied in its own arguments cannot beat the recorded o listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); await seedComposioGmail(database, store); @@ -3791,7 +3816,7 @@ test("a version a model supplied cannot stand in for an action with none recorde listActions: async () => [], execute: async (slug, _userId, version) => { calls.push({ slug, version }); - return {}; + return vendorAnswered(); }, }); // The action with no recorded version, which is the branch the test above does not cover: there @@ -3824,7 +3849,7 @@ test("a Composio call is recorded as reaching the vendor as the person, not as t const { store, database, auditStore } = await freshStore(); useComposioClient({ listActions: async () => [], - execute: async () => ({ messages: [] }), + execute: async () => vendorAnswered({ messages: [] }), }); await seedComposioGmail(database, store); @@ -3938,7 +3963,7 @@ test("a brokered call is judged by the effect the vendor recorded, not by the ab const { store, database, auditStore } = await freshStore(); useComposioClient({ listActions: async () => [], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); // `effect: "read"` on the seeded action, and no catalogue entry for `gmail` at all — so the two // sources disagree and the row records which one decided. @@ -4012,7 +4037,7 @@ test("refreshing a Composio app records each action's effect, destructive marker version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await database.insert(mcpServers).values({ @@ -4086,7 +4111,7 @@ test("a granted Composio action that the vendor withdrew is still shown as grant version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); await seedComposioGmail(database, store); @@ -4234,7 +4259,10 @@ describe("a refresh whose transport could not ask anybody", () => { */ test("an app with nothing recorded against it can be refreshed to no actions at all", async () => { const { store, database } = await freshStore(); - useComposioClient({ listActions: async () => [], execute: async () => ({}) }); + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); await database.insert(mcpServers).values({ id: "gmail", title: "Gmail", @@ -4299,7 +4327,7 @@ test("an audit write that fails is not recorded as the vendor misbehaving", asyn version: "20260903_00", }, ], - execute: async () => ({}), + execute: async () => vendorAnswered(), }); // The granted action is absent from what the vendor now lists, so the refresh reaches the audit // write about grants nothing advertises — the one the stub above refuses. From 0b08915ebf96aec9febc17bd7b2518dc47c0bd9e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:23 +0200 Subject: [PATCH 054/215] Observe at the vendor which person's account a Composio call opens The deployment holds one Composio key and the id sent beside it decides whose mailbox the call reaches, so that id is the whole authorization. Every stub in this file took `_userId` and discarded it, which left the load-bearing claim of the brokered transport unobserved: a store that sent `input.botId`, or "", or a constant, passed all of them. The stub now records the id and the test names the asking person. Sending the Bot's id instead fails here and nowhere else in the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b398bf0a0..0c4b2202d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3845,6 +3845,45 @@ test("a version a model supplied cannot stand in for an action with none recorde expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); }); +/* + * WHOSE ACCOUNT THE CALL OPENS, OBSERVED AT THE VENDOR. + * + * This is the claim the whole brokered transport exists to make, and until these two tests it was + * the one thing nothing looked at. The deployment holds ONE Composio key; which person's mailbox a + * call opens is decided entirely by the id sent beside it. Every stub in this file took `_userId` + * and threw it away, so a store that sent the Bot's id, or the empty string, or a constant, passed + * all of them — the property held by construction and nothing would have noticed it stopping. + * + * `execute`'s SECOND positional argument is that id. Recorded here rather than counted, so a + * regression fails naming the id that actually went out. + */ +test("a Composio call reaches the vendor as the person asking, not as the Bot", async () => { + const { store, database } = await freshStore(); + const reached: { slug: string; userId: string }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug, userId) => { + reached.push({ slug, userId }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + // `user_asker`, not `bot_helper` and not "". The id comes off the connection the call path built + // from the session — `app.ts` takes it from a credential assertion and `routes.ts` from the + // session — and it is the only thing standing between this Bot and somebody else's mailbox. + expect(reached).toEqual([ + { slug: "GMAIL_FETCH_EMAILS", userId: "user_asker" }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From c9e132c8ec33b7775be4106b9020e70d697aeebd Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:48 +0200 Subject: [PATCH 055/215] Hold the forged-identity case to the arguments it actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test named for this asserted nothing about arguments at all. A model can name somebody else three plausible ways — `userId`, `user_id`, `entityId` — and all three reach the vendor verbatim, because the identity is `execute`'s second positional argument and `args` is the fourth. Forwarding them is correct rather than a leak: Composio spells arguments snake_case, `user_id` is an ordinary parameter name, and swallowing it would drop an argument the person meant. So this asserts both halves, the asker as the identity and the arguments untouched but for the reserved version key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 0c4b2202d..8ea8653d0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3884,6 +3884,68 @@ test("a Composio call reaches the vendor as the person asking, not as the Bot", ]); }); +/** + * An identity a model wrote into its own arguments does not become the identity of the call. + * + * THE THREE KEYS ARE THE ONES THAT WOULD WORK IF ANYTHING READ THEM. `userId` is the parameter + * name on the transport's own projection, `user_id` is how Composio spells arguments, and + * `entityId` is what their SDK called this before it was renamed — so a model that guessed at any + * of the three would be guessing well. + * + * FORWARDED, NOT STRIPPED, AND THAT IS THE CORRECT BEHAVIOUR. The identity is `execute`'s second + * POSITIONAL argument, taken from `connection.actorId`; `args` is the fourth and reaches the vendor + * as the action's own parameters. Nothing on that path reads `args` looking for an identity, which + * is what makes this structural rather than checked. Stripping the keys instead would be a bug with + * a real victim: Composio's schemas are snake_case, `user_id` is an ordinary parameter name on real + * actions, and a transport that swallowed it would quietly drop an argument the person meant. So + * this asserts BOTH halves — the vendor is handed the asker as the identity, and it is handed the + * model's arguments untouched. + * + * The absent `__version` is the other half of the same statement: the reserved key is the ONLY + * thing removed from what a model sent. + */ +test("an identity a model puts in the arguments does not change whose account the call opens", async () => { + const { store, database } = await freshStore(); + const reached: { userId: string; args: Record }[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (_slug, userId, _version, args) => { + reached.push({ userId, args }); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store); + + await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + // A model naming somebody else, three ways, beside one argument it genuinely meant. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(reached).toEqual([ + { + // The session's person. None of the three keys reached the identity, because the identity is + // not read from arguments at all. + userId: "user_asker", + // Passed through whole, minus nothing: the reserved version key is the only thing the call + // path removes, and the model sent none. + args: { + userId: "user_stranger", + user_id: "user_stranger", + entityId: "user_stranger", + query: "is:unread", + }, + }, + ]); +}); + test("a Composio call is recorded as reaching the vendor as the person, not as the deployment", async () => { const { store, database, auditStore } = await freshStore(); useComposioClient({ From 2c8a7bc95b5b7c89f4303b50f14beb8753890942 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:49 +0200 Subject: [PATCH 056/215] Hold the trail to calling a vendor's refusal a failure The event name is derived from `result.isError`, and nothing looked at the derivation: replacing it with a constant `mcp.call_succeeded` left the suite green, so the trail could have asserted success about every refused call. The one test that reads as covering this exercises the throw path instead, where the catch branch spells the name literally. The second test gates the conflation rather than hiding it: our own unreadable answer lands under the same event name as the vendor's refusal, and only the prose in `payload.failure` tells an operator which of the two happened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 8ea8653d0..c534e8020 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3507,6 +3507,18 @@ const vendorAnswered = ( successful: true, }); +/** + * What Composio answers when it ran nothing and says why, which is a 200 and not a throw. + * + * `successful: false` beside a sentence is the vendor reporting its own failure inside the + * envelope, which is the case that used to come back from this transport as `isError: false`. + */ +const vendorRefused = (sentence: string): ComposioResult => ({ + data: {}, + error: sentence, + successful: false, +}); + // The vendor is a process-wide registry, so a stub outliving its test would be answering somebody // else's calls. afterEach(() => useComposioClient(null)); @@ -3972,6 +3984,114 @@ test("a Composio call is recorded as reaching the vendor as the person, not as t expect(call?.payload).toMatchObject({ reachedAs: "user_asker" }); }); +/** + * Every call event this suite's store recorded against one tool, in order. + * + * Named because the assertions below are about WHICH event was written, and reading that off an + * unfiltered list would also pick up the grant the fixture makes. `mcp.call_` is the prefix the + * three outcomes share. + */ +function callEventsFor( + auditStore: { recorded: () => { eventType: string; targetId?: string }[] }, + targetId: string, +) { + return auditStore + .recorded() + .filter( + (event) => + event.targetId === targetId && event.eventType.startsWith("mcp.call_"), + ); +} + +/** + * A vendor that reported its own failure is filed as a failure, not as a success. + * + * `mcp.call_failed` is derived from `result.isError` and nothing asserted the derivation: flipping + * the two event names left the suite green, so the trail could have said `mcp.call_succeeded` about + * every refused call and the only surface that counts successes would have agreed. That is the same + * class of defect as the one the comment above the try block describes — a trail asserting the + * opposite of what happened — and it was still open on this branch. + * + * The sentence matters as much as the name. `payload.failure` is the vendor's own words, and it is + * the most useful thing an operator gets: it is what turned "the connector is broken" into "the + * connection lapsed" on the Drive path. + */ +test("a call the vendor refused is filed as failed, with the vendor's own sentence", async () => { + const { store, database, auditStore } = await freshStore(); + useComposioClient({ + listActions: async () => [], + execute: async () => + vendorRefused("Gmail rejected the request: bad label."), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + // Exactly one call row, and it is the failure. Asserted as the whole list rather than by finding + // a failure in it, because a `find` passes just as happily when a `mcp.call_succeeded` row sits + // beside it — and a success row for a refused call is the thing being ruled out. + expect(callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS")).toMatchObject([ + { + eventType: "mcp.call_failed", + payload: { + actor: "user_asker", + bot: "bot_helper", + failure: "Gmail rejected the request: bad label.", + }, + }, + ]); +}); + +/** + * Our own unreadable answer is filed under the SAME name as the vendor's refusal. + * + * GATED AS IT BEHAVES TODAY, AND THE CONFLATION IS THE FINDING. `callTool` in `composio.ts` is + * careful to keep these two apart — the vendor's `try` holds the vendor's call and nothing else, + * precisely so a `JSON.stringify` throw of ours is not reported as the action having failed — and + * then `store.ts` collapses the distinction again on the way to the trail, because the event name + * is derived from `isError` alone and both are `isError: true`. So the only thing telling an + * operator "Composio refused" from "Composio answered and we could not read it" is the sentence in + * `payload.failure`, which is prose and not a queryable field. A reader counting `mcp.call_failed` + * to decide whether a connector is healthy cannot separate a vendor fault from a bug of ours. + * + * A circular `data` is the honest way to reach it: `resultOf` stringifies whatever the vendor sent, + * and a structure that cannot be serialized is one of the three faults its comment names. + */ +test("an answer this deployment could not read is filed under the same name as a vendor refusal", async () => { + const { store, database, auditStore } = await freshStore(); + const circular: Record = {}; + circular.itself = circular; + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(circular), + }); + await seedComposioGmail(database, store); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(true); + + const [event] = callEventsFor(auditStore, "gmail/GMAIL_FETCH_EMAILS"); + // The same event name the vendor's own refusal gets, one test above. + expect(event?.eventType).toBe("mcp.call_failed"); + // And the sentence is the only thing that says this one was ours. + expect((event?.payload as { failure?: string } | undefined)?.failure).toMatch( + /could not turn that answer into text/, + ); +}); + test("an action's effect, destructive marker and version round-trip", async () => { const database = await freshDatabase(); From aa52f27bf11ea3563d04f37d980cbed7819e5fcf Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:02:27 +0200 Subject: [PATCH 057/215] Refuse a brokered call from somebody a colleague's connection would carry The connection gate reads composio_connections for the app AND the asking person, but every test around it seeded a database where the app was connected by the asker or by nobody, so dropping the actor term from that where clause left the whole suite green. Without it the question becomes "has anybody connected Gmail", the first colleague to connect makes the app callable by everybody, and the broker is handed a stranger's id. This is the defect the brokered transport exists to prevent, and it is now the one thing that fails when the term goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index c534e8020..c5bb97f8d 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3702,6 +3702,51 @@ test("a Composio call by somebody who has not connected the app is refused with expect(reached).toEqual([]); }); +/** + * One person having connected the app is not the asking person having connected it. + * + * THE FAIL-OPEN THIS CLOSES. The gate reads `composio_connections` for `(toolkit, actorId)`, and + * every test around it seeds a database where the app is connected by the asker or by nobody at + * all — so dropping `eq(composioConnections.userId, actorId)` from that `where`, which turns the + * question into "has ANYBODY connected Gmail", left the whole suite green. That single term is what + * keeps one person's mailbox out of another's: with it gone, the first colleague to connect Gmail + * makes the app callable by everybody, the broker is handed the stranger's id, and Composio answers + * with whatever account it holds for them — or refuses in words that read as the connector being + * broken. + * + * The stranger is never inserted anywhere. `composio_connections.user_id` is text with no foreign + * key and the brokered path touches no vault row, so asking as somebody unknown writes nothing this + * suite would have to clean up — which is the only reason a second person can appear here without + * a fixture. + */ +test("a Composio call by somebody who has not connected the app is refused even though a colleague has", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + // `user_asker` is connected to Gmail. Nobody else is. + await seedComposioGmail(database, store); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_stranger", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Never dialled, which is the half that matters: a call let through here is spent at the broker + // in a stranger's name, and the person asking sees somebody else's mailbox or somebody else's + // error. + expect(reached).toEqual([]); +}); + test("a Composio call whose url names no app is refused rather than falling back to the row id", async () => { const { store, database } = await freshStore(); const reached: string[] = []; From 4103f8b112b7b5bd5f993f633cb9d80447cac5ed Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:03:24 +0200 Subject: [PATCH 058/215] Take a brokered connection away when the person or the app goes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 133 +++++++- server/tests/composio-connections.test.ts | 379 ++++++++++++++++++++++ 2 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 server/tests/composio-connections.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index bf50b722a..c19b1a5d0 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2006,6 +2006,12 @@ export function createPluginStore(options: PluginStoreOptions) { * * Revoked rather than deleted, because the vault keeps revoked rows for audit. * + * A THIRD KIND OF ACCESS THAT IS NOT A SECRET. A brokered app holds no per-person secret at all + * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing + * between a person and their mailbox is a `composio_connections` row, and that table references + * nothing that would cascade it. Removing the app therefore left every one of them behind, and + * adding the app back turned them live again without anybody being asked. That row goes too. + * * The revokes go first. These are writes on two tables and the store exposes no transaction that * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves * a server whose secrets no longer work and which removing again will finish off, while @@ -2013,7 +2019,13 @@ export function createPluginStore(options: PluginStoreOptions) { */ async removeServer(serverId: string, by: string): Promise { const [existing] = await database - .select({ credentialId: mcpServers.credentialId }) + .select({ + credentialId: mcpServers.credentialId, + // Read so the brokered connections below can be keyed on the app the url names, which is + // the same key the call gate uses. See there for why the row id will not do. + provenance: mcpServers.provenance, + url: mcpServers.url, + }) .from(mcpServers) .where(eq(mcpServers.id, serverId)); @@ -2097,6 +2109,64 @@ export function createPluginStore(options: PluginStoreOptions) { }); } + /* + * The third kind of access, which is not a secret at all: everybody's brokered connection. + * + * CRITERION. Removing an app must leave nobody holding brokered access to it, so that adding + * it back again grants nothing until each person has consented afresh. + * + * REASON. `composio_connections` is the whole gate on a brokered call and it references + * nothing — not `mcp_servers`, not `users` — so nothing cascaded it and removing the app left + * every row standing. Two ordinary administrative acts, remove and add back, then restored + * everybody's access at a url the second act chose, with nobody asked again and no screen + * saying it had happened. Consent that reattaches by itself is not consent. + * + * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a + * display key and nothing holds it equal to the slug in the url, so a delete by id would clear + * some other app's connections, or none, for the very row shape that gate already refuses to + * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * + * Before the server row goes, for the reason the revokes above are: what a failure between + * two writes leaves has to be the recoverable half. A connection cleared with the app still + * present is fixed by removing it again; an app deleted with the connections standing is + * reachable by no operation at all, because the toolkit was only ever readable off its url. + */ + const toolkit = existing + ? accessFor(existing, catalogueEntry(serverId)).toolkit + : null; + + if (toolkit) { + const connected = await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)) + .returning({ userId: composioConnections.userId }); + + // Sorted, so two removals of the same app write their rows in the same order. + for (const connection of connected.sort((left, right) => + left.userId.localeCompare(right.userId), + )) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: by, + server: serverId, + owner: connection.userId, + // The same three-way distinction the vault loop above draws, and the same answer: an + // administrator took the whole app away and the person did nothing. + reason: "mcp_server_removed", + /* + * False, and said out loud. This closed the gate this deployment owns; the account + * the person connected is still connected at Composio, and only they or an operator + * of that broker can end it. A row implying otherwise would be worse than no row. + */ + vendorRevoked: false, + }, + }); + } + } + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); await recordAuditEvent(auditStore, { eventType: "configuration.changed", @@ -3041,6 +3111,11 @@ export function createPluginStore(options: PluginStoreOptions) { * The join rows go too, so the account pages stop claiming a connection this deployment can no * longer use. * + * AND THE BROKERED CONNECTIONS, which are neither a credential nor a join row. Composio holds + * the account, so there is no secret in the vault to find and the `composio_connections` row is + * itself the permission — the only thing deciding whether a call may go out as this person. + * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. + * * NOT vendor-side revocation. That needs the OAuth client and the vendor's revoke endpoint, and * it belongs with disconnect. This is the half that stops us holding the secret; the grant at * Google outlives it until somebody revokes it there. Said plainly rather than implied, because @@ -3097,6 +3172,62 @@ export function createPluginStore(options: PluginStoreOptions) { .delete(mcpUserCredentials) .where(eq(mcpUserCredentials.userId, userId)); + /* + * Every app this person connected at the broker, where there is no secret to scan the vault + * for. + * + * CRITERION. After this returns, no brokered call may go out on this person's behalf. + * + * REASON. A brokered connection is not a credential: Composio holds the account and this + * deployment sends a user id, so the vault sweep above finds nothing and `composio_connections` + * is the entire gate. Reading only the vault therefore retired nothing for somebody whose only + * connector was brokered, reported that as a retirement, and left the `(toolkit, user_id)` gate + * passing for a person who no longer exists — their access outliving them, which is the first + * thing anybody asks about a per-person connector. The table's own docblock justifies its shape + * by this path, so the shape was carrying a promise nothing kept. + * + * FOUND HERE AND NOWHERE ELSE, which is what the missing foreign key buys. The row survives the + * `users` row precisely so this can still name what the person had after they are gone — the + * same argument the vault lookup above makes, from the side that has no vault row. It is also + * why the guard at the top of this method is load-bearing rather than defensive: `not null` + * admits the empty string, so a row at `(toolkit, "")` is legal, and retiring "nobody" must not + * be what deletes it. + * + * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays + * quiet on its own: the rows are gone, so the second call deletes none. + */ + const brokered = await database + .delete(composioConnections) + .where(eq(composioConnections.userId, userId)) + .returning({ toolkit: composioConnections.toolkit }); + + // Sorted, so two retirements of the same person write their rows in the same order. + for (const connection of brokered.sort((left, right) => + left.toolkit.localeCompare(right.toolkit), + )) { + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + // The app, which for a brokered connection is all the row records. The `mcp_servers` row + // it belongs to may have been removed already, and the connection outlives that too. + targetId: connection.toolkit, + payload: { + actor: by, + server: connection.toolkit, + owner: userId, + reason: "person_removed", + /* + * False here for a different reason than above. There, the grant at Google outlives our + * copy of the secret. Here there is no secret of ours at all: the account stays + * connected at Composio until somebody ends it there, and what this did was shut the + * only gate this deployment owns. + */ + vendorRevoked: false, + }, + }); + } + return { retired }; }, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts new file mode 100644 index 000000000..1a3d9eb8c --- /dev/null +++ b/server/tests/composio-connections.test.ts @@ -0,0 +1,379 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import type { + CredentialSecretReader, + CredentialStore, +} from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + agents, + composioConnections, + mcpServers, + mcpTools, + pluginGrants, + users, +} from "../src/db/schema"; +import type { ComposioActions, ComposioResult } from "../src/plugins/composio"; +import { useComposioClient } from "../src/plugins/composio"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * What ends a brokered connection, and what the trail says when nobody was asking. + * + * `composio_connections` is the sole gate on a brokered call: the row `(toolkit, user_id)` is the + * whole of the permission, it points at no vault secret, and it references neither `users` nor + * `mcp_servers`. Nothing therefore cascades it away, which is deliberate — the row has to outlive + * the person so offboarding can still find it — and it means an explicit retirement is the ONLY + * thing that can ever end one. This file is about the two acts that must perform that retirement + * and about the trail they leave. + * + * WHY THIS FILE OWNS ITS IDS OUTRIGHT, AND SO NEEDS NO REFUSE-TO-RUN GUARD. + * `plugin-store.integration.test.ts` inserts at `gmail`, `notion` and `bot_helper` and refuses to + * run when a database already holds them: it asserts things about a real vendor's own action + * classification, so its ids are forced to be the spellings production uses, and a fixture at a + * forced id cannot coexist with a real row at that id. Nothing here asserts anything about a real + * vendor — `accessFor` answers `brokered` for ANY row whose provenance column says composio, and + * reads the app slug straight off the url — so every id below carries a run-unique suffix and every + * delete is keyed on one. That makes each row this file removes provably one it inserted, which is + * the property that guard buys the other way round, and it also lets this file run beside that one. + * + * The production deletes under test are keyed the same way: `removeServer` deletes by toolkit and + * `retireConnectionsFor` by user id, and both of those values are suite-scoped here, so neither can + * reach another run's rows either. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const suite = randomUUID().slice(0, 8); +/** The app: its `mcp_servers.id`, and the slug in its url, which is what a connection is keyed on. */ +const toolkit = `revocable-${suite}`; +const actionName = "APP_FETCH_ITEMS"; +const ref = `${toolkit}/${actionName}`; +const botId = `agent_revoke_${suite}`; +/** Somebody who connected the app. */ +const askerId = `user_asker_${suite}`; +/** Somebody who connected it and whose `users` row is then deleted out from under the connection. */ +const leaverId = `user_leaver_${suite}`; +const admin = "admin@openbot.local"; + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; + +/** + * The vault, and every method loud. + * + * A brokered call reaches no credential at all — the deployment's Composio key belongs to the + * transport and never travels through the store — and neither of the removals under test has a + * secret of this suite's to retire, because no `mcp_user_token` is ever minted here. So any call to + * any of these means this file has started exercising something it does not claim to, and a silent + * stub would hide that. + * + * Typed as the interface rather than left to inference, so a method added to the vault fails here + * instead of at the assignment further down. This file is not covered by `tsc` today — `tests` is + * outside `server/tsconfig.json`'s `include` — which is exactly why the shape is stated rather than + * assumed. + */ +const credentialsStub: CredentialSecretReader & CredentialStore = { + readSecret: async () => { + throw new Error("a brokered call reads no credential"); + }, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + updateSecret: async () => { + throw new Error("this suite does not write credentials"); + }, + rotate: async () => { + throw new Error("this suite does not write credentials"); + }, + revoke: async () => { + throw new Error("this suite mints no credential to revoke"); + }, + isLive: async () => { + throw new Error("this suite holds no credential to ask about"); + }, + findLiveByKey: async () => { + throw new Error("this suite holds no credential to ask about"); + }, +}; + +/** + * A store over the real database, keeping every event it writes. + * + * Recorded ALONGSIDE the real insert rather than instead of it: the payloads are what these tests + * assert about, and a store whose audit insert never touched the database would not be exercising + * the one it has. + */ +const events: Parameters["insert"]>[0][] = + []; +const persisting = createAuditStore(database); +const auditStore = { + insert: async (event: (typeof events)[number]) => { + events.push(event); + await persisting.insert(event); + }, +}; + +const store = createPluginStore({ + database, + auditStore, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); + +/** Every action Composio was asked to run, so "was this call made" is an assertion and not a guess. */ +const reached: string[] = []; + +const answered: ComposioResult = { data: {}, error: null, successful: true }; + +/** + * A client that answers everything, so a refusal in these tests is always this deployment's. + * + * The vendor is a process-wide registry, so `afterEach` takes it back out: a stub outliving its test + * would be answering another file's calls. + */ +function useAnsweringClient(actions: Partial = {}) { + useComposioClient({ + listActions: async () => [], + execute: async (slug) => { + reached.push(slug); + return answered; + }, + ...actions, + }); +} + +/** Only this run's rows, and every one of them keyed on an id this run invented. */ +async function clean() { + await database.delete(pluginGrants).where(eq(pluginGrants.agentId, botId)); + await database.delete(agents).where(eq(agents.id, botId)); + await database.delete(mcpTools).where(eq(mcpTools.serverId, toolkit)); + await database.delete(mcpServers).where(eq(mcpServers.id, toolkit)); + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)); + await database.delete(users).where(inArray(users.id, [askerId, leaverId])); +} + +/** The app's row and its one granted action. Separated from the Bot, so a re-add can reuse the Bot. */ +async function addApp() { + await database.insert(mcpServers).values({ + id: toolkit, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: toolkit, + name: actionName, + description: "Fetch some items.", + effect: "read", + version: "20260903_00", + }); + await store.grant("mcp", ref, botId, admin); +} + +/** The app, a Bot holding its one action, and optionally somebody who has connected it. */ +async function seedApp(options: { connect?: boolean } = {}) { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + await addApp(); + if (options.connect !== false) { + await database + .insert(composioConnections) + .values({ toolkit, userId: askerId }); + } +} + +/** What this deployment still believes somebody has connected. */ +async function connectedToolkitsFor(userId: string): Promise { + const rows = await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, userId)); + return rows.map((row) => row.toolkit); +} + +function recordedOfType(eventType: string) { + return events.filter((event) => event.eventType === eventType); +} + +// Cleaning BEFORE each test as well as after the run, so a run that dies halfway leaves the next +// one nothing to trip over. +beforeEach(async () => { + await clean(); + events.length = 0; + reached.length = 0; +}); + +afterEach(() => useComposioClient(null)); + +afterAll(async () => { + await clean(); +}); + +/** + * OFFBOARDING. The act an administrator is told removes somebody's access. + * + * The call is made first, so what follows is an assertion about the retirement rather than about the + * fixture. Reaching the vendor a second time would be the person's mailbox being opened after they + * were removed. + */ +test("offboarding somebody retires the app they connected, and the next call is refused", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + const { retired } = await store.retireConnectionsFor(askerId, admin); + + // Counted, because the number is what "we removed their access" claims. Reporting the vault's + // tally alone would say nothing was retired for somebody whose only connector was brokered. + expect(retired).toBe(1); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator removing somebody, never somebody changing their own mind. And the account + // at the broker is still connected: this closed the gate, it did not revoke anything at + // Composio. + reason: "person_removed", + vendorRevoked: false, + }); +}); + +/** + * THE GATE, AFTER THE PERSON IS GONE. + * + * `composio_connections.user_id` carries no foreign key by design, so deleting somebody's `users` + * row leaves their connection standing — and the gate reads nothing but `(toolkit, user_id)`, so it + * goes on passing for an id no person answers to. That is the state offboarding exists to end, and + * it is the one the vault-based retirement cannot reach: there is no secret here to scan for, + * because Composio holds the account. + */ +test("a connection whose person is already deleted is retired, and stops passing the gate", async () => { + await seedApp({ connect: false }); + useAnsweringClient(); + + await database + .insert(users) + .values({ id: leaverId, email: `${leaverId}@example.com`, name: "Leaver" }); + await database + .insert(composioConnections) + .values({ toolkit, userId: leaverId }); + await database.delete(users).where(eq(users.id, leaverId)); + + // The design fact this rests on: the row outlives the person, which is what leaves anything to + // find. Asserted rather than assumed, because the retirement below is pointless without it. + expect(await connectedToolkitsFor(leaverId)).toEqual([toolkit]); + + const { retired } = await store.retireConnectionsFor(leaverId, admin); + expect(retired).toBe(1); + expect(await connectedToolkitsFor(leaverId)).toEqual([]); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: leaverId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([]); +}); + +/** Retiring twice is something an administrator may legitimately do, and the second time is quiet. */ +test("retiring the same person twice retires nothing the second time", async () => { + await seedApp(); + useAnsweringClient(); + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(1); + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(0); +}); + +/** + * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at + * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an + * unattributed offboarding reaching a row it cannot possibly own. + */ +test("retiring nobody retires nothing and leaves the anonymous row alone", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + + expect((await store.retireConnectionsFor("", admin)).retired).toBe(0); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); +}); + +/** + * REMOVING THE APP. The second act that has to end a brokered connection. + * + * Nothing else can: the table references `mcp_servers` no more than it references `users`, so the + * rows simply stand there once the app's row is gone. + */ +test("removing the app takes every brokered connection to it", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + + await store.removeServer(toolkit, admin); + + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0].payload).toMatchObject({ + actor: admin, + server: toolkit, + owner: askerId, + // An administrator took the whole app away and the person did nothing. Distinct from both + // "they disconnected" and "they were removed", which is what an auditor is trying to tell apart. + reason: "mcp_server_removed", + vendorRevoked: false, + }); +}); + +/** + * CONSENT MUST NOT REATTACH. + * + * Removing an app and adding it back is two ordinary administrative acts. If the connection rows + * survive them, the second act silently restores everybody's brokered access without anybody being + * asked again — and the only visible difference between an app nobody has connected and an app + * everybody is still connected to is whether a call goes out. + */ +test("adding the app back does not restore a connection nobody re-granted", async () => { + await seedApp(); + useAnsweringClient(); + + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); + + await store.removeServer(toolkit, admin); + // The same app at the same id, added again. Only the server and its action: the Bot's grant + // survived the removal on its own, which is a separate defect about `plugin_grants` and not this + // one. + await addApp(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: askerId }), + ).rejects.toThrow(/have not connected/i); + expect(reached).toEqual([actionName]); +}); From d852a7a16c798c08ac850e04a1bad0742c724b39 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:04:02 +0200 Subject: [PATCH 059/215] Say who an audit row is about when the answer is not a person Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 65 ++++++++++++++++++++-- server/tests/composio-connections.test.ts | 67 +++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index c19b1a5d0..61db3b274 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -343,6 +343,27 @@ export function unlistedAdvertisedTools( const iso = (value: Date | string | null): string | null => value === null ? null : value instanceof Date ? value.toISOString() : value; +/** + * The two things an actor field says when the actor is not a person, and they are not the same + * thing. + * + * CRITERION. A field whose purpose is to name who did something must never be written as the empty + * string. An absent field reads as absent; `""` reads as a value, so a reader grouping the trail by + * actor gets a person called nothing, and every count of "acts by X" is quietly wrong about them. + * + * `deployment` is a positive answer: nobody was asking because the deployment itself acted — a + * shared credential, a public endpoint, a refresh it ran on its own behalf immediately after an app + * was added. `unattributed` is the opposite, and the distinction is the whole point of having two: + * something happened that SHOULD have had a person behind it and this deployment could not say who. + * `identifyActor` answers `{ id: "" }` for exactly that, and the run is then refused — which is + * precisely the moment the trail is worth reading, so it must not be the moment it goes blank. + * + * Neither is an address, so neither can collide with a user id: every actor written here otherwise + * is `users.id` or the email a session resolved to. + */ +const DEPLOYMENT_ACTOR = "deployment"; +const UNATTRIBUTED_ACTOR = "unattributed"; + /** * Whose account this call went out as, for the trail. * @@ -350,9 +371,15 @@ const iso = (value: Date | string | null): string | null => * had no answer for a Composio app — the entry is null, so it fell through to `deployment` for a call * that ran in one person's own mailbox, which is the trail being wrong about the one thing a * per-person connector exists for. + * + * A person-reached server with no actor is `unattributed` and never `deployment`: the call did not + * go out on a shared credential, it did not go out at all, and naming the deployment would assert + * an attribution that never happened. See {@link DEPLOYMENT_ACTOR}. */ const reachedAsFor = (access: ServerAccess, actorId: string): string => - access.reachedAs === "person" ? actorId : "deployment"; + access.reachedAs === "person" + ? actorId || UNATTRIBUTED_ACTOR + : DEPLOYMENT_ACTOR; /** * Where this server actually is, when the stored row and the catalogue disagree. @@ -2202,6 +2229,23 @@ export function createPluginStore(options: PluginStoreOptions) { ): Promise<{ tools: number }> { const { row, entry, access } = await requireServer(serverId); + /* + * Who the trail says asked for this listing, which is not the same value as who to list AS. + * + * CRITERION. The two audit rows below must never name an actor of `""`. + * + * REASON. `actorId` does double duty: it selects the person's credential where listing needs + * one, and it is copied into those rows. The add paths pass neither, deliberately — nobody can + * have connected an app in the second it is added, and the comment above this method says why + * requiring one there was wrong. So the absence is permanent and correct for the credential, + * and meaningless for the trail, which was left writing `actor: ""` on every row an add + * produced. The deployment refreshing on its own behalf is a real answer and `reachedAs` + * already spells it that way; see {@link DEPLOYMENT_ACTOR}. Held separately rather than + * defaulting the parameter, because defaulting it would hand `connectionTokenFor` a person + * called "deployment" to look a grant up by. + */ + const auditActor = actorId || DEPLOYMENT_ACTOR; + // How a row is reached is resolved once, in `requireServer`. Derived from the entry here, // a Composio app — which has no entry — was dialled as MCP at `composio://gmail`. const transport = transportFor(access.transport); @@ -2445,7 +2489,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetType: "mcp_server", targetId: serverId, payload: { - actor: actorId, + actor: auditActor, change: "grants_not_advertised", server: serverId, // The refs, because that is what a grant is keyed on and what an administrator revokes. @@ -2475,7 +2519,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetType: "mcp_server", targetId: serverId, payload: { - actor: actorId, + actor: auditActor, change: "unlisted_tools_advertised", server: serverId, tools: unlisted, @@ -3287,6 +3331,17 @@ export function createPluginStore(options: PluginStoreOptions) { throw new PluginRefusedError(`${input.ref} is not a tool.`, null); } + /* + * Who the trail says made this call, which is not what the call is made AS. + * + * `input.actorId` stays the value every gate is decided on, and the empty string must go on + * matching no grant and no connection anywhere. This is only what the row says: a run nobody + * could be attributed to is `unattributed` rather than blank, on the criterion at + * {@link DEPLOYMENT_ACTOR}, and never `deployment` — a run this deployment could not put a + * name to is not the deployment having acted. + */ + const auditActor = input.actorId || UNATTRIBUTED_ACTOR; + const decision = await this.decide("mcp", input.ref, input.botId); if (!decision.allowed) { await recordAuditEvent(auditStore, { @@ -3295,7 +3350,7 @@ export function createPluginStore(options: PluginStoreOptions) { targetId: input.ref, ...(input.initiator ? { initiator: input.initiator } : {}), payload: { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, @@ -3402,7 +3457,7 @@ export function createPluginStore(options: PluginStoreOptions) { * the row goes down once, after the outcome exists. */ const decided = { - actor: input.actorId, + actor: auditActor, bot: input.botId, server: serverId, tool: toolName, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 1a3d9eb8c..59881107c 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -377,3 +377,70 @@ test("adding the app back does not restore a connection nobody re-granted", asyn ).rejects.toThrow(/have not connected/i); expect(reached).toEqual([actionName]); }); + +/** + * THE TRAIL, WHERE NOBODY WAS ASKING. + * + * An empty string in a field whose purpose is to name who did something is worse than an absent + * field: it reads as a value, and a reader counting rows by actor gets a person called "". + * + * `reachedAs` and `actor` are the two on this row, and both are the run's actor verbatim. A brokered + * app is reached AS THE PERSON, so a run nobody could be attributed to has no name to put in either + * — and the refusal is recorded, which is exactly when the trail matters. + */ +test("an unattributed run is recorded as unattributed rather than as a blank", async () => { + await seedApp(); + useAnsweringClient(); + + await expect( + store.callTool({ ref, args: {}, botId, actorId: "" }), + ).rejects.toThrow(/not attributed to anybody/i); + expect(reached).toEqual([]); + + const failed = recordedOfType("mcp.call_failed"); + expect(failed).toHaveLength(1); + expect(failed[0].payload).toMatchObject({ + actor: "unattributed", + reachedAs: "unattributed", + }); + // Not "deployment" either: this call did not go out on a shared credential, it did not go out at + // all, and saying the deployment reached the app would assert an attribution that never happened. + expect(failed[0].payload.reachedAs).not.toBe("deployment"); +}); + +/** + * THE TRAIL, WHERE THE DEPLOYMENT WAS THE ONE ACTING. + * + * `refreshTools` defaults its actor to the empty string, and `addServer` and `addCustomServer` both + * take that default — deliberately, because that argument doubles as the credential to list with and + * nobody can have connected an app in the moment it is added. So the absence is real and permanent, + * and what the trail owes a reader is the distinction: not a person, and not nobody either, but the + * deployment refreshing on its own behalf. `reachedAs` already spells that "deployment". + */ +test("the refresh that follows an add is attributed to the deployment", async () => { + await seedApp(); + // A different action, so the granted one is left held and not advertised — which is the audit row + // under test. + useAnsweringClient({ + listActions: async () => [ + { + slug: "APP_SOMETHING_ELSE", + description: "Not the one anybody holds.", + version: "20260903_00", + }, + ], + }); + + // No actor, which is exactly what the add path passes. + await store.refreshTools(toolkit); + + const stranded = events.filter( + (event) => + (event.payload as { change?: string }).change === "grants_not_advertised", + ); + expect(stranded).toHaveLength(1); + expect(stranded[0].payload).toMatchObject({ + actor: "deployment", + refs: [ref], + }); +}); From c1e70d44e4364b6bcf0c80615e81efa7450ac852 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:54:22 +0200 Subject: [PATCH 060/215] Throw when no app was named and when no client was installed, rather than listing nothing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 35 ++++++++++++++++++++++--- server/tests/composio-transport.test.ts | 33 +++++++++++++++++++---- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 6f53174bd..9ecba6b62 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -98,8 +98,10 @@ let installed: ComposioActions | null = null; * * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that * installs a stub has to be able to take it back out. It is also the unconfigured state — a - * deployment with no Composio key installs nothing, and every function here answers emptily or - * refuses rather than failing, so an app nobody configured is absent rather than broken. + * deployment with no Composio key installs nothing. What that state produces is not an empty answer: + * {@link listTools} THROWS and {@link callTool} refuses, both saying which of the two it is, because + * an empty listing is indistinguishable from an app that advertises nothing and would be committed + * as one. */ export function useComposioClient(client: ComposioActions | null): void { installed = client; @@ -162,12 +164,39 @@ export function effectOf(tags: readonly string[] | undefined): { * turned around: an empty list is what an app with no actions looks like, so answering emptily would * report a success and strand every grant. What throws is a sentence, never a vendor object. See the * catch below. + * + * AND SO IS A LISTING NOBODY WAS ASKED FOR, which is the same criterion applied one step earlier. + * `[]` from a `listTools` means, in `mcp.ts`, `google-drive-rest.ts` and `builtin-routines.ts` + * alike, "the vendor was asked and advertises no actions" — and `refreshTools` commits that as a + * healthy refresh. This function used to answer `[]` for a url naming no app and for a deployment + * with no client installed, neither of which involved asking anybody, and the commit deleted every + * `mcp_tools` row for the app: the recorded `effect`, `destructive` and, fatally, `version`, which + * `callTool` refuses to run without and which only a listing can put back. So the two "asked + * nobody" cases throw, and they throw SEPARATELY, because one sends an operator to this + * deployment's configuration and the other to the row's url. */ export async function listTools(connection: { url: string; }): Promise { const toolkit = toolkitOf(connection.url); - if (!toolkit || !installed) return []; + if (!toolkit) { + throw new Error( + `${connection.url} does not name a Composio app, so nothing was asked what it offers. A row reached through this transport is one whose provenance says composio, and its url has to be composio:// followed by an app slug; correct the url on the Plugins page.`, + ); + } + if (!installed) { + /* + * A STATE, NOT A FAULT, and the sentence has to read as one. + * + * Nothing under `server/src` installs a Composio client — see {@link useComposioClient} — so + * this is what every Composio refresh on every real deployment answers today, by design and + * not by accident. An operator who reads it as a crash goes looking for a broken vendor; what + * they need to know is that the connector is not wired up here and that nothing was lost. + */ + throw new Error( + `Composio is not configured for this deployment, so nothing could be asked what ${toolkit} offers. That is the expected answer until a Composio client is installed at startup, and the actions already recorded for this app are kept rather than cleared.`, + ); + } let actions: ComposioAction[]; try { diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 3090f9d8e..070d9adf9 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -222,13 +222,36 @@ describe("listing an app's actions", () => { expect(tool?.effect).toBe("write"); }); - test("listing a url that names no app is empty rather than an error", async () => { - useComposioClient(recording().client); - expect(await listTools({ url: "https://example.com" })).toEqual([]); + test("a listing nobody was asked for throws rather than answering empty", async () => { + // `[]` means "the vendor was asked and advertises none" everywhere else in this codebase, and + // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — + // nothing under `server/src` calls `useComposioClient` — so `[]` here was the only answer a + // real Composio refresh could produce, and committing it deleted every recorded action. + const listing = listTools({ url: "composio://gmail" }); + + await expect(listing).rejects.toThrow( + /not configured for this deployment/i, + ); + + const thrown = (await listing.catch((error: unknown) => error)) as Error; + expect(thrown.message).toContain("gmail"); + // Not a crash report. No deployment installs a client yet, so an operator reading this has to + // recognise a state rather than go hunting for a fault. + expect(thrown.message).toMatch(/expected/i); }); - test("listing with no client installed is empty rather than a crash", async () => { - expect(await listTools({ url: "composio://gmail" })).toEqual([]); + test("a url that names no app throws about the url, not about the client", async () => { + useComposioClient(recording().client); + + const listing = listTools({ url: "https://example.com" }); + + // The two refusals send an operator to different places — one to this deployment's + // configuration, one to the row — so they must not share a sentence. + await expect(listing).rejects.toThrow(/does not name a Composio app/i); + + const thrown = (await listing.catch((error: unknown) => error)) as Error; + expect(thrown.message).not.toMatch(/not configured/i); + expect(thrown.message).toContain("https://example.com"); }); test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { From bb5f92a04f2548b876c456ee3c1411ab1fd200c2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:56:08 +0200 Subject: [PATCH 061/215] Ask for the whole listing, and refuse a page too full to know it was the whole listing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 53 ++++++++++++++++++++++++- server/tests/composio-transport.test.ts | 44 ++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 9ecba6b62..346561d95 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -35,6 +35,28 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; */ export const VERSION_ARG = "__version"; +/** + * How many actions one listing asks for, which is as many as this SDK can be made to answer with. + * + * A NUMBER RATHER THAN NO NUMBER, because omitting it is not "no opinion". Composio's page defaults + * to 20 and Gmail publishes 63 actions, so an omitted limit truncates — and worse, it NARROWS: + * `getRawComposioTools` sets `important=true` whenever the query named toolkits and gave no limit, + * no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the short + * answer is a filtered one and nothing in it says a filter was applied. Passing a limit is what + * turns the flag off. + * + * 1000 BECAUSE THAT IS THE CEILING, not because it is generous. The REST parameter documents "max + * allowed is 1000" (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:441-444`), and the + * core SDK exposes no way to go past it: `ToolListParamsSchema` has no cursor field, and + * `getRawComposioTools` reads `tools.items` and drops the response's `next_cursor`. So one page at + * the ceiling is not a page — it is the whole listing, and the only listing expressible here. + * + * Which is why {@link listTools} refuses a page that came back FULL. At the ceiling a complete + * answer and a truncated one are the same array, and there is no second request that could tell + * them apart. + */ +export const LISTING_LIMIT = 1000; + /** One action, as much of Composio's listing as anything here reads. */ export type ComposioAction = { slug: string; @@ -82,7 +104,19 @@ export type ComposioResult = { * resolution and an exception have to be read. */ export type ComposioActions = { - listActions(toolkit: string): Promise; + /** + * Every action of one app, for a page the CALLER has to name. + * + * `page` is required rather than optional, and that is the whole point of it being here. The + * previous signature took the toolkit alone, so an adapter had nothing to pass a limit through + * and the SDK's default applied — 20 rows, silently narrowed to the vendor's "important" subset. + * A required argument makes the narrowed listing a thing a caller has to ask for on purpose + * instead of a thing they get by leaving something out. See {@link LISTING_LIMIT}. + */ + listActions( + toolkit: string, + page: { limit: number }, + ): Promise; execute( slug: string, userId: string, @@ -200,7 +234,7 @@ export async function listTools(connection: { let actions: ComposioAction[]; try { - actions = await installed.listActions(toolkit); + actions = await installed.listActions(toolkit, { limit: LISTING_LIMIT }); } catch (error) { /* * THROWN, NOT ANSWERED EMPTY, and with a sentence rather than the vendor's raw object. @@ -219,6 +253,21 @@ export async function listTools(connection: { throw new Error(listingSentence(toolkit, error), { cause: error }); } + if (actions.length >= LISTING_LIMIT) { + /* + * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. + * + * `LISTING_LIMIT` is the largest page the vendor's REST parameter allows, and the core SDK + * offers no cursor to ask for a second one. So an app with exactly that many actions and an app + * with more of them answer identically here. Committed as complete, the second one has every + * action past the cut deleted from `mcp_tools` under a refresh that reported success — the same + * loss the empty answer used to cause, arriving by a different route. + */ + throw new Error( + `Composio answered with ${actions.length} actions for ${toolkit}, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. The actions already recorded are kept rather than replaced by a listing that might be a fragment.`, + ); + } + return actions.map((action) => { const { effect, destructive } = effectOf(action.tags); return { diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 070d9adf9..cf4ad0cbc 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -3,6 +3,7 @@ import { type ComposioActions, callTool, effectOf, + LISTING_LIMIT, listNeedsCredential, listTools, toolkitOf, @@ -180,6 +181,49 @@ describe("listing an app's actions", () => { expect(listNeedsCredential).toBe(false); }); + test("the listing asks for a page, and for one big enough to be the whole list", async () => { + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return [GMAIL_READ]; + }, + }).client, + ); + + await listTools({ url: "composio://gmail" }); + + // Composio's default page is 20 and Gmail publishes 63 actions, so an omitted limit truncates. + // It also NARROWS: `getRawComposioTools` auto-applies `important=true` when no limit, no tags + // and no search were given (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), and + // nothing in the short answer says a filter was applied. Asking for a page is therefore not an + // optimisation, and the seam must not let a caller forget to. + expect(asked).toEqual([ + { toolkit: "gmail", page: { limit: LISTING_LIMIT } }, + ]); + }); + + test("a listing that filled the biggest page the SDK can ask for is not called complete", async () => { + useComposioClient( + recording({ + listActions: async () => + Array.from({ length: LISTING_LIMIT }, (_unused, index) => ({ + ...GMAIL_READ, + slug: `GMAIL_ACTION_${index}`, + })), + }).client, + ); + + // `ToolListParamsSchema` accepts no cursor and `getRawComposioTools` drops the response's + // `next_cursor`, so one page at the API's stated maximum is the largest listing expressible + // through this SDK. A page that came back full is therefore indistinguishable from a truncated + // one, and committing it would delete every action past the cut while reporting a success. + await expect(listTools({ url: "composio://gmail" })).rejects.toThrow( + /there may be more/i, + ); + }); + test("an action arrives with its schema, its effect and its version", async () => { const { client } = recording({ listActions: async (toolkit) => { From b2b16ba35a68ea3dfd4b54174974fd8940bd0aa6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:58:11 +0200 Subject: [PATCH 062/215] Stop offering a model an action whose file argument only Composio's own uploader can produce Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 101 +++++++++++++++++++++--- server/tests/composio-transport.test.ts | 76 ++++++++++++++++++ 2 files changed, 165 insertions(+), 12 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 346561d95..68ebee6e8 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -187,12 +187,65 @@ export function effectOf(tags: readonly string[] | undefined): { return { effect: "write", destructive: false }; } +/** A JSON Schema node, or null for anything that is not one. */ +function schemaNode(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Whether an action asks for a file, anywhere in its schema. + * + * `file_uploadable` is Composio's own extension keyword, and one of the few the SDK's + * `JSONSchemaPropertySchema` whitelists rather than strips (`@composio/core` 0.18.1, + * `src/types/tool.types.ts:89`) — so unlike most of what the vendor publishes, this one is still + * here to be read. See {@link listTools} for what is done with the answer. + * + * WALKED, NOT LOOKED UP. Composio toolkits routinely put the flag behind a `$ref`/`$defs` + * indirection or inside an `anyOf` variant, which is why the vendor's own predicate recurses + * through both (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A check that read + * only the top level of `properties` would answer false for every ref-based schema, which is the + * majority of the ones that carry a file. + * + * The keys walked are the composition keywords `ParametersSchema` and `JSONSchemaPropertySchema` + * actually keep, and no others: a key those two strip cannot be present to be walked. + */ +function stagesAFile(schema: unknown): boolean { + const node = schemaNode(schema); + if (!node) return false; + if (node.file_uploadable === true) return true; + + for (const key of [ + "properties", + "patternProperties", + "$defs", + "definitions", + ]) { + const children = schemaNode(node[key]); + if (children && Object.values(children).some(stagesAFile)) return true; + } + + for (const key of ["anyOf", "oneOf", "allOf", "items", "not"]) { + const branch = node[key]; + if ( + Array.isArray(branch) ? branch.some(stagesAFile) : stagesAFile(branch) + ) { + return true; + } + } + + return false; +} + /** * Every action this app publishes, in the shape a `tools/list` answer has, plus what we know about it. * * An action with no schema is still listed, with an open one. The vendor is the right party to reject a * bad argument, and an action silently missing from the list reads to an administrator as an app that - * does not have it. + * does not have it. The one exception is an action that asks for a FILE, which is dropped — see the + * criterion beside the filter below, and note that it turns on the action being uncallable rather + * than on its schema being unfamiliar. * * A listing that could not be read at all is a THROW rather than an empty list, for the same reason * turned around: an empty list is what an app with no actions looks like, so answering emptily would @@ -268,17 +321,41 @@ export async function listTools(connection: { ); } - return actions.map((action) => { - const { effect, destructive } = effectOf(action.tags); - return { - name: action.slug, - description: action.description ?? "", - inputSchema: action.inputParameters ?? {}, - effect, - destructive, - ...(action.version ? { version: action.version } : {}), - }; - }); + /* + * AN ACTION IS OFFERED ONLY IF A MODEL COULD ACTUALLY FILL IN ITS ARGUMENTS. + * + * A `file_uploadable` parameter fails that. Under the SDK's default file handling — the flag is + * `dangerouslyAllowAutoUploadDownloadFiles` and it is off unless a client asks for it + * (`src/models/Tools.ts:136`, `:242-248`) — the parameter reaches the model as the vendor's + * internal staging descriptor, `{ name, mimetype, s3key }`. An `s3key` is issued by an upload to + * Composio's bucket. Nothing in this deployment performs one, and a model has no way to obtain + * one, so the only value it can produce is invented and the vendor's staging lookup rejects the + * call. The SDK says as much itself in the warning it logs on that path (`:349-366`). + * + * WHY THIS IS NOT THE SAME AS THE SCHEMALESS ACTION ABOVE, which is deliberately still offered. + * There the vendor is the right party to reject a bad argument, and the action might well + * succeed. Here it cannot: every call is a rejection, and an advertised action that can only + * fail is worse than an absent one, because an administrator grants it, the audit trail records + * attempts against it, and the model spends turns retrying with a different invented key. + * + * ENABLING AUTO-UPLOAD WOULD NOT FIX IT EITHER, which is why the answer is not "turn the flag + * on". That flag collapses the parameter to `{ type: 'string', format: 'path' }` — a promise + * that the SDK will read a local path off this server's disk. A model naming a server-side path + * is a worse offer than one naming a bucket key, not a better one. + */ + return actions + .filter((action) => !stagesAFile(action.inputParameters)) + .map((action) => { + const { effect, destructive } = effectOf(action.tags); + return { + name: action.slug, + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(action.version ? { version: action.version } : {}), + }; + }); } /** diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index cf4ad0cbc..26949cbfb 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -248,6 +248,82 @@ describe("listing an app's actions", () => { ]); }); + test("an action that stages a file is not offered at all", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { + recipient: { type: "string" }, + // What the SDK hands on under its default file handling: the vendor's own staging + // descriptor, untouched. `dangerouslyAllowAutoUploadDownloadFiles` is off unless a + // client asks for it (`src/models/Tools.ts:136`, `:242-248`), and only that flag + // collapses the shape. An `s3key` is issued by an upload nothing here performs. + attachment: { + type: "object", + file_uploadable: true, + properties: { + name: { type: "string" }, + mimetype: { type: "string" }, + s3key: { type: "string" }, + }, + }, + }, + required: ["recipient", "attachment"], + }, + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + // Dropped rather than offered with a field the model can only invent. Offering it guarantees a + // hallucinated key and a rejection at the vendor's staging lookup, and a grant recorded against + // a name that can never work. + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + + test("a file parameter reached through $defs and a variant is found too", async () => { + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_GET_ATTACHMENT", + version: "20260903_00", + inputParameters: { + type: "object", + properties: { body: { $ref: "#/$defs/upload" } }, + $defs: { + upload: { + anyOf: [ + { type: "null" }, + { type: "string", file_uploadable: true }, + ], + }, + }, + }, + }, + ], + }).client, + ); + + // Composio toolkits routinely express the flag through a `$ref`/`$defs` indirection, which is + // why the SDK's own predicate walks `$defs` and every composed variant + // (`src/utils/modifiers/FileToolModifier.utils.neutral.ts:77-134`). A walk that stopped at + // `properties` would answer false for every ref-based schema and offer it anyway. + const listed = await listTools({ url: "composio://gmail" }); + expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); + }); + test("an action with no schema is still listed, with an open one", async () => { useComposioClient( recording({ From 9f5c9de67962eba91746e355e4608d99fa869ee0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 03:59:31 +0200 Subject: [PATCH 063/215] Read one app slug out of a connection url, or read no app at all Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 21 +++++++++++++++++++-- server/tests/composio-transport.test.ts | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 68ebee6e8..c6bb4d8cd 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -151,12 +151,29 @@ export const listNeedsCredential = false; * is the field every transport already gets and `effectiveUrl` already owns. Null for anything that is * not one of ours, so a misrouted connection lists nothing instead of asking Composio about a * hostname. + * + * ONE SLUG OR NOTHING, and the strictness is the security property rather than tidiness. This answer + * becomes `ServerAccess.toolkit` (`./access`), which is the name the brokered gate looks a person's + * row up by in `composio_connections` — so a url read loosely is somebody's connection to one app + * satisfying a call against another. Whatever follows the scheme has to be a slug and nothing else: + * `composio://gmail/messages` used to answer `"gmail/messages"`, taking a path segment for an app. + * + * TRIMMED BEFORE THE SLASHES COME OFF, because the other order does not work. `composio://gmail/ ` + * ran the strip against a string whose last character was a space, so the slash was not at the end, + * nothing matched, and the trim then produced `"gmail/"`. + * + * The character class is deliberately not case-folded. `composio_connections.toolkit` documents the + * column as lower case and this function does not lower-case what it returns; that mismatch is a + * separate known issue, and matching case-insensitively here keeps this change to the shape of the + * url rather than quietly settling it. */ +const TOOLKIT_SLUG = /^[A-Za-z0-9_-]+$/; + export function toolkitOf(url: string): string | null { const prefix = "composio://"; if (!url.startsWith(prefix)) return null; - const slug = url.slice(prefix.length).replace(/\/+$/, "").trim(); - return slug === "" ? null : slug; + const slug = url.slice(prefix.length).trim().replace(/\/+$/, ""); + return TOOLKIT_SLUG.test(slug) ? slug : null; } /** diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 26949cbfb..d6c65632f 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -89,6 +89,26 @@ describe("which app a connection names", () => { expect(toolkitOf("composio://")).toBeNull(); expect(toolkitOf("")).toBeNull(); }); + + test("anything past the app slug means the url does not name one app", () => { + // This answer is the app a person's connection is checked against — `accessFor` puts it on + // `ServerAccess.toolkit` (`access.ts:133`) and the brokered gate looks `composio_connections` + // up by it. A url this function reads loosely is a check performed against the wrong app, so + // anything it cannot read as exactly one slug has to be no app rather than a best guess. + expect(toolkitOf("composio://gmail/messages")).toBeNull(); + expect(toolkitOf("composio://gmail?scope=read")).toBeNull(); + expect(toolkitOf("composio://gmail#inbox")).toBeNull(); + expect(toolkitOf("composio://gmail slack")).toBeNull(); + }); + + test("surrounding space is taken off before the trailing slash, not after", () => { + // The strip ran first and the trim second, so a slash that was not the last character survived + // it: `composio://gmail/ ` answered `"gmail/"`, which matches no row in `composio_connections` + // and is not the app anybody meant. + expect(toolkitOf("composio://gmail/ ")).toBe("gmail"); + expect(toolkitOf("composio://gmail ")).toBe("gmail"); + expect(toolkitOf("composio://google_drive//")).toBe("google_drive"); + }); }); describe("what a label means", () => { From 10b6bf0166853f71e97f8709ea94897f2ebcc550 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:00:42 +0200 Subject: [PATCH 064/215] Describe the client seam as unwired, and make the version remedy conditional on the vendor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 35 ++++++++++++++++++------- server/tests/composio-transport.test.ts | 9 +++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index c6bb4d8cd..f69faa0df 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -19,8 +19,14 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; * * It implements the same interface as the other three, as module-level exports, because that is the * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client - * arrives through {@link useComposioClient} rather than a constructor — the registry is built at - * import time, long before `index.ts` has configuration. + * would have to arrive through a setter rather than a constructor — the registry is built at import + * time, long before anything has read configuration. {@link useComposioClient} is that setter. + * + * NOTHING IN THE SHIPPED PRODUCT CALLS IT. There is no adapter under `server/src`: the only caller + * is the test suite. So on every real deployment `installed` is null, which is a state this module + * is written for rather than an outage — {@link listTools} throws a sentence saying so and + * {@link callTool} refuses with one. Read every mention of "the client" below as a description of + * the seam an adapter would plug into, not of wiring that exists. */ /** @@ -75,8 +81,8 @@ export type ComposioAction = { * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field * on a resolution and not only a thrown exception. Named here rather than imported so this module - * keeps no compile-time dependency on the vendor's package; the adapter that installs the real - * client is the one place their types belong. + * keeps no compile-time dependency on the vendor's package; the adapter that would install a real + * client is the one place their types belong, and it has not been written. * * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement * about what arrives. Nothing here reads them and nothing here shows them to a model. @@ -93,7 +99,8 @@ export type ComposioResult = { * What this module needs of Composio, and nothing more. * * A narrow projection rather than their client, so a test satisfies it with two functions and the - * SDK's shape is somebody else's problem in exactly one place: the adapter that installs the real one. + * SDK's shape is confined to one place: the adapter that would install a real client. No such + * adapter exists yet, so today the only implementations of this type are stubs. * * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to @@ -128,7 +135,12 @@ export type ComposioActions = { let installed: ComposioActions | null = null; /** - * Hand this module its client, once, from the place that reads configuration. + * The seam an adapter would hand this module its client through, once, at startup. + * + * WOULD, BECAUSE NO SUCH ADAPTER EXISTS. Nothing under `server/src` calls this function — the only + * callers are tests — so `installed` is null on every deployment of the shipped product. The + * comment here used to describe `index.ts` doing the installing, and the code below was written + * around a state that was treated as an edge case when it is in fact the only state. * * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that * installs a stub has to be able to take it back out. It is also the unconfigured state — a @@ -584,11 +596,16 @@ export async function callTool( * a call against some other revision of the action, whose arguments and behaviour are not the ones * that were listed, classified and granted. * - * In practice this means the app's tool list has not been refreshed since the version column - * existed, which is an operator's one-click fix rather than anything a person asking can do. + * THE REMEDY IS CONDITIONAL ON THE VENDOR, and this sentence used to state it as certain. + * "Refresh this app's tools and try again" is right for one of the two causes — a list recorded + * before the version column existed — and wrong for the other. Where Composio published no + * version for the action, {@link listTools} records none, `store.ts` writes `tool.version ?? + * null`, and the next refresh writes the same null back: the reader presses the button, is told + * nothing changed, and presses it again. So the sentence names the refresh and names the + * condition under which it helps, which is the part nobody in this deployment controls. */ return failure( - `${toolName} has no recorded version, so it cannot be called. Refresh this app's tools on its Plugins page and try again.`, + `${toolName} has no recorded version, so it cannot be called: Composio requires a specific one and rejects "latest", so there is nothing to fall back on. Refreshing this app's tools on its Plugins page recovers it only if Composio publishes a version for this action. Where Composio publishes none, no refresh will make it callable.`, ); } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index d6c65632f..215219c88 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -486,6 +486,15 @@ describe("calling one action", () => { expect(result.isError).toBe(true); expect(result.text).toMatch(/version/i); expect(calls).toEqual([]); + + // The refusal used to name a refresh as THE fix, unconditionally. It is not one where the + // vendor published no version: `listTools` sets the field only when Composio sent one, so a + // refresh writes the same nothing back and the reader presses the button again. The sentence + // has to make the remedy conditional on the vendor, which is the part nobody here controls. + expect(result.text).not.toContain( + "Refresh this app's tools on its Plugins page and try again.", + ); + expect(result.text).toMatch(/only if Composio publishes/i); }); test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { From 23c2b24fe973eddec7e9290dcf0379c773fb53a3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:02:21 +0200 Subject: [PATCH 065/215] Say that the schema reaching a model is the SDK's re-spelling and not the vendor's own Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 25 +++++++++++++++++- server/tests/composio-transport.test.ts | 35 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index f69faa0df..3338f63a2 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -67,7 +67,30 @@ export const LISTING_LIMIT = 1000; export type ComposioAction = { slug: string; description?: string; - /** JSON Schema, as they spell it. Absent for the occasional action that publishes none. */ + /** + * The action's JSON Schema as `@composio/core` re-spells it, which is NOT as Composio published it. + * + * This used to say "as they spell it", and that claim travelled: whatever lands in this field is + * what {@link listTools} puts in front of a model as the vendor's own schema. The SDK parses the + * response through `ToolSchema`, and its `ParametersSchema` is a plain `z.object` with no + * passthrough (`@composio/core` 0.18.1, `src/types/tool.types.ts:134-174`), so every key it does + * not name is dropped before anything here can see it. At the schema ROOT that is `if`, `then`, + * `else`, `examples` and every `x-` extension. Per property, `JSONSchemaPropertySchema` (`:77-131`) + * does keep `if`/`then`/`else`/`examples`, but names neither `deprecated` nor `contentEncoding`, + * so both of those go. + * + * WHY THE CLAIM WAS DROPPED RATHER THAN THE LOSS FIXED. The strip happens inside the vendor's own + * parse, upstream of every byte this module receives, so there is nothing here to restore a key + * from — "stop losing them" is not an option this file has. The one place it could be avoided is + * the adapter that has yet to be written, by reading `client.tools.list` directly rather than + * `tools.getRawComposioTools` and never running `ToolSchema` over the answer; that is a decision + * about the vendor's types, and it belongs where the vendor's types belong. What this module can + * honestly promise is the narrower thing: it adds nothing to this schema and removes nothing from + * it, so what the SDK handed over is exactly what a model is shown. + * + * Absent for the occasional action that publishes none — and equally for one that published `{}`, + * which the SDK normalizes to absent before parsing (`src/models/Tools.ts:76-93`). + */ inputParameters?: Record; /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ tags?: string[]; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 215219c88..c740ff621 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -344,6 +344,41 @@ describe("listing an app's actions", () => { expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); }); + test("the schema a model is shown is the one the SDK handed over, unaltered", async () => { + /* + * A CHARACTERIZATION TEST, and it passed before the claim beside `inputParameters` was + * corrected — the correction is to a comment, because the loss it describes happens inside + * `ToolSchema.parse` and there is no key left here to restore. + * + * What it pins is the narrower promise that replaced the false one: this module adds nothing to + * the schema and removes nothing from it. The keys below are ones `ParametersSchema` and + * `JSONSchemaPropertySchema` would have stripped, so a real client never delivers them — which + * is exactly why they are the right probe for whether anything HERE also strips. `listTools` + * now walks the schema looking for a file parameter, and a walk that rebuilt what it read + * would silently narrow every schema in the listing. + */ + const schema = { + type: "object", + properties: { + query: { type: "string", deprecated: true, contentEncoding: "utf-8" }, + }, + if: { required: ["query"] }, + // No `then` beside it: biome bans a `then` key on an object literal, and the point of these + // is only that they are root keywords `ParametersSchema` does not name. + else: { required: [] }, + examples: [{ query: "is:unread" }], + "x-openbot-probe": "kept", + }; + useComposioClient( + recording({ + listActions: async () => [{ ...GMAIL_READ, inputParameters: schema }], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + expect(tool?.inputSchema).toEqual(schema); + }); + test("an action with no schema is still listed, with an open one", async () => { useComposioClient( recording({ From 749eef92237b1ae65deb4defe1aa4a98d9a5d8d6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:03:50 +0200 Subject: [PATCH 066/215] Stop saying Composio lists an action's schema to anybody, when it lists it to a key Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 3338f63a2..0c75e77d9 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -176,7 +176,16 @@ export function useComposioClient(client: ComposioActions | null): void { installed = client; } -/** The tool list needs no credential: Composio publishes an action's schema to anybody. */ +/** + * The tool list needs no credential FROM THE CONNECTION, which is not the same as needing none. + * + * The sentence here used to be "Composio publishes an action's schema to anybody", and the vendor's + * own client says otherwise: the listing is an authenticated request carrying the deployment's + * Composio API key, which the client holds and this module never sees. What is genuinely not + * required is a PERSON. An action's schema is the same whoever asks, so nothing about whose account + * is connected has to be settled before listing — which is exactly what this flag is asked to + * decide by `refreshTools`, and the only thing it decides. See {@link ./transport}. + */ export const listNeedsCredential = false; /** From fa84783b6939c18196c38bd332d3468b6186acb7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:04:42 +0200 Subject: [PATCH 067/215] Find a file parameter that is only reachable under a condition, not just under a property Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 0c75e77d9..985bc9d66 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -269,8 +269,11 @@ function schemaNode(value: unknown): Record | null { * only the top level of `properties` would answer false for every ref-based schema, which is the * majority of the ones that carry a file. * - * The keys walked are the composition keywords `ParametersSchema` and `JSONSchemaPropertySchema` - * actually keep, and no others: a key those two strip cannot be present to be walked. + * The keys walked are every subschema-bearing keyword `ParametersSchema` and + * `JSONSchemaPropertySchema` keep — a key those two strip cannot be present to be walked, so the + * list is closed. It is wider than the vendor's predicate by `patternProperties`, `not` and the + * conditional trio, which that one skips: a file staged only under a condition is still a file + * this deployment cannot stage. */ function stagesAFile(schema: unknown): boolean { const node = schemaNode(schema); @@ -287,7 +290,16 @@ function stagesAFile(schema: unknown): boolean { if (children && Object.values(children).some(stagesAFile)) return true; } - for (const key of ["anyOf", "oneOf", "allOf", "items", "not"]) { + for (const key of [ + "anyOf", + "oneOf", + "allOf", + "items", + "not", + "if", + "then", + "else", + ]) { const branch = node[key]; if ( Array.isArray(branch) ? branch.some(stagesAFile) : stagesAFile(branch) From 4f921bcc2db2a4492b9e9ed010172f809c91f15f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:10:07 +0200 Subject: [PATCH 068/215] Hold the version refusal to the conditional remedy it now offers The transport's sentence changed when the remedy became conditional on the vendor publishing a version at all, and this assertion still matched the old unconditional one. The comment above it claimed a one-click fix, which is the thing the new sentence exists to stop promising. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index c5bb97f8d..d6460b6b6 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3896,10 +3896,18 @@ test("a version a model supplied cannot stand in for an action with none recorde // vendor, rather than failing on a boolean that names nothing. expect(calls).toEqual([]); - // The transport's refusal, which is the advertised answer for an action with no recorded version — - // and an operator's one-click fix, rather than a call against a revision a model named. + // The transport's refusal, which is the advertised answer for an action with no recorded version, + // rather than a call against a revision a model named. The sentence offers a refresh CONDITIONALLY + // — it recovers the action only where Composio publishes a version for it — because where the + // vendor publishes none, no number of refreshes will make the action callable, and promising a + // one-click fix that cannot work sends an operator round a loop. expect(result.isError).toBe(true); - expect(result.text).toMatch(/Refresh this app's tools on its Plugins page/); + expect(result.text).toMatch( + /Refreshing this app's tools on its Plugins page recovers it only if/, + ); + expect(result.text).toMatch( + /Where Composio publishes none, no refresh will make it callable/, + ); }); /* From dc6d35312bb86632b1cf38cbb096974b988167cf Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:19:59 +0200 Subject: [PATCH 069/215] Fail these transport tests when the behaviour they are named for breaks Six of them passed whether the code did what their name said or not. The url-names-no-app test asserted the refusal and nothing else, so with the default stub answering `[]` the guard could be moved to after the dial and every assertion still passed while the transport handed a hostname to Composio as an app slug. The three cap tests allowed anything under 25,000 against a `MAX_RESULT_CHARS` of 20,000, so a cap raised to 24,000 passed, and the one named "capped visibly" never looked for the `[truncated]` marker at all. The empty-answer test read the sentence and neither of the two fields beside it, and the envelope test listed three strings that must not appear, which is only ever as long as the fields the envelope had the day it was written. The bounds are now the constant itself, imported rather than restated, and the envelope test pins the whole string. Four branches nothing reached are reached: an action Composio published no version for, and the empty-thrown-message arm of both fallbacks, plus `vendorSentence`'s trim and its type guard, each of which would otherwise hand a model a blank refusal or `[object Object]`. Every test here was confirmed red against a mutation of the behaviour it names, and no source file changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-transport.test.ts | 176 ++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 10 deletions(-) diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index c740ff621..001394865 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -10,6 +10,7 @@ import { useComposioClient, vendorSentence, } from "../src/plugins/composio"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; /** * The Composio transport's boundary, asserted with no network and no database. @@ -48,6 +49,24 @@ function answered( }; } +/** + * What a capped answer ends with, and exactly how long a capped answer is. + * + * IMPORTED RATHER THAN RESTATED, and asserted as an equality rather than as an upper bound. The + * three cap tests below used to allow anything under 25,000 while `MAX_RESULT_CHARS` is 20,000, so + * the number they were named for was the one thing they did not pin: a regression that doubled the + * cap to 40,000 fails, but one that raised it to 24,000 — half again as much of somebody's context + * window spent by somebody else's server — passed. A bound with slack in it is a bound that has to + * be re-argued every time the constant moves, so there is no reason for it to have any. + */ +const TRUNCATION_MARKER = "\n\n[truncated]"; +const CAPPED_LENGTH = MAX_RESULT_CHARS + TRUNCATION_MARKER.length; + +/** The nesting `vendorSentence` reaches through, with whatever the vendor left at the bottom of it. */ +function nested(message: unknown): unknown { + return { cause: { error: { error: { message } } } }; +} + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -194,6 +213,25 @@ describe("finding the vendor's own sentence", () => { expect(vendorSentence({ cause: { error: {} } })).toBeNull(); expect(vendorSentence(undefined)).toBeNull(); }); + + test("a sentence made only of whitespace is not a sentence", () => { + // The `.trim()` on the return had nothing asserting it. A blank message that counted as a + // sentence is worse than none: `callTool` and `listingSentence` both prefer it over their + // fallbacks, so the reader gets an empty refusal instead of the one line naming what to do. + expect(vendorSentence(nested(""))).toBeNull(); + expect(vendorSentence(nested(" "))).toBeNull(); + expect(vendorSentence(nested("\n\t "))).toBeNull(); + }); + + test("a message that is not a string is not read as one", () => { + // Nothing asserted the type guard either. Composio's payloads are somebody else's JSON, so the + // field can be a number, an object or null; handed on unchecked, each of those reaches a model's + // context and an audit row as `[object Object]` or `1810`. + expect(vendorSentence(nested(1810))).toBeNull(); + expect(vendorSentence(nested(null))).toBeNull(); + expect(vendorSentence(nested({ text: "a nested sentence" }))).toBeNull(); + expect(vendorSentence(nested(["a sentence in a list"]))).toBeNull(); + }); }); describe("listing an app's actions", () => { @@ -397,6 +435,27 @@ describe("listing an app's actions", () => { expect(tool?.effect).toBe("write"); }); + test("an action Composio published no version for is listed with no version key", async () => { + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_UNVERSIONED", tags: ["readOnlyHint"] }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + // The branch that spreads the key only when the vendor sent one had nothing exercising it: + // every listing stub above carries a version. What it guards is not cosmetic. `store.ts` + // writes `tool.version ?? null`, so a key present and empty would be recorded as a version this + // deployment believes it has, and `callTool` would send `""` to a vendor that rejects it — + // instead of the refusal that names what the reader can and cannot do about it. + expect(Object.keys(tool ?? {})).not.toContain("version"); + expect(tool?.name).toBe("GMAIL_UNVERSIONED"); + expect(tool?.effect).toBe("read"); + }); + test("a listing nobody was asked for throws rather than answering empty", async () => { // `[]` means "the vendor was asked and advertises none" everywhere else in this codebase, and // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — @@ -415,8 +474,26 @@ describe("listing an app's actions", () => { expect(thrown.message).toMatch(/expected/i); }); - test("a url that names no app throws about the url, not about the client", async () => { - useComposioClient(recording().client); + test("a url that names no app throws about the url, and asks nobody", async () => { + /* + * THE REFUSAL IS ONLY HALF THE CLAIM, and this test used to make only that half. + * + * With the default stub answering `[]`, nothing here noticed whether Composio had been asked + * at all — so the guard could be moved to after the dial and every assertion below still + * passed, while the transport handed `https://example.com` to the vendor as an app slug. That + * is the failure the guard exists to prevent: `toolkitOf` is what keeps a url this deployment + * cannot read from becoming a request, and a test that cannot tell a refusal from a round trip + * is not testing the guard. + */ + const asked: unknown[] = []; + useComposioClient( + recording({ + listActions: async (toolkit, page) => { + asked.push({ toolkit, page }); + return []; + }, + }).client, + ); const listing = listTools({ url: "https://example.com" }); @@ -427,6 +504,7 @@ describe("listing an app's actions", () => { const thrown = (await listing.catch((error: unknown) => error)) as Error; expect(thrown.message).not.toMatch(/not configured/i); expect(thrown.message).toContain("https://example.com"); + expect(asked).toEqual([]); }); test("a listing the vendor's own schema rejects throws a sentence, not a Zod dump", async () => { @@ -463,6 +541,29 @@ describe("listing an app's actions", () => { expect(String((thrown as Error).message)).not.toContain("invalid_type"); expect(String((thrown as Error).message)).toContain("gmail"); }); + + test("a listing that failed with nothing said still names the app it was about", async () => { + // The other arm of `listingSentence`'s fallback, which nothing reached. `refreshTools` puts + // this string in the row's `lastError` and an administrator reads it off the Plugins page, so + // a blank one is a refresh that reports having failed and declines to say about what. + for (const thrown of [{ status: 502 }, new Error(""), new Error(" ")]) { + useComposioClient( + recording({ + listActions: async () => { + throw thrown; + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message.trim()).not.toBe(""); + expect(message).toContain("gmail"); + } + }); }); describe("calling one action", () => { @@ -644,8 +745,16 @@ describe("calling one action", () => { { __version: "20260903_00" }, ); + // VISIBLY is the marker and RATHER THAN SILENTLY is the flag, and this test asserted only the + // flag. `truncated: true` beside text that just stops is exactly the silent cut the name + // promises against: the model reads a JSON document that ends mid-token and completes it from + // memory, because nothing in what it was handed says the ending is ours. + expect(result.isError).toBe(false); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("an empty answer says so in words rather than being empty", async () => { @@ -662,6 +771,10 @@ describe("calling one action", () => { // `data` is a required record, so the empty answer the SDK can actually produce is `{}` — if that // did not count, this branch would be unreachable and its promise would be a fiction. expect(result.text).toMatch(/returned nothing/i); + // Nothing to say is not a failure and is not a truncation. Both fields were unasserted, so this + // branch could have started reporting an error and the test would not have noticed. + expect(result.isError).toBe(false); + expect(result.truncated).toBe(false); }); test("an answer the vendor marked unsuccessful is a failure, not content", async () => { @@ -708,11 +821,14 @@ describe("calling one action", () => { ); expect(result.isError).toBe(false); - expect(result.text).toContain("m1"); - // `successful`, `error` and `logId` are the envelope this transport reads to decide the outcome. - // Reporting them as content spends a model's context on our own bookkeeping. - expect(result.text).not.toContain("log_must_not_appear"); - expect(result.text).not.toContain("successful"); + // `successful`, `error` and `logId` are the envelope this transport reads to decide the + // outcome. Reporting them as content spends a model's context on our own bookkeeping. Pinned + // as the whole string rather than as three absences, because a list of things that must not + // appear is only ever as long as the fields the envelope had on the day it was written — the + // vendor's `sessionInfo` is already in the type and named in none of them. + expect(result.text).toBe( + JSON.stringify({ messages: [{ id: "m1" }] }, null, 2), + ); }); test("an unsuccessful answer with no sentence still says something actionable", async () => { @@ -755,6 +871,39 @@ describe("calling one action", () => { expect(result.text).toMatch(/Plugins page/); }); + test("a failure that carries no message at all still says something actionable", async () => { + /* + * The empty-message arm of the fallback, which nothing reached. Both ways of arriving at it are + * real: `@composio/core` rejects with plain objects on some paths, so `error instanceof Error` + * is false and there is no message to read at all; and a thrown `Error` whose message is blank + * or whitespace is what a transport-level abort produces. + * + * Passed on unchanged, either one lands in a model's context and in `store.ts`'s audit row as an + * empty refusal — a failure with `isError: true` and nothing said, which reads to a model as + * permission to invent a reason and retry. + */ + for (const thrown of [{ status: 502 }, new Error(""), new Error(" \n ")]) { + useComposioClient( + recording({ + execute: async () => { + throw thrown; + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text.trim()).not.toBe(""); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + } + }); + test("an enormous vendor sentence is capped in a refusal too, and says so", async () => { useComposioClient( recording({ @@ -773,7 +922,11 @@ describe("calling one action", () => { // is the same unbounded spend the success path already refuses to make. expect(result.isError).toBe(true); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + // "and says so" is the marker, which nothing here used to check. + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("an enormous thrown message is capped in a refusal too", async () => { @@ -793,7 +946,10 @@ describe("calling one action", () => { expect(result.isError).toBe(true); expect(result.truncated).toBe(true); - expect(result.text.length).toBeLessThan(25_000); + expect(result.text.slice(-TRUNCATION_MARKER.length)).toBe( + TRUNCATION_MARKER, + ); + expect(result.text.length).toBe(CAPPED_LENGTH); }); test("our own serialization failure is not reported as the action having failed", async () => { From 056a0c5f038fa78ff63b1fae30f90d3e0d9fb0f7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:19:51 +0200 Subject: [PATCH 070/215] Refuse a brokered row whose id is a curated slug instead of dialling the curated vendor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 48 +++++++++++++++++++++++++++- server/src/plugins/store.ts | 12 +++++-- server/tests/composio-access.test.ts | 46 ++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 15c339377..d0fa2f2a1 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -102,13 +102,53 @@ const REACHED_AS_BY_AUTH: Record< }; /** - * A reviewed entry decides for itself; otherwise the row decides. + * A row that claims to be two servers at once, which makes it neither. + * + * CRITERION. A row whose provenance says `composio` and whose id is a curated catalogue slug is + * refused, not resolved — in either direction. + * + * REASON. {@link accessFor} holds two facts and no third: the row, and the entry that row's id + * looked up. A curated row whose provenance column was edited to `composio` and a genuinely + * brokered app that happens to be named `notion` arrive here identically, so every answer is right + * about one of them and wrong about the other. Entry-wins picked the first reading and therefore + * dialled the second as MCP at the curated vendor's pinned host, spending the deployment's own + * grant instead of the asking person's brokered connection — the wrong vendor on the wrong + * credential, recorded in the trail as an ordinary call to a reviewed server. + * + * THE SAME COLLISION IS ALREADY REFUSED AT THE OTHER END. `addCustomServer` will not let a row take + * a curated slug, because the slug prefixes tool names and is what a grant and a policy rule are + * written against. There is no `addComposioServer` to copy that guard into — nothing in the shipped + * product writes a `composio` row at all — so a colliding row arrives only by hand edit or restore, + * and only a check at resolution sees one. + * + * NOBODY ASKED FOR THIS REFUSAL, so it is not a person's to act on mid-call: it is two of our own + * columns contradicting each other, the same shelf `PluginInvariantError` sits on. Declared here + * rather than imported from `store.ts` because this module is a leaf — `store.ts` imports it, and + * it imports nothing back. + */ +export class ServerRowAmbiguousError extends Error { + constructor(message: string) { + super(message); + this.name = "ServerRowAmbiguousError"; + } +} + +/** + * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is + * refused rather than resolved. * * THE ENTRY WINS, AND THAT ORDER IS THE SECURITY PROPERTY. A curated slug's behaviour comes from code * that was reviewed, so a row whose provenance column says something else — edited by hand, restored * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start * sending its calls somewhere else. The row only ever answers where the catalogue is silent. * + * IT CUTS BOTH WAYS, WHICH IS WHY `composio` IS REFUSED RATHER THAN OVERRULED. Only one direction + * was considered when that order was written: a brokered row whose id collides with a curated slug + * was quietly answered as the curated vendor. Nothing in these two arguments tells that row apart + * from a tampered curated one, so the only answer that is not wrong in one of the two worlds is no + * answer. See {@link ServerRowAmbiguousError}. Every other provenance value still loses to the + * entry, because none of them proposes a different vendor to reach. + * * MCP stays the fallback, which is still right for a server an administrator added by URL: that is * somebody else's MCP endpoint by definition, reached on the one token the deployment holds for it. */ @@ -116,6 +156,12 @@ export function accessFor( row: { provenance: string; url: string }, entry: CatalogueEntry | null, ): ServerAccess { + if (entry && row.provenance === "composio") { + throw new ServerRowAmbiguousError( + `${entry.key} is a server this deployment ships an entry for, and a row with that id says its provenance is composio. Nothing can tell an edited column from a brokered app that took the name, so this row is not resolved at all: rename it, or correct its provenance.`, + ); + } + if (entry) { return { transport: entry.transport ?? "mcp", diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 61db3b274..74367ac58 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2153,14 +2153,20 @@ export function createPluginStore(options: PluginStoreOptions) { * some other app's connections, or none, for the very row shape that gate already refuses to * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. * + * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An + * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row + * that has one — so passing the entry a colliding id looks up would hide the brokered state of + * the one row most in need of clearing, and would now refuse outright the very row this method + * exists to get rid of, leaving the collision unremovable. Nothing is dialled here, so there is + * no vendor for an entry to protect; the only question is which app's consent rows this row's + * own url stands for. + * * Before the server row goes, for the reason the revokes above are: what a failure between * two writes leaves has to be the recoverable half. A connection cleared with the app still * present is fixed by removing it again; an app deleted with the connections standing is * reachable by no operation at all, because the toolkit was only ever readable off its url. */ - const toolkit = existing - ? accessFor(existing, catalogueEntry(serverId)).toolkit - : null; + const toolkit = existing ? accessFor(existing, null).toolkit : null; if (toolkit) { const connected = await database diff --git a/server/tests/composio-access.test.ts b/server/tests/composio-access.test.ts index 2187a340c..06b59497c 100644 --- a/server/tests/composio-access.test.ts +++ b/server/tests/composio-access.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { accessFor } from "../src/plugins/access"; +import { accessFor, ServerRowAmbiguousError } from "../src/plugins/access"; import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry, resolveServerUrl } from "../src/plugins/catalogue"; @@ -143,8 +143,12 @@ describe("accessFor", () => { // A row whose provenance was tampered with must not turn a reviewed vendor into a brokered one, // and must not acquire an app at the broker either — a url edited to `composio://gmail` on a // curated slug is the same tampering by another field. + // + // `composio` is deliberately NOT the value used here. That one combination is now refused + // outright rather than overruled — see the test below for why the entry cannot arbitrate it — + // and this test is about every other value the column can hold, where the entry still decides. const shadowed = accessFor( - { provenance: "composio", url: "composio://gmail" }, + { provenance: "custom", url: "composio://gmail" }, notion, ); expect(shadowed.transport).toBe("mcp"); @@ -152,6 +156,44 @@ describe("accessFor", () => { expect(shadowed.toolkit).toBeNull(); }); + test("a brokered row that carries a curated slug is refused, not dialled at the curated vendor", () => { + const notion = catalogueEntry("notion"); + // Thrown for the reason the test above throws: a renamed slug must break this file rather than + // quietly stop checking the protection it exists for. + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here checks that a colliding row is refused", + ); + } + // Two different rows produce this pair of arguments and nothing in them tells the two apart: a + // curated Notion row whose provenance column was edited to `composio`, and a genuinely brokered + // Notion app whose id happens to be the catalogue's slug. Entry-wins answered as though only + // the first existed, so the second was dialled as MCP at Notion's pinned host on the + // deployment's grant rather than the person's brokered connection. Refusing is the only answer + // that is not wrong in one of the two worlds. + expect(() => + accessFor({ provenance: "composio", url: "composio://notion" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // The url is not what makes it ambiguous. The ID is, and `entry` is how this function is told + // the id collided — so a brokered row pointed at some other app is refused on the same ground, + // and nothing here can be satisfied by reading the url more carefully. + expect(() => + accessFor({ provenance: "composio", url: "composio://gmail" }, notion), + ).toThrow(ServerRowAmbiguousError); + + // And the refusal is the collision's, not the provenance value's: the same row with no curated + // entry behind its id resolves exactly as any other brokered row does. + expect( + accessFor({ provenance: "composio", url: "composio://notion" }, null), + ).toEqual({ + transport: "composio", + credential: "brokered", + reachedAs: "person", + toolkit: "notion", + }); + }); + test("which app a Composio row is comes from its url, not from its id", () => { // The id is a display key and the url is what the transport dials, so the url is what decides. // A row named `gmail` at `composio://slack` used to be checked against a Gmail connection and From ce6fc8a3fe669a4b608c03923bd4becc19eed5f4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:20:45 +0200 Subject: [PATCH 071/215] Say that an empty listing came from a vendor that was asked, because now it did Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 48 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 74367ac58..2edabc0cd 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2369,26 +2369,34 @@ export function createPluginStore(options: PluginStoreOptions) { * nothing to lose, so the empty answer falls through to the replace below and commits — a * refresh stamp, no error, no actions. * - * REASON. `listTools` returning `[]` is supposed to mean "the vendor was asked and advertises - * none", and for three of the four transports it does. `composio.listTools` breaks it: it - * answers `[]` when no client is installed, and NOTHING IN THE SHIPPED PRODUCT CALLS - * `useComposioClient` — so on every real deployment that is the only answer a Composio refresh - * can produce. Committed, it deleted every `mcp_tools` row for the app, taking the recorded - * `effect`, `destructive` and `version` with it. `version` is the one that cannot be - * reconstructed: `callTool` refuses an action without it, so a refresh that reported success - * broke every subsequent call. The grants survived, pointing at rows that no longer existed — - * absent from `listServers`, and revived by a later refresh that worked. + * REASON. The replace below is a delete and an insert, so an empty answer committed here + * deletes every `mcp_tools` row for the server, taking the recorded `effect`, `destructive` + * and `version` with it. `version` is the one that cannot be reconstructed: `callTool` refuses + * an action without it, so a refresh that reported success broke every subsequent call, and + * the grants survived pointing at rows that no longer existed — absent from `listServers`, and + * revived only by a later refresh that worked. + * + * WHAT USED TO REACH THIS LINE, and no longer does. `composio.listTools` once answered `[]` + * for a url naming no app and for a deployment with no Composio client installed, neither of + * which is a vendor's answer, and the second of those is the state of every real deployment. + * Both throw now, so that particular arrival is closed at the seam rather than here. The guard + * stays because its argument never depended on who sent the empty answer. * * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and - * only a refresh from a deployment that can actually reach the vendor puts it back. + * only a refresh from a deployment that can actually reach the vendor puts it back. That holds + * for any vendor that suddenly lists nothing, whatever made it do so, which is why removing + * this would reopen the same data loss for a different reason. * - * THE SEAM REQUIREMENT this leans on, which is not satisfied today: a transport that could not - * ask anybody must THROW rather than return `[]`. `composio.ts:141` returns `[]` for a missing - * client and for a url that names no app, and until it throws instead there is no field on the - * listing that tells the two apart. Nothing here asks which transport it is talking to, so the - * fix belongs in that one line and not in a branch on `access.transport`. + * THE SEAM REQUIREMENT this leans on, and it is satisfied: a transport that could not ask + * anybody must THROW rather than return `[]`. `composio.listTools` opens with two throws that + * say which of the two it is — no app in the url, no client installed — and no transport has + * an early `return []` left in it at all: `builtin-routines` answers a static list, and `mcp` + * and `google-drive-rest` hand back only what a request returned. So an empty listing reaching + * this line is a vendor's own answer, which is what the sentence below says. Nothing here asks + * which transport it is talking to, and nothing has to: the requirement is met at each seam + * rather than branched on here. */ if (listed.length === 0) { const held = await database @@ -2401,9 +2409,13 @@ export function createPluginStore(options: PluginStoreOptions) { .update(mcpServers) .set({ // Named as the state it is, because "listed nothing" and "would not answer" send an - // operator to different places. No `toolsRefreshedAt`: that column says when this - // deployment last learned what the app offers, and it did not learn it here. - lastError: `This app listed no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check that the connector is configured for this deployment, then refresh again.`, + // operator to different places, and reaching this line settles which one it was: a + // listing that could not be made throws and lands in the `catch` above instead. So + // this sentence must not send anybody to check their configuration — that is the + // other state's sentence, written by the transport that refused. No + // `toolsRefreshedAt`: that column says when this deployment last learned what the app + // offers, and it did not learn it here. + lastError: `This app was asked and answered with no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check whether it still publishes them, then refresh again.`, updatedAt: new Date(), }) .where(eq(mcpServers.id, serverId)); From 8a375f5eadc1723b01cd0c8920146cad2886123f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:20:07 +0200 Subject: [PATCH 072/215] Refuse to delete a person this suite never created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership guard asked about `mcp_servers`, `agents` and `composio_connections` and never about `users`, while `freshDatabase` deleted `users.user_leaver` before every test whatever the guard had decided. A real account at that id goes through ten cascades — sign-in accounts, live sessions, roles, channel memberships, per-Bot preferences, written instructions, skills, routines, per-user connector credentials — and the fixture then re-inserts a look-alike, so nothing says it happened. The guard now covers the person too, and the deletes wait on the same positive evidence the teardown does rather than on the assumption that a thrown `beforeAll` stops everything below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index d6460b6b6..eddd608cb 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -195,13 +195,25 @@ let ownsFixtureIds = false; * from any screen and invisible to `retireConnectionsFor`, which exists to stop exactly that state. * Removing a real Bot takes six tables: its channel memberships, its agent profile, everyone's * preferences for it, its routines and all of their run history, its component exclusions and its - * plugin grants. The fixtures then re-insert byte-identical look-alikes, so nothing on screen would - * say it happened. + * plugin grants. Removing a real PERSON takes ten: their sign-in accounts and live sessions, their + * roles, their channel memberships and intelligence mappings, their per-Bot preferences and written + * instructions, their skills, their routines, and every per-user connector credential they hold — + * and nulls the owner off their agent profiles and SSO provider besides. The fixtures then re-insert + * byte-identical look-alikes, so nothing on screen would say it happened. * - * So the deletes in {@link freshDatabase} are unconditional and this is what makes them safe. + * The list below is every table this file deletes from at an id it did not invent — an id spelled + * the way production spells it, so a row already sitting at it belongs to somebody else. Each of + * `mcp_servers`, `agents`, `composio_connections` and `users` is asked about here, and nothing else + * needs to be: `mcp_tools` and `plugin_grants` are the two remaining unconditional deletes and both + * are reached only by a key that references one of these four, so a row at either could not exist + * without the guard having already refused over its parent. Every other delete in this file names + * an id carrying {@link suite}. + * + * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what + * makes them safe. */ beforeAll(async () => { - const [configuredServers, existingBots, existingConnections] = + const [configuredServers, existingBots, existingConnections, existingPeople] = await Promise.all([ database .select({ id: mcpServers.id }) @@ -217,6 +229,17 @@ beforeAll(async () => { .where( inArray(composioConnections.userId, ["user_asker", "user_leaver"]), ), + /* + * The person, who was missing from this guard entirely. + * + * `user_leaver` is inserted at and deleted at by the Composio fixtures, and the delete used to + * run whatever the guard had decided — so a real row at that id was removed, with the ten + * cascades above behind it, on a run the guard had already refused. + */ + database + .select({ id: users.id }) + .from(users) + .where(eq(users.id, "user_leaver")), ]); const found = [ @@ -225,15 +248,16 @@ beforeAll(async () => { ...existingConnections.map( (row) => `the composio_connections row for '${row.userId}'`, ), + ...existingPeople.map((row) => `the person '${row.id}'`), ]; if (found.length > 0) { throw new Error( `This suite owns ${found.join(", ")} outright — it inserts at those exact ids and deletes ` + "them before every test — and refuses to run against a database that already has them, " + - "because deleting a real server row takes every person's per-user credentials with it and " + - "deleting a real Bot takes the six tables behind it. Point DATABASE_URL at a scratch " + - "database.", + "because deleting a real server row takes every person's per-user credentials with it, " + + "deleting a real Bot takes the six tables behind it, and deleting a real person takes the " + + "ten behind them. Point DATABASE_URL at a scratch database.", ); } @@ -3363,14 +3387,32 @@ describe("a vendor reply that is not a token", () => { * `bot_helper` and `user_asker` name them in every assertion. * * What replaces the suffix is the guard at the top of this file, not the ordering below. The deletes - * here are unconditional and would be indefensible on their own — a real `notion` row cascades into - * every person's per-user credentials, a real Bot into the six tables behind it. They are safe only - * because nothing gets this far unless the guard has already established that no row at any of these - * ids exists, which makes every row they remove one of this file's own. Cleaning before each test - * rather than after is then just so a run that dies halfway leaves the next one nothing to trip over; - * the `afterAll` below is what stops the last test's fixtures from outliving the run. + * here would be indefensible on their own — a real `notion` row cascades into every person's per-user + * credentials, a real Bot into the six tables behind it, a real person into the ten behind them. They + * are safe only because the guard has established that no row at any of these ids exists, which makes + * every row they remove one of this file's own, and the first thing below is the check that it did. + * Cleaning before each test rather than after is then just so a run that dies halfway leaves the next + * one nothing to trip over; the `afterAll` below is what stops the last test's fixtures from outliving + * the run. */ async function freshDatabase(): Promise { + /* + * The guard's answer, asked again rather than assumed. + * + * "Nothing gets this far unless the guard has established the ids are free" was the whole + * justification for the deletes below, and it was an assumption about the runner: a `beforeAll` + * that throws is supposed to stop every test under it. The rest of this file already declines to + * rely on that — `afterAll` is documented as running anyway, which is why {@link ownsFixtureIds} + * exists at all — and a delete cascading through a real person is not a thing to leave resting on + * the difference. So the same positive evidence the teardown waits for authorises these too, and + * a run that never got it fails loudly here instead of quietly emptying rows. + */ + if (!ownsFixtureIds) { + throw new Error( + "the ownership guard has not cleared this run to own 'gmail', 'notion', 'bot_helper', " + + "'user_asker' and 'user_leaver', so nothing may be deleted at those ids", + ); + } /* * The Bot's own grants, never a delete by ref. * @@ -3397,6 +3439,9 @@ async function freshDatabase(): Promise { await database .delete(composioConnections) .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + // The person the connection outlives, who is a row in `users` like anybody else. Reached only + // through the check at the top of this function, because there is no suffix on this id to tell a + // fixture apart from somebody's account and ten cascades sit behind the difference. await database.delete(users).where(eq(users.id, "user_leaver")); return database; } From 1aad7c8a678fbb528b8d1d50fdb4484f53378767 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:23:23 +0200 Subject: [PATCH 073/215] Take back every credential row this file mints, and put back only a client it actually saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways a row outlived the run. The dynamic-registration suite wrapped the vault's `create` and not its `rotate`, so every reconnect and every re-registration after the first minted a `notion` credential nothing recorded — thirteen rows a run, the last of them a live `mcp_user_token` for a person, revoked by nothing and referenced by nothing. The custom-credential suite minted four tokens and deleted three, leaving the upsert's own live in the vault against a server that no longer exists. And both OAuth suites restored `mcp_servers.credential_id` from a variable only their `beforeAll` assigns, while `afterAll` runs whether or not that `beforeAll` finished — so a setup that died early wrote null over a deployment's client and then deleted the row, on the strength of a flag still sitting at its initialiser. The capture now starts at `undefined`, which is nobody having looked, and both the restore and the delete wait on evidence rather than on an absence of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 94 ++++++++++++++----- 1 file changed, 69 insertions(+), 25 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index eddd608cb..57e44493a 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -1239,14 +1239,24 @@ describe("refresh token rotation", () => { sent.length = 0; } - let notionWasAlreadyConfigured = false; + /** + * Whether THIS RUN is what put the `notion` row there, and so is what should take it away. + * + * Counting creations rather than absences, the same way round as {@link suiteCreatedServerRow} and + * for the same reason: `afterAll` runs even when the `beforeAll` below it has thrown, and a flag + * still sitting at its initialiser then authorised the delete. Only a capture that ran and found + * the row missing can write the value the delete needs. + */ + let suiteCreatedNotionRow = false; /** * The OAuth client this deployment had before the suite ran, restored afterwards. * - * `mcp_servers.credential_id` is live configuration, and this suite repoints it. Restored - * unconditionally, because the delete below removes the row it would otherwise still address. + * `mcp_servers.credential_id` is live configuration, and this suite repoints it. `undefined` is + * "nobody looked", which is what the value is until the capture below runs and is what it is still + * sitting at if that `beforeAll` threw first — and a restore that treated it as `null` would not be + * restoring anything, it would be blanking a real deployment's client on the way out. */ - let clientBefore: string | null = null; + let clientBefore: string | null | undefined; beforeAll(async () => { await database @@ -1272,7 +1282,7 @@ describe("refresh token rotation", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, rotationServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; // Written directly, so the test needs no vendor to be reachable. What is under test is which @@ -1314,11 +1324,15 @@ describe("refresh token rotation", () => { eq(mcpUserCredentials.userId, rotationUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, rotationServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Only when the + // capture actually ran: `undefined` is nobody having looked, and writing that back as null is + // not a restore. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, rotationServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -1339,7 +1353,7 @@ describe("refresh token rotation", () => { ), ); // A server row is deployment configuration, so it goes only if this suite is what added it. - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, rotationServerId)); @@ -1868,7 +1882,15 @@ describe("a dynamic client the vendor has evicted", () => { * * Genuine rather than stubbed, because what this suite asserts is that a re-registered client is * KEPT — which is a write and a read back through the encryption, not a call that was made. The - * one wrapper is the bookkeeping that lets the cleanup take exactly this suite's rows. + * wrappers are the bookkeeping that lets the cleanup take exactly this suite's rows. + * + * BOTH ways a row is minted, not just the first. `create` is the vault's answer when the key holds + * no live row; `rotate` is its answer when one does, and `recordConnection` and `storeOAuthClient` + * each pick between them on exactly that. So every reconnect after the first and every + * re-registration after the first went through `rotate` — which the spread handed straight to the + * real vault, unrecorded. This suite reconnects and re-registers repeatedly, and each run left + * thirteen `notion` credential rows nothing would ever remove, the last of them LIVE: an + * `mcp_user_token` for a person, unrevoked and referenced by nothing. */ const realVault = createCredentialStore(database); const vault = { @@ -1890,6 +1912,15 @@ describe("a dynamic client the vendor has evicted", () => { vaultRows.push(row.id); return row; }, + /** The same forwarding, for the same reason: `rotate` runs inside the caller's transaction too. */ + rotate: async ( + value: Parameters[0], + executor?: Parameters[1], + ) => { + const row = await realVault.rotate(value, executor); + vaultRows.push(row.id); + return row; + }, }; /** @@ -2098,9 +2129,15 @@ describe("a dynamic client the vendor has evicted", () => { actorId: dynamicUserId, }); - let notionWasAlreadyConfigured = false; - /** This deployment's own client, restored afterwards: the column is live configuration. */ - let clientBefore: string | null = null; + /** Whether THIS RUN put the `notion` row there. Counted, never inferred from an absence. */ + let suiteCreatedNotionRow = false; + /** + * This deployment's own client, restored afterwards: the column is live configuration. + * + * `undefined` until the capture runs, so a `beforeAll` that dies before it leaves a teardown that + * knows it has nothing to put back rather than one that writes null over somebody's client. + */ + let clientBefore: string | null | undefined; // The vendor refuses the ordinary way unless a test says otherwise, so a test that varies the // refusal cannot leave the next one asserting against somebody else's setup. @@ -2132,7 +2169,7 @@ describe("a dynamic client the vendor has evicted", () => { .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, dynamicServerId)); - notionWasAlreadyConfigured = existing !== undefined; + suiteCreatedNotionRow = existing === undefined; clientBefore = existing?.credentialId ?? null; await database @@ -2170,11 +2207,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpUserCredentials.userId, dynamicUserId), ), ); - // Before the deletes, because the column addresses one of the rows they remove. - await database - .update(mcpServers) - .set({ credentialId: clientBefore }) - .where(eq(mcpServers.id, dynamicServerId)); + // Before the deletes, because the column addresses one of the rows they remove. Skipped + // entirely when no capture ran, for the reason on {@link clientBefore}. + if (clientBefore !== undefined) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, dynamicServerId)); + } for (const id of vaultRows) { await database.delete(credentials).where(eq(credentials.id, id)); } @@ -2194,7 +2234,7 @@ describe("a dynamic client the vendor has evicted", () => { eq(mcpTools.name, dynamicToolName), ), ); - if (!notionWasAlreadyConfigured) { + if (suiteCreatedNotionRow) { await database .delete(mcpTools) .where(eq(mcpTools.serverId, dynamicServerId)); @@ -3074,12 +3114,16 @@ describe("a custom server may only be pointed at its own kind of credential", () await database .delete(mcpServers) .where(like(mcpServers.id, `${customServerId}%`)); + // Every id the `beforeAll` above minted, which is the list this one has to match. The upsert's + // own token was missing from it, so each run left one live `mcp` credential behind for a server + // that no longer exists — a secret in the vault reachable from nothing. await database .delete(credentialRows) .where( inArray(credentialRows.id, [ deploymentCredentialId, personalCredentialId, + upsertCredentialId, oauthClientCredentialId, ]), ); @@ -3574,9 +3618,9 @@ afterEach(() => useComposioClient(null)); * {@link freshDatabase} cleans BEFORE each test, so without this the final test's rows are * permanent: a `notion` server row and a `notion-fetch` action nobody configured, which makes * whatever database this ran against advertise a connector nobody set up. Worse on the next run — - * the rotation and dynamic-registration suites above capture `notionWasAlreadyConfigured` from the - * leak, correctly decline to clean what looks like the deployment's own row, and leave the - * unconditional delete as the only thing that removes it. + * the rotation and dynamic-registration suites above read the leak as a row they did not create, + * correctly decline to clean what looks like the deployment's own, and leave this delete as the only + * thing that removes it. * * Exactly what this file created, and only when the guard cleared the run to own these ids. */ From e294a1f2ea1192c0fce341d31a36b11f37d75e11 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:28:22 +0200 Subject: [PATCH 074/215] Narrow every audit assertion to the run that is being asserted about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `audit_events` is append-only and 0012 closed the last way around that, so this file cannot tidy the trail and every row every previous run wrote is still there. The refusals are recorded against `google-drive/search_files` and named by rules about `google-drive`, spellings forced for the same reason the fixtures are, so the queries matched nine hundred rows this run had nothing to do with. The reader test's `limit(1)` was in fact answered by the oldest row in the table — a refusal from yesterday, written by code that is not this branch's — and the dry-run test read `recorded[0]` out of the same pile. Those are tests that cannot fail: delete the line that writes the row and they stay green. Every query now carries the run's start, taken from the database's own clock in the first hook the file registers, and the two that indexed into an unordered list say how many rows they expect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 57e44493a..64d2c8c8a 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; -import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -149,6 +149,34 @@ const store = createPluginStore({ policy: () => policy, }); +/** + * When this run began, by the DATABASE's clock, so every audit query can exclude what came before. + * + * The trail is the one table this file cannot tidy up after itself: `audit_events` is append-only, + * and 0012 closed the last way around that, so every row every previous run wrote is still there and + * still matches. The refusals are recorded against `google-drive/search_files` and named by rules + * about `google-drive` — production spellings, forced for the same reason the fixtures are — so a + * query narrowed only by target and rule matches nine hundred rows this run had nothing to do with, + * and an assertion that one exists is answered by a run that finished yesterday. That is a test + * which cannot fail: deleting the code that writes the row would leave it green. + * + * Postgres's clock rather than this process's, because the two are not the same clock and the + * comparison happens against a column the server stamps. + * + * Read through {@link sinceThisRun}, which refuses rather than defaulting: a bound of "the beginning + * of time" is the unscoped query back again, silently. + */ +let runStartedAt: Date | null = null; + +function sinceThisRun() { + if (!runStartedAt) { + throw new Error( + "the run's start was never recorded, so no audit query can be narrowed to it", + ); + } + return gte(auditEvents.createdAt, runStartedAt); +} + async function auditRowsFor(targetId: string) { return database .select({ @@ -162,6 +190,7 @@ async function auditRowsFor(targetId: string) { and( eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.targetId, targetId), + sinceThisRun(), ), ); } @@ -213,6 +242,16 @@ let ownsFixtureIds = false; * makes them safe. */ beforeAll(async () => { + /* + * Stamped here, in the first hook the file registers, so no row this run writes is older than it + * and no row an earlier run wrote is newer. + */ + const [clock] = await database.execute<{ now: Date }>( + sql`select now() as now`, + ); + if (!clock) throw new Error("the database would not say what time it is"); + runStartedAt = clock.now; + const [configuredServers, existingBots, existingConnections, existingPeople] = await Promise.all([ database @@ -618,7 +657,10 @@ describe("the policy is asked as well as the grant", () => { (row.payload as { decision?: { rule?: string } }).decision?.rule === rule, ); - expect(recorded.length).toBeGreaterThan(0); + // The one this call wrote. Exact, because the reads below are of `recorded[0]` and the list is + // in no order: with the rows of every previous run in it, that index was whichever the planner + // returned first, which is a row this code did not write. + expect(recorded).toHaveLength(1); /* * What tells this row apart from a call this deployment actually stopped. `allowed` is the * policy's answer and `carriedOut` is what the mode did with it, so a reader counting what a @@ -878,6 +920,14 @@ describe("removing an MCP server", () => { describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { + /* + * This run's refusal, not whichever of nine hundred the planner happened to hand back first. + * + * Unbounded, `limit(1)` was answered by the oldest row in the table — a refusal from a run + * whose Bot id no longer names anything — so the payload shape being asserted was a shape this + * branch's code had never written. Ordered as well as bounded, because `limit` without an order + * is a row the query plan picks. + */ const [row] = await database .select({ bot: sql`payload ->> 'bot'`, @@ -890,8 +940,10 @@ describe("the trail can be read by a second reader", () => { eq(auditEvents.targetType, "mcp_tool"), eq(auditEvents.eventType, "mcp.call_rejected"), eq(auditEvents.targetId, ref), + sinceThisRun(), ), ) + .orderBy(asc(auditEvents.createdAt), asc(auditEvents.id)) .limit(1); // Asserted in SQL rather than through the application, because the stored payload shape is the @@ -2096,6 +2148,8 @@ describe("a dynamic client the vendor has evicted", () => { and( eq(auditEvents.eventType, "mcp.oauth_client_registered"), eq(auditEvents.targetId, dynamicServerId), + // `notion` and `dyn-1` are fixed spellings, so without this the count is every run's. + sinceThisRun(), ), ); } @@ -2702,6 +2756,14 @@ describe("a dynamic client the vendor has evicted", () => { eq(auditEvents.eventType, "configuration.changed"), eq(auditEvents.targetId, dynamicServerId), sql`payload ->> 'change' = 'unlisted_tools_advertised'`, + /* + * `named` is what THIS refresh recorded, not the union over every refresh there has + * ever been. `notion` and `notion-create-pages` are both fixed spellings, so both + * assertions below were being answered partly by rows older code wrote: the negative + * one would report today's classification as wrong on the strength of a row from + * before the write list covered that name. + */ + sinceThisRun(), ), ) ).flatMap((row) => (row.payload as { tools?: string[] }).tools ?? []); From ea0500e355e51fe8e2da1bcc9a4618510bf965d4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 04:31:06 +0200 Subject: [PATCH 075/215] Bring the anonymous actor inside the guard that covers every other id here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composio_connections.user_id` is notNull and notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can legally hold — which is exactly what the test that inserts one is about. The delete that takes it back runs inline, outside `freshDatabase` and outside every other sweep, at an id nothing had established was this file's. With it in the list, the guard's claim is now true of every table this file deletes from at an id it did not invent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 64d2c8c8a..b8183aafa 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -234,9 +234,11 @@ let ownsFixtureIds = false; * the way production spells it, so a row already sitting at it belongs to somebody else. Each of * `mcp_servers`, `agents`, `composio_connections` and `users` is asked about here, and nothing else * needs to be: `mcp_tools` and `plugin_grants` are the two remaining unconditional deletes and both - * are reached only by a key that references one of these four, so a row at either could not exist - * without the guard having already refused over its parent. Every other delete in this file names - * an id carrying {@link suite}. + * are reached only by a key that references one of these four — `mcp_tools.server_id` names a + * server, `plugin_grants.agent_id` names a Bot — so a row at either could not exist without the + * guard having already refused over its parent. Every other delete in this file names an id + * carrying {@link suite}, and the reads that touch the deployment's own `google-drive` row skip + * their delete instead, on the {@link suiteCreatedServerRow} flags above. * * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what * makes them safe. @@ -262,11 +264,21 @@ beforeAll(async () => { .select({ id: agents.id }) .from(agents) .where(eq(agents.id, "bot_helper")), + /* + * The anonymous actor is one of these ids too. `composio_connections.user_id` is notNull and + * notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can + * legally hold — which is the whole point of the test that inserts one — and the delete that + * takes it back again runs inline, outside `freshDatabase` and outside every other sweep. + */ database .select({ userId: composioConnections.userId }) .from(composioConnections) .where( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + inArray(composioConnections.userId, [ + "user_asker", + "user_leaver", + "", + ]), ), /* * The person, who was missing from this guard entirely. From 3a4b46c98522aa847b0ca4ef440ea4cce45cf1a1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:17:43 +0200 Subject: [PATCH 076/215] Believe the vendor when it declares an action destructive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTools` mapped a `tools/list` answer into name, description and schema and dropped `annotations` entirely. The MCP specification defines `annotations.destructiveHint` and servers publish it, so `effect` and `destructive` were never set for any MCP-transport listing. That silence was doing more work than it looked. The docblock on `ListedTool` asserted that an MCP server publishes no effect, and the argument that a recorded effect could not disturb any existing curated read rested on it — true of the behaviour, but only because the annotations were being thrown away. The consequence: a tool a vendor declares destructive fell through `classifyTool` to read for any curated entry whose hand-written `writeTools` happened to omit the name. Fail-open, on a permission-adjacent decision. Only `destructiveHint` is believed, and only on an explicit true. It can move an action from read to write and never the other way, so a server that lies with it restricts itself and nothing else. `readOnlyHint` is withheld: for a curated vendor it changes no answer, since an advertised name absent from `writeTools` already classifies as a read, and the one case where it would change an answer is a server an administrator added by URL, where believing it means letting an arbitrary server declare its own tools harmless. The SDK says as much where it declares the hints. The specification's default of destructive-when-not-read-only is likewise not applied, because applying it would reclassify every unannotated action of every MCP vendor as a write. A tool carrying no annotations is unchanged in every respect, so Notion's reviewed write list goes on deciding exactly as it did, and Drive never reaches this code at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/mcp.ts | 53 ++++++++- server/tests/mcp-listing.test.ts | 183 +++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 server/tests/mcp-listing.test.ts diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts index 021459c66..f53fb16a7 100644 --- a/server/src/plugins/mcp.ts +++ b/server/src/plugins/mcp.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; /** * The only place in this deployment that speaks MCP to somebody else's server. @@ -89,9 +90,17 @@ export type McpTool = { * A tool as a transport listed it, including anything that transport happens to know about it. * * Three optional fields rather than a separate type per transport, so `refreshTools` reads - * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because most transports - * know none of it: an MCP server publishes no effect and no version, and a field it always left - * undefined would be an invitation to read it as meaning something. + * `tool.effect` with no cast and no `"effect" in tool` sniffing. Optional because a transport may + * know none of it for a given tool, and a field always left undefined would be an invitation to + * read it as meaning something. + * + * AN MCP SERVER CAN PUBLISH AN EFFECT, and this docblock used to say it could not. That sentence + * was not a stale comment, it was load bearing: the argument that surfacing a recorded effect could + * not disturb any existing curated read rested on MCP listings never carrying one, which was true + * only because {@link listTools} was discarding `annotations`. The specification defines + * `annotations.destructiveHint`, servers publish it, and a tool a vendor declared destructive was + * classifying as a read for any curated entry whose hand-written `writeTools` happened to omit the + * name. Version is the field MCP genuinely has no concept of; effect and destructive are not. * * `McpTool` stays exactly what a `tools/list` answer contains, because that is what it is for. */ @@ -274,8 +283,43 @@ async function withClient( */ export const listNeedsCredential = true; +/** + * ONLY THE HINT THAT NARROWS IS BELIEVED, and the omission of the other one is the decision here. + * + * The SDK declares four hints on `annotations` — `readOnlyHint`, `destructiveHint`, + * `idempotentHint`, `openWorldHint` — and warns in the same place that a client should never make + * tool use decisions from annotations a server it does not trust supplied. That warning is the + * whole design of this function. `destructiveHint` can only ever move an action from read to write, + * so a server that lies with it can restrict itself and nothing else. `readOnlyHint` moves an + * action the other way, and `classifyTool` exists to make sure nothing but review can do that. + * + * WHAT HONOURING `readOnlyHint` WOULD ACTUALLY BUY, which is the reason withholding it costs + * nothing. For a curated vendor it changes no answer: an advertised name absent from the reviewed + * `writeTools` already classifies as a read, so recording `read` for it lands on the same result by + * a worse route. For a name the reviewed list DOES hold, `classifyTool` consults review first and + * ignores the column, so the hint would be discarded anyway. The single case where it would change + * an answer is a server an administrator added by URL, which has no reviewed list behind it and + * whose every tool is a write for exactly that reason — and there, believing it means letting an + * arbitrary server declare its own tools harmless and be believed. Zero accuracy gained, one + * fail-open introduced, so it is not read at all. + * + * `destructiveHint` is taken on presence of `true` only, never inverted. The specification gives it + * a default of true when a tool is not read-only, and applying that default would reclassify every + * unannotated action of every MCP vendor as a write — correct by the letter and a mass revocation + * of grants people already hold. An absent hint stays absent, which leaves the reviewed list + * deciding exactly as it did before, and only an explicit declaration narrows anything. + * + * A server that sets both hints is contradicting itself, and is read as destructive. The + * specification says `destructiveHint` is meaningless while `readOnlyHint` is true, but resolving + * an incoherent listing towards the permissive reading is the one direction that could hurt. + */ +function declaredEffect(annotations: ToolAnnotations | undefined) { + if (annotations?.destructiveHint !== true) return {}; + return { effect: "write", destructive: true } as const; +} + /** What this server says it offers, right now. */ -export async function listTools(connection: Connection): Promise { +export async function listTools(connection: Connection): Promise { return withClient(connection, async (client) => { const result = await client.listTools(undefined, { timeout: LIST_TIMEOUT_MS, @@ -284,6 +328,7 @@ export async function listTools(connection: Connection): Promise { name: tool.name, description: tool.description ?? "", inputSchema: (tool.inputSchema ?? {}) as Record, + ...declaredEffect(tool.annotations), })); }); } diff --git a/server/tests/mcp-listing.test.ts b/server/tests/mcp-listing.test.ts new file mode 100644 index 000000000..7afabceba --- /dev/null +++ b/server/tests/mcp-listing.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; +import { catalogueEntry, classifyTool } from "../src/plugins/catalogue"; +import { listTools } from "../src/plugins/mcp"; + +/** + * What an MCP listing carries out of the transport, and what the classifier then makes of it. + * + * WHY THIS SUITE EXISTS. The MCP specification lets a server publish `annotations.destructiveHint`, + * and `listTools` used to drop the whole `annotations` object on the floor. Nothing failed: every + * MCP-listed tool simply arrived with no effect, `refreshTools` recorded null, and `classifyTool` + * fell through to the reviewed write list. That looked correct — and it was, for every name a + * person had already reviewed. For a name the reviewed list did not happen to hold, a tool the + * vendor had explicitly declared destructive classified as a READ. Fail-open on a + * permission-adjacent decision, and invisible, because the only evidence was a field never read. + * + * These cases pin the transport and the classifier TOGETHER rather than separately. A unit test of + * `listTools` alone would prove a field is copied; a unit test of `classifyTool` alone would prove + * a string is honoured. Neither would have caught this, because the defect lived exactly in the + * join: the transport never produced the string the classifier was already willing to act on. So + * each case here goes over a real MCP connection and then through the same call `store.ts` makes, + * `classifyTool(entry, name, true, tool.effect ?? null)` — what `refreshTools` writes to the column + * and what the call path reads back out of it. + */ + +const mock = new MCPMock(); +let url = ""; + +/** + * A tool definition including the annotations the mock's own type does not declare. + * + * `MCPToolDefinition` names only `name`, `description` and `inputSchema`, but the mock stores the + * definition it is handed and serves it back verbatim, so annotations really do cross the wire. + * Widening the type here rather than casting keeps the fixture honest: `ToolAnnotations` is the + * SDK's own declaration, so a hint renamed upstream fails this file at compile time instead of + * silently ceasing to be served. + */ +type AnnotatedTool = MCPToolDefinition & { annotations?: ToolAnnotations }; + +/** Every tool needs one: the SDK rejects an entire listing that omits a single `inputSchema`. */ +const NO_ARGUMENTS = { type: "object", properties: {} } as const; + +/** + * Notion's real catalogue entry, not a fabricated one. + * + * The question this suite answers is what happens to the connector this deployment actually ships, + * so the reviewed write list under test has to be the shipped one. `notion-fetch` is on Notion's + * advertised listing and absent from `writeTools`; `notion-update-page` is on `writeTools`. Those + * two names are what make the narrowing and the no-widening cases meaningful. + */ +const notion = catalogueEntry("notion"); + +/** + * A destructive action absent from Notion's reviewed write list. + * + * Deliberately a name `writeTools` does not hold, because a name it DOES hold classifies as a write + * whatever the listing says — which would make this case pass without the transport carrying + * anything at all. + */ +const destructiveUnreviewed: AnnotatedTool = { + name: "notion-purge-workspace", + description: "Removes everything.", + inputSchema: NO_ARGUMENTS, + annotations: { destructiveHint: true }, +}; + +/** A read the vendor labels as one, which the reviewed list already classified as a read. */ +const readOnlyUnreviewed: AnnotatedTool = { + name: "notion-fetch", + description: "Reads a page.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A reviewed write that the vendor contradicts by calling it read-only. */ +const readOnlyButReviewedAsWrite: AnnotatedTool = { + name: "notion-update-page", + description: "The vendor claims this only reads.", + inputSchema: NO_ARGUMENTS, + annotations: { readOnlyHint: true }, +}; + +/** A tool with no annotations at all, which is what most MCP servers publish. */ +const unannotated: AnnotatedTool = { + name: "notion-search", + description: "Says nothing about what it does.", + inputSchema: NO_ARGUMENTS, +}; + +beforeAll(async () => { + mock + .addTool(destructiveUnreviewed) + .addTool(readOnlyUnreviewed) + .addTool(readOnlyButReviewedAsWrite) + .addTool(unannotated); + url = await mock.start(); +}); + +afterAll(async () => { + await mock.stop?.(); +}); + +/** + * The classification a refresh would commit for one listed tool. + * + * Written as `tool.effect ?? null` because that is literally what `refreshTools` inserts into + * `mcp_tools.effect`, and `classifyTool` distinguishes null from the empty string. Reproducing the + * `??` here rather than passing `tool.effect` through is the difference between testing the shipped + * path and testing a plausible one. + */ +const classify = ( + tools: Awaited>, + name: string, +) => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`the mock did not list ${name}`); + return classifyTool(notion, name, true, tool.effect ?? null); +}; + +describe("what an MCP listing tells the classifier", () => { + test("a tool the vendor declares destructive is a write", async () => { + /* + * The case the whole change exists for. Nothing in the reviewed list names + * `notion-purge-workspace`, so before the annotations were surfaced this returned "read" — a + * Bot with a read grant could have called it. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-purge-workspace")).toBe("write"); + }); + + test("a destructive tool is also carried as destructive, not only as a write", async () => { + // `effect` gates the call; `destructive` is what the confirmation card reads. A tool that + // arrived as a write with `destructive` false would be gated correctly and presented wrongly. + const tools = await listTools({ url }); + const purge = tools.find((tool) => tool.name === "notion-purge-workspace"); + + expect(purge?.destructive).toBe(true); + }); + + test("a vendor's read-only claim cannot take a reviewed write off the write list", async () => { + /* + * The direction the criterion forbids. `notion-update-page` is on the reviewed `writeTools`, + * and a server that says otherwise — whether mistakenly or because somebody stood up a server + * that says whatever it likes — must not be able to widen what a Bot may do. + */ + const tools = await listTools({ url }); + + expect(classify(tools, "notion-update-page")).toBe("write"); + }); + + test("a vendor's read-only claim records nothing at all", async () => { + /* + * WITHHELD ON PURPOSE, and pinned so the omission reads as a decision rather than as the same + * oversight being fixed. `readOnlyHint` can only ever move an action towards "read", which is + * the widening the classifier's ordering exists to prevent. For a catalogued vendor it would + * change nothing — an advertised name absent from `writeTools` is already a read — so it buys + * no accuracy; for a server an administrator added by URL there is no reviewed list at all, and + * honouring it would let that server declare its own tools harmless and be believed. The SDK + * says as much where it declares these hints: clients should never make tool use decisions + * based on annotations received from untrusted servers. Acting only on the hint that narrows is + * how that warning is honoured while a declared destructive tool still gets gated. + */ + const tools = await listTools({ url }); + const fetch = tools.find((tool) => tool.name === "notion-fetch"); + + expect(fetch?.effect).toBeUndefined(); + // And so the reviewed list still decides, exactly as it did before this change. + expect(classify(tools, "notion-fetch")).toBe("read"); + }); + + test("a tool with no annotations is unchanged in every respect", async () => { + // The overwhelmingly common case, and the one that says existing Notion grants survive: no + // annotations means nothing recorded, which means the reviewed list decides as it always did. + const tools = await listTools({ url }); + const search = tools.find((tool) => tool.name === "notion-search"); + + expect(search?.effect).toBeUndefined(); + expect(search?.destructive).toBeUndefined(); + expect(classify(tools, "notion-search")).toBe("read"); + }); +}); From d287c7ae5946b79235b674f77beb8fa041d03a1e Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:10 +0200 Subject: [PATCH 077/215] Walk the one keyword that was hiding a staged file from the listing filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `additionalProperties` is a full subschema in both `ParametersSchema` and `JSONSchemaPropertySchema` (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments. `stagesAFile` never descended it, so a `file_uploadable` parameter hidden there escaped the filter and the action was offered to a model under both auto-upload settings — a bucket key nobody here can issue, or a server-side path nobody should promise — and every call against it failed. The comment claiming the walked list was closed stated what BOUNDED it rather than what completed it, which is how the gap survived a reading. `patternProperties`, `not`, the conditional trio, `items` as a tuple and the comparison against `true` rather than truthiness were all walked already and asserted by nothing; each has a case now. Two assertions that could not fail go with it, because both were argued for in an earlier wave and neither held. The cap and page-size tests compared against the very constants under test: `MAX_RESULT_CHARS` 20,000 to 40,000 and `LISTING_LIMIT` 1000 to 20 were both applied to the modules and all 44 tests stayed green. And the "schema unaltered" test compared the answer to the same object reference the stub had handed over, so an in-place `delete` inside `listTools` passed it. The numbers are written out here now and read back from the modules in one test, and the schema is compared to a snapshot taken before the call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 20 ++- server/tests/composio-transport.test.ts | 185 ++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 16 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 985bc9d66..9446c0dff 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -269,11 +269,20 @@ function schemaNode(value: unknown): Record | null { * only the top level of `properties` would answer false for every ref-based schema, which is the * majority of the ones that carry a file. * - * The keys walked are every subschema-bearing keyword `ParametersSchema` and - * `JSONSchemaPropertySchema` keep — a key those two strip cannot be present to be walked, so the - * list is closed. It is wider than the vendor's predicate by `patternProperties`, `not` and the - * conditional trio, which that one skips: a file staged only under a condition is still a file - * this deployment cannot stage. + * WHAT BOUNDS THE LIST IS NOT WHAT COMPLETES IT, and this comment used to claim the second from the + * first. A keyword `ParametersSchema` and `JSONSchemaPropertySchema` strip cannot be present to be + * walked, so nothing outside those two needs a branch — but every subschema-bearing keyword inside + * them does, and `additionalProperties` had none. Both of them keep it as a FULL SUBSCHEMA + * (`src/types/tool.types.ts:154` and `:111`), which is how a toolkit spells a bag of attachments, so + * a file hidden there was offered to a model under both auto-upload settings and every call against + * the action failed. + * + * `additionalProperties` and `items` are unions rather than plain subschemas — the first with + * `boolean`, the second with a tuple array. The boolean arm falls out of {@link schemaNode} and the + * array arm is what the second loop's `Array.isArray` is for, so neither needs a case of its own. + * + * Wider than the vendor's predicate by `patternProperties`, `not` and the conditional trio, which + * that one skips: a file staged only under a condition is still a file this deployment cannot stage. */ function stagesAFile(schema: unknown): boolean { const node = schemaNode(schema); @@ -295,6 +304,7 @@ function stagesAFile(schema: unknown): boolean { "oneOf", "allOf", "items", + "additionalProperties", "not", "if", "then", diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 001394865..0e2a113ea 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -50,17 +50,24 @@ function answered( } /** - * What a capped answer ends with, and exactly how long a capped answer is. + * The two numbers this file reasons about, WRITTEN OUT rather than imported. * - * IMPORTED RATHER THAN RESTATED, and asserted as an equality rather than as an upper bound. The - * three cap tests below used to allow anything under 25,000 while `MAX_RESULT_CHARS` is 20,000, so - * the number they were named for was the one thing they did not pin: a regression that doubled the - * cap to 40,000 fails, but one that raised it to 24,000 — half again as much of somebody's context - * window spent by somebody else's server — passed. A bound with slack in it is a bound that has to - * be re-argued every time the constant moves, so there is no reason for it to have any. + * AN ASSERTION THAT IMPORTS THE CONSTANT IT IS ABOUT CANNOT FAIL WHEN THAT CONSTANT MOVES, because + * both sides move together. The previous version of this file replaced a loose bound with an + * equality against `MAX_RESULT_CHARS` itself and called the number pinned; it was not. Applied to + * the modules, `MAX_RESULT_CHARS` 20,000 → 40,000 and `LISTING_LIMIT` 1000 → 20 both left all 44 + * tests passing: the cap tests measured the answer against whatever the cap had just become, and + * the listing test asked for whatever page the module had just decided to ask for. + * + * So the literals live here, and one test below is the only place the imported constants are read. + * Changing either constant now reddens exactly that test, which is where the argument for the + * number belongs: 20,000 is how much of a model's context one tool result may spend, and 1000 is + * the vendor's stated page ceiling and therefore the whole listing. */ +const RESULT_CAP = 20_000; +const WHOLE_LISTING = 1000; const TRUNCATION_MARKER = "\n\n[truncated]"; -const CAPPED_LENGTH = MAX_RESULT_CHARS + TRUNCATION_MARKER.length; +const CAPPED_LENGTH = RESULT_CAP + TRUNCATION_MARKER.length; /** The nesting `vendorSentence` reaches through, with whatever the vendor left at the bottom of it. */ function nested(message: unknown): unknown { @@ -97,6 +104,45 @@ const GMAIL_READ = { version: "20260903_00", }; +/** A parameter that stages a file, in the shape `JSONSchemaPropertySchema` keeps it. */ +const FILE_PROPERTY = { type: "string", file_uploadable: true }; + +/** + * One subschema keyword carrying `sub`, hung off a property so the root stays a `ParametersSchema`. + * + * A COMPUTED KEY, for `then` and for nothing else: biome refuses a literal `then` key on an object + * literal, and the conditional trio has to be reachable here or the branch that walks it is being + * asserted by nothing. `if`, `then`, `else`, `items` and `$ref` live on `JSONSchemaPropertySchema` + * and not on the parameters root (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-131` against + * `:134-175`), so a case for one of them has to nest to be a shape the vendor could send. + */ +function underProperty(keyword: string, sub: unknown): Record { + return { + type: "object", + properties: { field: { type: "object", [keyword]: sub } }, + }; +} + +/** One action whose whole schema is the case under test, listed beside a plain one. */ +function listing(inputParameters: Record) { + return recording({ + listActions: async () => [ + GMAIL_READ, + { slug: "GMAIL_STAGES_A_FILE", version: "20260903_00", inputParameters }, + ], + }).client; +} + +describe("the numbers these assertions are about", () => { + test("the modules hold the numbers this file has written out", () => { + // The only reads of the imported constants in this file. Every other assertion measures + // against the literals above, so a constant that moves reddens this one test — which states + // the number — instead of quietly redefining what all the others are checking. + expect(MAX_RESULT_CHARS).toBe(RESULT_CAP); + expect(LISTING_LIMIT).toBe(WHOLE_LISTING); + }); +}); + describe("which app a connection names", () => { test("the app slug comes off the url", () => { expect(toolkitOf("composio://gmail")).toBe("gmail"); @@ -258,7 +304,7 @@ describe("listing an app's actions", () => { // nothing in the short answer says a filter was applied. Asking for a page is therefore not an // optimisation, and the seam must not let a caller forget to. expect(asked).toEqual([ - { toolkit: "gmail", page: { limit: LISTING_LIMIT } }, + { toolkit: "gmail", page: { limit: WHOLE_LISTING } }, ]); }); @@ -266,7 +312,7 @@ describe("listing an app's actions", () => { useComposioClient( recording({ listActions: async () => - Array.from({ length: LISTING_LIMIT }, (_unused, index) => ({ + Array.from({ length: WHOLE_LISTING }, (_unused, index) => ({ ...GMAIL_READ, slug: `GMAIL_ACTION_${index}`, })), @@ -382,6 +428,110 @@ describe("listing an app's actions", () => { expect(listed.map((tool) => tool.name)).toEqual(["GMAIL_FETCH_EMAILS"]); }); + test("a file parameter is found down every subschema keyword the SDK keeps", async () => { + /* + * ONE CASE PER KEYWORD, because a keyword the walk does not descend is an action offered to a + * model under BOTH auto-upload settings — the parameter is either a bucket key nobody here can + * issue or a server-side path nobody should promise — so every call against it fails. + * + * `additionalProperties` is the one that was missing, and it is not exotic: both + * `ParametersSchema` and `JSONSchemaPropertySchema` keep it as a full subschema + * (`@composio/core` 0.18.1, `src/types/tool.types.ts:154` and `:111`), which is precisely how a + * toolkit spells "a bag of attachments". The rest were already walked and asserted by nothing. + */ + const hidden: { where: string; schema: Record }[] = [ + { + where: "additionalProperties at the root", + schema: { type: "object", additionalProperties: FILE_PROPERTY }, + }, + { + where: "additionalProperties under a property", + schema: underProperty("additionalProperties", FILE_PROPERTY), + }, + { + where: "patternProperties at the root", + schema: { + type: "object", + patternProperties: { "^attachment_": FILE_PROPERTY }, + }, + }, + { + where: "patternProperties under a property", + schema: underProperty("patternProperties", { any: FILE_PROPERTY }), + }, + { + where: "not at the root", + schema: { type: "object", not: FILE_PROPERTY }, + }, + { where: "not", schema: underProperty("not", FILE_PROPERTY) }, + { where: "if", schema: underProperty("if", FILE_PROPERTY) }, + { where: "then", schema: underProperty("then", FILE_PROPERTY) }, + { where: "else", schema: underProperty("else", FILE_PROPERTY) }, + { where: "items", schema: underProperty("items", FILE_PROPERTY) }, + { + where: "items as a tuple", + schema: underProperty("items", [{ type: "string" }, FILE_PROPERTY]), + }, + { where: "oneOf", schema: underProperty("oneOf", [FILE_PROPERTY]) }, + { where: "allOf", schema: underProperty("allOf", [FILE_PROPERTY]) }, + { + where: "definitions at the root", + schema: { + type: "object", + properties: { body: { $ref: "#/definitions/upload" } }, + definitions: { upload: FILE_PROPERTY }, + }, + }, + ]; + + for (const { where, schema } of hidden) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + // The keyword is carried into the comparison so a failure names which one escaped. + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS"], + }); + } + }); + + test("an action is dropped only where the flag is actually set", async () => { + /* + * THE OTHER HALF OF THE WALK, which decides what stays offered. `file_uploadable` is + * `z.boolean().optional()` (`src/types/tool.types.ts:89`), so `false` is a value the vendor + * really sends and the comparison against `true` rather than against truthiness is what keeps + * it from dropping an action nobody has to stage anything for. `additionalProperties` is a + * union with `boolean` (`:111`, `:154`), so `true` and `false` arrive there as values and the + * walk has to read them as "not a subschema" instead of tripping over them. + */ + const offered: { where: string; schema: Record }[] = [ + { + where: "the flag is explicitly false", + schema: { + type: "object", + properties: { note: { type: "string", file_uploadable: false } }, + }, + }, + { + where: "additionalProperties is open", + schema: { type: "object", additionalProperties: true }, + }, + { + where: "additionalProperties is closed", + schema: { type: "object", additionalProperties: false }, + }, + ]; + + for (const { where, schema } of offered) { + useComposioClient(listing(schema)); + const listed = await listTools({ url: "composio://gmail" }); + expect({ where, offered: listed.map((tool) => tool.name) }).toEqual({ + where, + offered: ["GMAIL_FETCH_EMAILS", "GMAIL_STAGES_A_FILE"], + }); + } + }); + test("the schema a model is shown is the one the SDK handed over, unaltered", async () => { /* * A CHARACTERIZATION TEST, and it passed before the claim beside `inputParameters` was @@ -407,6 +557,18 @@ describe("listing an app's actions", () => { examples: [{ query: "is:unread" }], "x-openbot-probe": "kept", }; + /* + * SNAPSHOTTED BEFORE THE CALL, because comparing the answer to `schema` compares it to the very + * object the stub handed over. `toEqual` between two references to one object holds whatever + * happened in between, so an in-place `delete` inside `listTools` — a walk that pruned what it + * read — passed this test unchanged. Proven: one added `delete` of a root keyword in the map + * left all 44 tests green. + * + * The snapshot is what the SDK handed over. The first assertion is that a model is shown that; + * the second is that the vendor's own object still IS that, because a module returning a + * faithful copy while wrecking the original would corrupt every later reader of one listing. + */ + const asHandedOver = structuredClone(schema); useComposioClient( recording({ listActions: async () => [{ ...GMAIL_READ, inputParameters: schema }], @@ -414,7 +576,8 @@ describe("listing an app's actions", () => { ); const [tool] = await listTools({ url: "composio://gmail" }); - expect(tool?.inputSchema).toEqual(schema); + expect(tool?.inputSchema).toEqual(asHandedOver); + expect(schema).toEqual(asHandedOver); }); test("an action with no schema is still listed, with an open one", async () => { From 7f357e587146f5d230fbeb358c59b476446ddd95 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:36 +0200 Subject: [PATCH 078/215] Read the vendor's answer only where an unreadable one can still be reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways this transport mishandled what Composio said back. THE ANSWER WAS DEREFERENCED OUTSIDE ITS GUARD, at three sites. `actions.length` and `actions.filter` sit after the try that wraps `listActions`, and `reportedFailure(answer, …)` sits after the one that wraps `execute`. A client resolving null therefore propagated `null is not an object (evaluating 'actions.length')` into the row's `lastError` for an administrator to read, and threw a `TypeError` straight out of `callTool` — which this module documents as never throwing and `store.ts` relies on not throwing, because an exception ends a person's turn mid-run with nothing said and nothing audited. `ComposioActions` is our own projection and its adapter is unwritten, so a return type is not a promise about what resolves. Both shapes are settled now while a sentence can still be written, elements included: `[null]` is the same failure one level down. AN `error` BESIDE `successful: true` WAS DROPPED AND AUDITED AS A SUCCESS. `ToolExecuteResponseSchema` spells the two as independent required fields and `transformToolExecuteResponse` copies both off the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`), so the combination is a shape the vendor's own schema permits. Taking the sentence as a failure is the vendor's arithmetic rather than a house rule: where the SDK derives the flag it writes `successful: !response.error` (`:1247`). A WHITESPACE-ONLY VERSION WAS TRUTHY WHEN RECORDED AND EMPTY WHEN SENT, so it was written to `mcp_tools` as a version this deployment believed it held and was then permanently uncallable, with a refusal naming a refresh that records the same blank again. It is trimmed at both ends now. `vendorSentence` had the same split: it trimmed in its guard and returned the padded string. `listingSentence` DID NOT FILTER THE VENDOR'S PLACEHOLDER the way `callTool` does, so "Error executing the tool X" reached the Plugins page — where the reader had just asked to refresh that very app. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 106 ++++++++++++-- server/tests/composio-transport.test.ts | 184 ++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 9446c0dff..325c3b926 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -389,6 +389,35 @@ export async function listTools(connection: { throw new Error(listingSentence(toolkit, error), { cause: error }); } + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS A LIST, and the check is here rather than assumed + * from the type. `ComposioActions` is this module's own projection, the adapter that would + * satisfy it has not been written, and a return type is not a promise about what resolves at + * runtime — a client that answers null, or a bare envelope with the array one level down, is a + * mistake this file will meet before a type checker does. + * + * Both reads below sit OUTSIDE the try that wraps the vendor's call, so before this guard + * `actions.length` propagated `null is not an object (evaluating 'actions.length')` — which is + * the string `refreshTools` writes into the row's `lastError` for an administrator to read. What + * this path owes that reader is a sentence naming the app and saying nothing was lost, so the + * shape is settled while such a sentence can still be written. + * + * THE ELEMENTS ARE CHECKED TOO, AND FOR THE SAME REASON RATHER THAN A DIFFERENT ONE. `[null]` + * reaches `action.inputParameters` in the filter below and `action.slug` in the map, both + * outside the try; it is the identical failure one level down, so it is answered here rather + * than left to produce a different unreadable message. What is checked is only that each + * element is an object — this module does not validate the vendor's schema, and an action + * missing a field it does not have is the vendor's business. + */ + if ( + !Array.isArray(actions) || + actions.some((action) => schemaNode(action) === null) + ) { + throw new Error( + `Composio did not answer with an action list for ${toolkit}: what came back was not a list of actions at all. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); + } + if (actions.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. @@ -430,13 +459,22 @@ export async function listTools(connection: { .filter((action) => !stagesAFile(action.inputParameters)) .map((action) => { const { effect, destructive } = effectOf(action.tags); + /* + * TRIMMED HERE BECAUSE IT IS TRIMMED AT THE OTHER END. {@link callTool} trims the recorded + * version and refuses an empty one, so a whitespace-only string that counted as a version + * was written to `mcp_tools` as a version this deployment believes it holds and was then + * permanently uncallable — and the refusal its caller reads names a refresh, which records + * the same blank again. Recording exactly what `callTool` will send is what closes that + * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + */ + const version = action.version?.trim(); return { name: action.slug, description: action.description ?? "", inputSchema: action.inputParameters ?? {}, effect, destructive, - ...(action.version ? { version: action.version } : {}), + ...(version ? { version } : {}), }; }); } @@ -457,13 +495,20 @@ export async function listTools(connection: { * Null when there is no such sentence, which leaves the caller to choose a fallback rather than * inventing one here. That choice is not simply "the thrown message": the thrown message is often the * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. + * + * TRIMMED ON THE WAY OUT AND NOT ONLY IN THE GUARD. The two used to disagree — the guard measured a + * trimmed string and the return handed back the padded one — so the decision the function had + * already made about the string was thrown away at the last line. What comes out is read by a person + * off an admin page, put in front of a model, and measured by {@link cap}, and in the third of those + * the padding is counted against somebody's context window. */ export function vendorSentence(error: unknown): string | null { const cause = (error as { cause?: unknown } | null | undefined)?.cause; const outer = (cause as { error?: unknown } | null | undefined)?.error; const inner = (outer as { error?: unknown } | null | undefined)?.error; const message = (inner as { message?: unknown } | null | undefined)?.message; - return typeof message === "string" && message.trim() !== "" ? message : null; + const sentence = typeof message === "string" ? message.trim() : ""; + return sentence === "" ? null : sentence; } /** @@ -509,6 +554,11 @@ function isSchemaMismatch(error: unknown): boolean { * The schema case names the fix, because it is a vendor change rather than a misconfiguration: the * answer arrived and this deployment's copy of their SDK would not accept it, so nothing an * administrator can do to this row will help and upgrading the package will. + * + * THE PLACEHOLDER IS REFUSED HERE ON THE SAME GROUNDS {@link callTool} REFUSES IT, which is the + * half this function was missing. "Error executing the tool X" names only the thing the reader + * asked for; on this path they asked to refresh an app, so it is the one fact they already had. + * Falling through to the app's name at least tells them which row went wrong. */ function listingSentence(toolkit: string, error: unknown): string { if (isSchemaMismatch(error)) { @@ -517,7 +567,7 @@ function listingSentence(toolkit: string, error: unknown): string { const thrown = error instanceof Error ? error.message.trim() : ""; return ( vendorSentence(error) ?? - (thrown === "" + (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) ? `Composio did not answer with an action list for ${toolkit}.` : thrown) ); @@ -582,12 +632,30 @@ function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { } /** - * What the vendor said about its own call, read from the field its schema requires it to send. - * - * The criterion is that the vendor SAID the call did not succeed, which is `successful === false` and - * not a falsy `successful`. An absent field is not the vendor reporting a failure — the schema makes - * it impossible from the real client, and reading it as a failure would turn a projection looser than - * the schema into a refusal of a call that worked. + * What the vendor said about its own call, read from BOTH fields its schema requires it to send. + * + * EITHER ONE CAN REPORT A FAILURE, and reading only the flag dropped the other. `successful === + * false` is the plain case. The second is an `error` sentence arriving beside `successful: true`: + * `ToolExecuteResponseSchema` spells the two as independent required fields and correlates them + * nowhere, and `transformToolExecuteResponse` copies both straight off the wire (`@composio/core` + * 0.18.1, `src/models/Tools.ts:215-222`), so that combination is a shape the vendor's own schema + * permits. Keyed on the flag alone it was audited as `mcp.call_succeeded` and the one sentence + * saying what went wrong was shown to nobody. + * + * TAKING THE ERROR AT ITS WORD IS THE VENDOR'S OWN ARITHMETIC rather than a rule invented here: + * where the SDK has to derive the flag itself it writes `successful: !response.error` (`:1247`). It + * is also the reading this file already applies to a vendor contradicting itself — see + * {@link effectOf} on `destructiveHint` beside `readOnlyHint`. + * + * WHICH IS EQUALLY WHY THE CRITERION IS A NON-EMPTY SENTENCE. By that same line `""` is a success, + * so an empty `error` is the vendor saying nothing went wrong in the least committal way open to it. + * Whitespace is read as empty too, and that part is this file's own reading rather than the SDK's — + * it matches {@link vendorSentence}, because a blank sentence beside an explicit `successful: true` + * would otherwise become a refusal saying only that the call failed and nobody said why. + * + * AND `successful !== false` RATHER THAN A FALSY `successful`, because an absent field is not the + * vendor reporting anything: the schema makes it impossible from the real client, and reading it as + * a failure would turn a projection looser than the schema into a refusal of a call that worked. * * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". */ @@ -595,8 +663,8 @@ function reportedFailure( answer: ComposioResult, toolName: string, ): string | null { - if (answer.successful !== false) return null; const sentence = typeof answer.error === "string" ? answer.error.trim() : ""; + if (answer.successful !== false && sentence === "") return null; return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? unexplained(toolName) : sentence; @@ -686,6 +754,24 @@ export async function callTool( ); } + /* + * NOTHING IS READ OFF THE ANSWER UNTIL IT IS AN ENVELOPE, and here the reason is stronger than + * the listing's. {@link callTool} is documented as never throwing and `store.ts` relies on that, + * so a shape this module did not expect has to become a refusal rather than an exception. + * `reportedFailure` reads `answer.successful` and was called from outside every try, so a client + * resolving null threw a `TypeError` straight out of here — ending a person's turn mid-run with + * nothing said and nothing audited, which is exactly what returning a result instead of throwing + * exists to prevent. + * + * A vendor fault it is not, so it does not get the vendor's words. This is the third kind of + * failure the comment above names: Composio answered and this deployment could not read it. + */ + if (typeof answer !== "object" || answer === null) { + return failure( + `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: it was not the { data, error, successful } envelope Composio's own schema requires.`, + ); + } + const reported = reportedFailure(answer, toolName); if (reported !== null) return failure(reported); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 0e2a113ea..96bc6db1c 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { + type ComposioAction, type ComposioActions, + type ComposioResult, callTool, effectOf, LISTING_LIMIT, @@ -269,6 +271,16 @@ describe("finding the vendor's own sentence", () => { expect(vendorSentence(nested("\n\t "))).toBeNull(); }); + test("the sentence comes back as it was measured, without its padding", () => { + // The guard trimmed and the return did not, so the one thing the function had already decided + // about the string was thrown away again. What comes out is a refusal in a model's context and + // a sentence in an audit row; leading newlines in both are this module's own untidiness, and + // the cap that measures the string measures the padding with it. + expect(vendorSentence(nested(" Gmail rejected the query.\n"))).toBe( + "Gmail rejected the query.", + ); + }); + test("a message that is not a string is not read as one", () => { // Nothing asserted the type guard either. Composio's payloads are somebody else's JSON, so the // field can be a number, an object or null; handed on unchecked, each of those reaches a model's @@ -727,6 +739,88 @@ describe("listing an app's actions", () => { expect(message).toContain("gmail"); } }); + + test("a listing failure carrying only the vendor's placeholder says something else", async () => { + /* + * `callTool` already refuses to pass "Error executing the tool X" on, and the listing path did + * not. Same string, same reader: `refreshTools` writes this sentence into the row's + * `lastError` and an administrator reads it off the Plugins page, where the name of the thing + * they asked to refresh is the one fact they already have. + */ + useComposioClient( + recording({ + listActions: async () => { + throw new Error("Error executing the tool GMAIL_FETCH_EMAILS"); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).not.toMatch(/error executing the tool/i); + expect(message).toContain("gmail"); + }); + + test("an answer that is not a list of actions throws a sentence, not a TypeError", async () => { + /* + * `ComposioActions` is OUR projection of the vendor, implemented by an adapter nobody has + * written yet, and TypeScript polices none of what a promise actually resolves to at runtime. + * A client that answers `null` — a 204, an SDK path that returns before assigning, a mock in + * somebody's staging deployment — used to reach `actions.length` and `actions.filter` outside + * the try that wraps the vendor's call, so what propagated was `null is not an object`. That + * lands verbatim in `lastError` on the Plugins page and tells an administrator nothing about + * which app or what to do, which is the whole reason this path throws sentences. + */ + // `[null]` is the same failure one level down: it clears `Array.isArray` and then reaches + // `action.inputParameters` in the filter, which is outside that try as well. + for (const shape of [null, undefined, { items: [] }, "gmail", [null]]) { + useComposioClient( + recording({ + listActions: async () => shape as unknown as ComposioAction[], + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toContain("gmail"); + expect(message).not.toMatch(/is not an object|is not a function/i); + } + }); + + test("a version made only of whitespace is recorded as no version at all", async () => { + /* + * TRIMMED ON THE WAY IN BECAUSE IT IS TRIMMED ON THE WAY OUT. `callTool` trims the recorded + * version and refuses an empty one, so a blank string that counts as a version here is written + * to `mcp_tools` as a version this deployment believes it has and is then permanently + * unusable — and the refusal the caller gets names a refresh, which rewrites the same blank. + * That is exactly the loop the test above reasons about, reached by recording rather than by + * the vendor publishing nothing. + */ + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_BLANK", tags: ["readOnlyHint"], version: " " }, + { + slug: "GMAIL_PADDED", + tags: ["readOnlyHint"], + version: " 20260903_00\n", + }, + ], + }).client, + ); + + const [blank, padded] = await listTools({ url: "composio://gmail" }); + + expect(Object.keys(blank ?? {})).not.toContain("version"); + // Recorded as the version `callTool` will actually send, rather than as one it has to repair. + expect(padded?.version).toBe("20260903_00"); + }); }); describe("calling one action", () => { @@ -1140,4 +1234,94 @@ describe("calling one action", () => { expect(result.text).toMatch(/could not turn that answer into text/i); expect(result.text).toContain("GMAIL_FETCH_EMAILS"); }); + + test("an answer reporting an error while claiming success is a failure", async () => { + /* + * `ToolExecuteResponseSchema` spells `error` and `successful` as two independent required + * fields and correlates them nowhere; `transformToolExecuteResponse` copies both straight off + * the wire (`@composio/core` 0.18.1, `src/models/Tools.ts:215-222`). So the combination is a + * shape the vendor's own schema permits, and keying only on `successful === false` dropped the + * one sentence in it that says anything — audited as `mcp.call_succeeded`, with the failure + * handed to the model as though it were content. + * + * The strict reading is the safe one and it is also the vendor's: where the SDK has to derive + * the flag itself it writes `successful: !response.error` (`src/models/Tools.ts:1247`), so a + * present error IS a failure by their own arithmetic. Same rule as `effectOf` uses for + * contradictory labels — both at once is somebody else's bug, and we take the strict branch. + */ + useComposioClient( + recording({ + execute: async () => + answered( + { messages: [{ id: "m1" }] }, + { + successful: true, + error: "Gmail rejected the query: invalid search syntax.", + }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + // The data must not be handed over as content beside a reported failure. + expect(result.text).not.toContain("m1"); + }); + + test("an empty error beside a success is still a success", async () => { + // The other side of the rule, and the reason it is worded as a SENTENCE rather than as a + // present field: `successful: !response.error` treats `""` as success, so an empty string is + // the vendor saying nothing went wrong in the least committal way available to it. + useComposioClient( + recording({ + execute: async () => + answered({ messages: [] }, { successful: true, error: "" }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(false); + expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); + }); + + test("an answer that is not an envelope refuses rather than throwing", async () => { + /* + * THE NEVER-THROW CONTRACT, asserted against the shape that broke it. This module documents a + * failure as a RESULT and `store.ts` relies on it: a model is mid-run with a person waiting, + * and an exception ends the turn with nothing said and nothing audited. + * + * `reportedFailure(answer, …)` read `answer.successful` outside every try, so a client + * resolving `null` threw a `TypeError` straight out of `callTool`. Like the listing case, this + * is a shape `ToolExecuteResponseSchema` forbids and `ComposioActions` cannot police — the + * projection is ours, the adapter is unwritten, and a runtime resolution is not a type. + */ + for (const shape of [null, undefined, "ok", 7]) { + useComposioClient( + recording({ + execute: async () => shape as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toMatch(/is not an object|undefined is not/i); + } + }); }); From d1afeba50153e266fa0d943c062bf334f5aaadd6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:28:53 +0200 Subject: [PATCH 079/215] Send the app the url names, so the gate and the call are about one fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toolkitOf`'s answer was validated and then discarded: `execute` received the action slug alone, so the app a brokered call ran against was implied by the slug a LISTING recorded, while the person's connection had been gated on the app the url names NOW. A url edited between a refresh and a call was therefore gated on one app and run against another — somebody's Slack connection satisfying the gate for a Gmail action that still runs in their Gmail. That is the branch's central guarantee, and a check performed and dropped is not one. The app is an argument of the call now, in a named record rather than four positional strings so a transposition cannot pass silently. It is asserted to FOLLOW the url and not merely to be present, which is what a discarded check could never show. THE VENDOR'S WIRE GENUINELY CANNOT CARRY IT, so the obligation is written into the projection where an implementation has to meet it. `ToolExecuteParams` has no toolkit field and the client's method takes the slug alone — `execute(toolSlug, params, options)` over `arguments`, `user_id`, `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:41`, `:480-532`) — and the core SDK sends exactly that (`@composio/core` 0.18.1, `src/models/Tools.ts:1013`). What an implementation can do is refuse a mismatch, and it has what it needs to: `tools.execute` already resolves the tool by slug before running it (`:1163`, resolver at `:693`) and the resolved tool carries the app the vendor will run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). Disagreement is required to throw, which `callTool` already turns into a refusal with a sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 49 ++++++++++++++++++++-- server/tests/composio-transport.test.ts | 54 ++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 325c3b926..6f250a1ab 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -147,10 +147,42 @@ export type ComposioActions = { toolkit: string, page: { limit: number }, ): Promise; + /** + * One action, of one app, as one person, at one version. + * + * THE APP IS PART OF THE CALL AND NOT A CHECK BESIDE IT, which is the whole reason this takes a + * named record rather than four strings. {@link callTool} resolves the app from the connection's + * url and the brokered gate in `./access` looks a person's `composio_connections` row up by that + * same name — and then the call used to go out as the slug alone. A slug is what a LISTING + * recorded, so a url edited between a refresh and a call was gated on the app it names NOW and + * run against the app it named THEN: somebody's Slack connection satisfying the gate for a Gmail + * action that still runs in their Gmail. The gate and the call have to be about one fact. + * + * THE VENDOR'S WIRE CANNOT CARRY THE PAIR, so the obligation is written down here instead. The + * REST parameters have no toolkit field and the client's method takes the slug alone — + * `execute(toolSlug, params, options)` with `ToolExecuteParams` of `arguments`, `user_id`, + * `version` and connection overrides (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:41` and `:480-532`) — and the core SDK sends exactly that, + * `clientWithoutRetries.tools.execute(tool.slug, executeBody)` (`@composio/core` 0.18.1, + * `src/models/Tools.ts:1013`). + * + * SO AN IMPLEMENTATION MUST REFUSE A MISMATCH RATHER THAN FORWARD ONE, and it has what it needs + * to. `tools.execute` already resolves the tool by slug before running it + * (`src/models/Tools.ts:1163`, resolver at `:693`), and the resolved tool carries the app the + * vendor will actually run it against as `Tool.toolkit.slug` (`src/types/tool.types.ts:189`). + * Where that disagrees with `call.toolkit`, an implementation is required to throw instead of + * executing — which {@link callTool} already turns into a refusal with a sentence, because a + * throw out of here is the vendor-reported failure it is written to catch. + */ execute( - slug: string, - userId: string, - version: string, + call: { + /** The app the connection's url names, resolved by {@link toolkitOf} at call time. */ + toolkit: string; + slug: string; + /** Never from `args`. See the module comment. */ + userId: string; + version: string; + }, args: Record, ): Promise; }; @@ -681,6 +713,12 @@ function reportedFailure( * mid-run with a person waiting; an exception ends the turn with nothing said, and the refusal is in * the audit trail either way. * + * THE APP THIS CALL RUNS AGAINST IS THE ONE THE URL NAMES RIGHT NOW, and it goes out WITH the call + * rather than being checked beside it. `toolkitOf`'s answer used to be validated and then dropped, + * which left the brokered gate and the vendor's call resting on two different facts — the app the + * url names today, and the app whose listing recorded the slug. See {@link ComposioActions.execute} + * for why the pair has to travel together and what an implementation owes it. + * * THREE KINDS OF FAILURE, all of them `isError: true` and each with its own sentence, because * `store.ts` records that sentence beside the audit row: this transport refused before dialling, the * vendor reported a failure — by throwing, or in the `successful` field of a 200 answer — or the @@ -742,7 +780,10 @@ export async function callTool( */ let answer: ComposioResult; try { - answer = await installed.execute(toolName, userId, version, rest); + answer = await installed.execute( + { toolkit, slug: toolName, userId, version }, + rest, + ); } catch (error) { // The vendor's own sentence when there is one, because a generic message costs a diagnosis. const thrown = error instanceof Error ? error.message.trim() : ""; diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 96bc6db1c..72409569d 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -30,7 +30,12 @@ import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; afterEach(() => useComposioClient(null)); -type Recorded = { slug: string; userId: string; version: string }; +type Recorded = { + toolkit: string; + slug: string; + userId: string; + version: string; +}; /** * An answer in the shape `ToolExecuteResponseSchema` actually permits. @@ -87,8 +92,8 @@ function recording(answers: Partial = {}): { listActions: answers.listActions ?? (async () => []), execute: answers.execute ?? - (async (slug, userId, version) => { - calls.push({ slug, userId, version }); + (async (call) => { + calls.push({ ...call }); return answered({ ok: true }); }), }, @@ -836,6 +841,7 @@ describe("calling one action", () => { expect(calls).toEqual([ { + toolkit: "gmail", slug: "GMAIL_FETCH_EMAILS", userId: "user_asker", version: "20260903_00", @@ -844,24 +850,61 @@ describe("calling one action", () => { expect(result.isError).toBe(false); }); + test("the app goes out with the call, and follows the url when the url changes", async () => { + /* + * THE DEEPEST HOLE THIS TRANSPORT HAD. `toolkitOf` resolved the app, `accessFor` gated the + * person's `composio_connections` row on it, and then the call went out as the slug alone — + * and a slug is what a LISTING recorded, not what the url says now. A url edited between a + * refresh and a call was therefore gated on the app it names today and run against the app it + * named when the tools were last read: a person who connected Slack satisfying the gate for a + * Gmail action that still runs in their Gmail. + * + * Composio's wire cannot carry the pair — `ToolExecuteParams` has no toolkit field and + * `tools.execute(toolSlug, params)` takes the slug alone (`@composio/client` 0.1.0-alpha.76, + * `resources/tools.d.ts:480-493` and `:41`) — so what binds them here is that the app is an + * argument of the call this module makes and an implementation has to reconcile it with the + * tool it resolves. Asserting it is passed asserts the implementation was handed the fact it + * needs; asserting it FOLLOWS the url is the part a check performed and then discarded could + * never show, and discarding it was the defect. + */ + const { client, calls } = recording(); + useComposioClient(client); + + for (const app of ["gmail", "slack"]) { + await callTool( + { url: `composio://${app}`, actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + } + + expect(calls.map((call) => call.toolkit)).toEqual(["gmail", "slack"]); + }); + test("the version is not passed on to the vendor as an argument", async () => { const seen: Record[] = []; useComposioClient( recording({ - execute: async (_slug, _userId, _version, args) => { + execute: async (_call, args) => { seen.push(args); return answered({}); }, }).client, ); + // Held in a variable rather than written inline, because `seen` showing the version absent + // shows it only of whatever object the module chose to pass on. Deleting the key from the + // CALLER'S object and forwarding that satisfies the assertion below while destroying the + // record the call path still holds — the same identity-for-value mistake the schema test had. + const args = { query: "is:unread", __version: "20260903_00" }; await callTool( { url: "composio://gmail", actorId: "user_asker" }, "GMAIL_FETCH_EMAILS", - { query: "is:unread", __version: "20260903_00" }, + args, ); expect(seen).toEqual([{ query: "is:unread" }]); + expect(args).toEqual({ query: "is:unread", __version: "20260903_00" }); }); test("a call with no recorded version refuses rather than guessing one", async () => { @@ -909,6 +952,7 @@ describe("calling one action", () => { // and the only structural defence is that the argument name is never read. expect(calls).toEqual([ { + toolkit: "gmail", slug: "GMAIL_FETCH_EMAILS", userId: "user_asker", version: "20260903_00", From 8cea54bf41b73bb548544adece1a3b5bf5484d6f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:20:11 +0200 Subject: [PATCH 080/215] Key the anonymous connection row on its app, so no file sweeps another's `composio_connections.user_id` is notNull and notNull does not exclude the empty string, so `(app, "")` is a legal row and two suites write one. Neither owned it. `plugin-store.integration.test.ts` refused to run on any `""` row whatever app it named, and deleted every `""` row whatever app it named; `composio-connections.test.ts` writes one against its own run-suffixed app. A run of the second killed before its cleanup therefore stranded a row that made all 81 tests in the first refuse permanently, and the two running together had the first deleting the second's fixture mid-test. Both now key on the PAIR. The guard refuses on `("gmail", "")` and nothing else, the cleanup deletes `("gmail", "")` and nothing else, and the same pair joins the sweep in `freshDatabase` so a row stranded earlier in a run is gone before the next test looks. A witness row at a suffixed app, inserted and removed by the test itself, is what fails if either delete widens again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 34 +++++ server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++--- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 59881107c..33c274dbb 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -313,6 +313,13 @@ test("retiring the same person twice retires nothing the second time", async () * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an * unattributed offboarding reaching a row it cannot possibly own. + * + * WHOSE ROW THIS IS, since the actor half of the key names nobody. The app half does: {@link + * toolkit} carries this run's suffix, so the sweep in `clean` takes this row by the same clause it + * takes the asker's by, and no other file can arrive at the pair by guessing. That is the whole of + * the ownership — a delete keyed on `user_id = ''` alone would reach every app's anonymous row at + * once, which is how this fixture came to be removed mid-run by another file, and how a run of + * this file that died before its cleanup came to refuse every test in that one. */ test("retiring nobody retires nothing and leaves the anonymous row alone", async () => { await seedApp({ connect: false }); @@ -322,6 +329,33 @@ test("retiring nobody retires nothing and leaves the anonymous row alone", async expect(await connectedToolkitsFor("")).toEqual([toolkit]); }); +/** + * The fixture above is taken back by the same sweep every other row here is, and by nothing wider. + * + * CRITERION. After the sweep, this run holds no `composio_connections` row at all — the one at the + * anonymous actor included, which none of the person ids that sweep names would reach. + * + * REASON. Brokered connections are removed here by toolkit, so the anonymous row is already + * covered and needs no second, broader delete to reach it. Asserted rather than read off the code, + * because the tempting spelling for "take the anonymous row too" is `user_id = ''`, which is every + * app at once: the sweep that lands on another file's fixture. A test that reddens the moment this + * file needs a wider delete is what keeps that spelling out. + */ +test("the sweep takes this run's anonymous row without reaching by actor", async () => { + await seedApp({ connect: false }); + await database.insert(composioConnections).values({ toolkit, userId: "" }); + expect(await connectedToolkitsFor("")).toEqual([toolkit]); + + await clean(); + + expect( + await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)), + ).toEqual([]); +}); + /** * REMOVING THE APP. The second act that has to end a brokered connection. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b8183aafa..0656349e7 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,7 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; -import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -265,20 +265,35 @@ beforeAll(async () => { .from(agents) .where(eq(agents.id, "bot_helper")), /* - * The anonymous actor is one of these ids too. `composio_connections.user_id` is notNull and - * notNull does not exclude the empty string, so `("gmail", "")` is a row a deployment can - * legally hold — which is the whole point of the test that inserts one — and the delete that - * takes it back again runs inline, outside `freshDatabase` and outside every other sweep. + * The anonymous actor is one of these ids too, but only at the app this file writes it + * against. + * + * CRITERION. This guard refuses on the PAIR `("gmail", "")` and on no other row at the + * anonymous actor, because that pair is the only one this file inserts and the only one it + * deletes. + * + * REASON. `composio_connections.user_id` is notNull and notNull does not exclude the empty + * string, so `("gmail", "")` is a row a deployment can legally hold — which is the whole + * point of the test that inserts one. Asked as `user_id = ''` alone, this also caught the + * anonymous row `composio-connections.test.ts` writes against its OWN run-suffixed app: a + * run of that file killed between its insert and its cleanup left a row nobody here owns, + * and every one of this file's tests then refused for good over it. Keyed on the pair, a + * stranded row belonging to another file is simply not this one's business. */ database - .select({ userId: composioConnections.userId }) + .select({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) .from(composioConnections) .where( - inArray(composioConnections.userId, [ - "user_asker", - "user_leaver", - "", - ]), + or( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ), ), /* * The person, who was missing from this guard entirely. @@ -297,7 +312,8 @@ beforeAll(async () => { ...configuredServers.map((row) => `the mcp_servers row '${row.id}'`), ...existingBots.map((row) => `the Bot '${row.id}'`), ...existingConnections.map( - (row) => `the composio_connections row for '${row.userId}'`, + (row) => + `the composio_connections row ('${row.toolkit}', '${row.userId}')`, ), ...existingPeople.map((row) => `the person '${row.id}'`), ]; @@ -3554,9 +3570,28 @@ async function freshDatabase(): Promise { // first test below is about, so nothing else removes these rows — and a delete by toolkit alone // would take every person's Gmail connection, leaving one orphaned at the broker with no local row // left to find it by. Only the two people this file invents. - await database - .delete(composioConnections) - .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + await database.delete(composioConnections).where( + or( + inArray(composioConnections.userId, ["user_asker", "user_leaver"]), + /* + * And the one pair at the anonymous actor this file writes, which no person id names. + * + * CRITERION. Exactly `("gmail", "")`, never `user_id = ''` across every app: the second + * spelling reaches the anonymous row another file owns at its own run-suffixed app. + * + * REASON. The test that inserts this pair takes it back in a `finally`, which covers a + * failed assertion and not a killed process — and the row it would otherwise leave is + * precisely what the guard at the top of this file refuses on. Swept here, a pair stranded + * earlier in this same run is gone before the next test looks at it; a pair that was + * already there when the run started is still the guard's to refuse, because at that point + * nothing has established it is ours. + */ + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ), + ); // The person the connection outlives, who is a row in `users` like anybody else. Reached only // through the check at the top of this function, because there is no suffix on this id to tell a // fixture apart from somebody's account and ten cascades sit behind the difference. @@ -3818,6 +3853,22 @@ test("a Composio call with nobody attributed is refused even when a connection r await database .insert(composioConnections) .values({ toolkit: "gmail", userId: "" }); + /* + * A second anonymous row, at an app this file has nothing to do with. + * + * CRITERION. Whatever removes the row above must leave this one exactly where it is. + * + * REASON. The cleanup below used to be `user_id = ''`, which is every app at once. That reached + * the anonymous row `composio-connections.test.ts` writes against its own run-suffixed app — + * deleting another file's fixture out from under it when the two run together — and it is the + * other half of the same confusion the guard at the top of this file suffered from. Suffixed, so + * this row is provably this run's to insert and to take away again, and so no real deployment + * row can be what the assertion below is reading. + */ + const unrelatedApp = `sweep_witness_${suite}`; + await database + .insert(composioConnections) + .values({ toolkit: unrelatedApp, userId: "" }); try { await expect( @@ -3830,12 +3881,44 @@ test("a Composio call with nobody attributed is refused even when a connection r ).rejects.toThrow(/not attributed to anybody/i); expect(reached).toEqual([]); + + // Here rather than only in `freshDatabase`, so the row is gone the moment this test is done + // with it and the assertion below has something to read. Keyed on the PAIR either way: the app + // is what makes this row this file's, and the anonymous actor on its own names nobody's. + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); + + // What the cleanup took, and what it did not. Asked as two facts about this list rather than + // as the whole of it, deliberately: a third app's anonymous row is somebody else's business, + // and a test that failed because one existed would be the same over-reach in assertion form. + const anonymous = ( + await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "")) + ).map((row) => row.toolkit); + expect(anonymous).not.toContain("gmail"); + expect(anonymous).toContain(unrelatedApp); } finally { - // Inline, because `freshDatabase` clears this table by the two people this file invents and the - // anonymous actor is neither — so nothing else in the run would ever remove this row. + // Both, so a failed assertion above still leaves the table as this test found it. Each is keyed + // on an app this run named, which is what makes the deletes this run's to make. + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, unrelatedApp)); await database .delete(composioConnections) - .where(eq(composioConnections.userId, "")); + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, ""), + ), + ); } }); From ffb2fee2a6639db1928871fc3beba0b4aa4eba45 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:21:58 +0200 Subject: [PATCH 081/215] Cover the empty-listing guard through a vendor that actually answers nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 added the guard that refuses to commit an empty listing over actions already held; wave 2 then made the Composio transport throw rather than answer `[]`, which is correct and routed wave 1's three tests around the guard into the vendor `catch` several lines earlier. `if (listed.length === 0)` could be replaced with `if (false)` and all 89 tests stayed green: only the "commit when nothing is held" half was covered, and the data-loss half was not. A second describe drives the guard through a stub that answers `[]` — a vendor's own answer, which no throw can stand in for — and asserts the three things the guard promises: the held action survives with its `version`, the row says the actions were kept and takes no refresh stamp, and no grant is withdrawn or filed as unadvertised. All three redden under `if (false)`. The describe above it stated as its premise that the transport answers `[]` rather than throwing, which stopped being true; it is retitled and rewritten to what it actually covers. Its "not cleared" assertion read `not.toBeNull()` against a `lastError` the refresh had overwritten, so it passed on a different string than the one it was about; it now names the transport's own sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 153 ++++++++++++++++-- 1 file changed, 141 insertions(+), 12 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 0656349e7..cc1e3e2dd 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4657,19 +4657,25 @@ test("a granted Composio action that the vendor withdrew is still shown as grant }); /* - * A refresh that listed nothing, on the path every real deployment takes. + * A refresh the transport REFUSED, on the path every real deployment takes. * * NOTHING IN THE SHIPPED PRODUCT CALLS `useComposioClient`, so `installed` is null on every live - * install and the Composio transport answers `[]` — not a throw — for want of a client to ask with. - * The tests below install no stub, which is that state exactly rather than a fiction about it. + * install and `composio.listTools` throws for want of a client to ask with. The tests below install + * no stub, which is that state exactly rather than a fiction about it. * - * A wholesale replace on that answer deletes what the last real listing recorded, and `version` is - * the column that cannot be reconstructed: `callTool` refuses without it, so an app whose actions - * were dropped this way stops working for everybody until somebody presses Refresh on a deployment - * that can actually reach the vendor. The grants survive the delete pointing at rows that no longer - * exist, which is invisible on the Plugins page and revived by the next refresh that works. + * THE PREMISE THIS DESCRIBE USED TO STATE — that the transport answers `[]` rather than throwing — + * WAS TRUE AND IS NOT. It was corrected at the seam, deliberately: a transport that could not ask + * anybody must throw, because an empty answer is indistinguishable from an app that genuinely + * publishes nothing. These three cases therefore land in `refreshTools`'s vendor `catch`, which + * records the sentence and returns before the replace — and they say nothing whatever about the + * empty-listing guard below it, which is what the describe after this one is for. Two suites + * asserting the same three outcomes through different branches is how that guard came to be + * deletable with every test still green. + * + * What is under test here is the catch branch's own promise: a listing that could not be made + * leaves every recorded action where it is, stamps no refresh, and withdraws nothing. */ -describe("a refresh whose transport could not ask anybody", () => { +describe("a refresh whose transport refused to ask anybody", () => { test("leaves the actions the app already advertises, with what the vendor said about them", async () => { const { store, database } = await freshStore(); await seedComposioGmail(database, store); @@ -4710,9 +4716,18 @@ describe("a refresh whose transport could not ask anybody", () => { .from(mcpServers) .where(eq(mcpServers.id, "gmail")); - // Not cleared. An empty answer from a transport that reached no vendor is not evidence that - // whatever was wrong before has been put right. - expect(row?.lastError).not.toBeNull(); + /* + * The transport's own sentence, named rather than merely counted as present. + * + * `not.toBeNull()` was what this asserted, and the refresh had in fact OVERWRITTEN the value + * the test set up two lines earlier — so "not cleared" passed on a different string than the + * one it was about, and would have gone on passing had the column been filled with anything at + * all, the empty-listing guard's sentence included. Which sentence is here is the whole + * difference between "nobody could be asked" and "the app was asked and offers nothing", and + * those send an operator to different places. + */ + expect(row?.lastError).toContain("Composio is not configured"); + expect(row?.lastError).not.toBe("The vendor would not answer."); // And no refresh stamp, because nothing was listed: the column says when this deployment last // learned what the app offers, and it did not learn it here. expect(row?.toolsRefreshedAt).toBeNull(); @@ -4769,6 +4784,120 @@ describe("a refresh whose transport could not ask anybody", () => { }); }); +/** + * THE VENDOR ITSELF ANSWERING NOTHING, which is the state the empty-listing guard exists for. + * + * WHAT THIS COVERS THAT NOTHING ELSE DOES. The guard sits after the vendor `catch` and before the + * wholesale replace, and only a listing that was actually MADE and came back empty reaches it. The + * describe above cannot: its transport throws, so it returns from the catch several lines earlier. + * With those three cases routed around it, `if (listed.length === 0)` could be replaced by + * `if (false)` — deleting the guard outright — and the whole suite stayed green. Everything below + * reddens under that mutation, which is the only thing that makes the guard's presence a fact + * about this codebase rather than a comment in it. + * + * WHY IT MATTERS. The replace is a delete and an insert, so committing an empty answer deletes + * every `mcp_tools` row for the app and takes `effect`, `destructive` and `version` with it. + * `version` cannot be reconstructed — `callTool` refuses an action without one — so a refresh that + * reported success broke every later call, with the grants left pointing at rows that no longer + * exist. A stub that answers `[]` is a vendor's honest answer and is exactly what an app that has + * been emptied at the broker looks like; keeping what is held is the only reading that is + * recoverable if it is wrong. + */ +describe("a refresh the vendor answered with no actions at all", () => { + /** A client that answers, and answers nothing — which no throw can stand in for. */ + function useEmptyAnsweringClient() { + useComposioClient({ + listActions: async () => [], + execute: async () => vendorAnswered(), + }); + } + + test("keeps every action already recorded, with what the vendor said about them", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + // The honest count is what is HELD, because nothing was replaced. Answering 0 here would tell + // the page the app offers nothing while the rows are still there. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The version above all: it is what `callTool` sends, so losing it breaks every later call on + // an app the refresh reported as fine. + expect(rows).toEqual([ + { name: "GMAIL_FETCH_EMAILS", effect: "read", version: "20260903_00" }, + ]); + }); + + test("says the actions were kept, and does not stamp a refresh", async () => { + const { store, database } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + + /* + * The sentence for THIS state and not the other one. The app answered, so nothing here may + * send an operator to check their configuration — that is the refused transport's sentence, + * and the describe above asserts that one. What this reader needs to know is that the app + * listed nothing and that the actions it holds were not deleted over it. + */ + expect(row?.lastError).not.toBeNull(); + expect(row?.lastError).toContain("kept rather than deleted"); + // No stamp: the column says when this deployment last learned what the app offers, and an + // answer it declined to believe is not it. + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("withdraws nothing and strands no grant", async () => { + const { store, database, auditStore } = await freshStore(); + useEmptyAnsweringClient(); + await seedComposioGmail(database, store); + + await store.refreshTools("gmail", "admin_user"); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + // Still offered and still not withdrawn. + expect(gmail?.tools.map((tool) => tool.ref)).toEqual([ + "gmail/GMAIL_FETCH_EMAILS", + ]); + expect(gmail?.withdrawn).toEqual([]); + // Nor filed as having stopped being offered. That row is written from the listing, so an empty + // one committed would name every grant the app holds — the trail asserting a withdrawal on + // exactly the answer this deployment decided not to believe. + expect( + auditStore + .recorded() + .filter( + (event) => + (event.payload as { change?: string }).change === + "grants_not_advertised", + ), + ).toEqual([]); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From 0fe17543b0417f88ba085cce8b0ae5d807ad19ca Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:24:54 +0200 Subject: [PATCH 082/215] Assert which MCP annotation is believed, and stop claiming none is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTools` now reads `annotations.destructiveHint`, so an MCP listing can fill in `effect` and `destructive` — and the docblock in this file said the opposite: that those three columns are Composio's and every other transport "has to keep coming out of that insert as null and false". Its own test still passed, because its fixtures carry no annotations; a comment nothing can falsify is how this branch has repeatedly ended up with code written to match it. Narrowed to what that test is actually about. The new test states the asymmetry the transport implements. A tool declaring `destructiveHint` records `write` and `true`. Two tools declaring `readOnlyHint` — one on Notion's reviewed write list, one not — record NULL, which is the assertion that matters: both classify correctly whatever the column holds, so only an empty column says the hint was never read. That is what keeps `readOnlyHint` from becoming an opt-out for a server an administrator added by URL, where `classifyTool` answers from the column before it reaches "no reviewed list means everything is a write". The mock hands its fixtures back verbatim, so annotations already travelled at runtime and were only unspellable in `MCPToolDefinition`; the helper's parameter is widened rather than each fixture cast. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index cc1e3e2dd..4c01199d2 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -9,6 +9,7 @@ import { } from "bun:test"; import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; @@ -1843,7 +1844,16 @@ describe("refresh token rotation", () => { */ async function withMockedNotionListing( notionServerId: string, - tools: MCPToolDefinition[], + /* + * The mock's own tool shape, plus the annotations a real server publishes. + * + * `MCPToolDefinition` names only name, description and schema, and the mock hands whatever it was + * given straight back in its `tools/list` answer — so an annotation travels at runtime and is + * simply unspellable in the type. Widened here rather than cast at each fixture, because the + * hints are what `listTools` reads to decide an action's recorded effect, and a test about that + * decision should not be the one place a cast hides a shape drifting. + */ + tools: (MCPToolDefinition & { annotations?: ToolAnnotations })[], body: () => Promise, ) { const mock = new MCPMock(); @@ -2804,8 +2814,11 @@ describe("a dynamic client the vendor has evicted", () => { /** * The classification an MCP server has always had, over a real listing that really happened. * - * The three columns the refresh now writes are Composio's, and every other transport has to keep - * coming out of that insert as null and false. `classifyTool` consults the reviewed `writeTools` + * The three columns the refresh writes are the transports' to fill in, and an MCP server that + * annotates nothing fills in none of them — which is what every fixture in THIS test does, and + * so what it is about. It is no longer true of the transport in general: `listTools` reads + * `annotations.destructiveHint` and writes both `effect` and `destructive` from it, which the + * test below this one covers. `classifyTool` consults the reviewed `writeTools` * list BEFORE the recorded `effect` column, on the criterion that a recorded value may narrow what * a Bot is allowed and may never widen it — so a name the list covers stays a write whatever the * column says, and a value appearing here can no longer turn one of Notion's reviewed writes into a @@ -2891,6 +2904,106 @@ describe("a dynamic client the vendor has evicted", () => { ); }); + /** + * The one annotation this deployment believes, and the two it declines to. + * + * CRITERION. `destructiveHint === true` is recorded as `effect: "write"` and `destructive: true`. + * `readOnlyHint` is recorded as NOTHING AT ALL — not as `read`, not as a value overridden further + * down — whether or not the reviewed write list already covers the name. + * + * REASON. The SDK warns where it declares these hints that a client must not make tool-use + * decisions from annotations an untrusted server supplied, and the two hints are not symmetrical + * against that warning. `destructiveHint` can only move an action from read to write, so a server + * that lies with it restricts itself. `readOnlyHint` moves an action the other way, and it would + * buy nothing in the two curated cases — a name on `writeTools` never reaches the column, and a + * name absent from it already reads as a read — while opening the third: a server an + * administrator added by URL has no reviewed list, and `classifyTool` returns on the recorded + * column BEFORE its `if (!entry) return "write"`, so believing the hint would let an arbitrary + * server declare its whole surface harmless and turn "no reviewed list means everything is a + * write" into an opt-out. + * + * WHY NULL IS THE ASSERTION rather than a classification. Both `readOnlyHint` fixtures come out + * of `listServers` correctly whatever the column holds — one is on the write list, the other is + * not — so a classification assertion alone would pass with the hint written down and overruled + * downstream. NULL in the column is what says it was never read. + */ + test("a refresh records the effect an MCP server declares, and only the narrowing one", async () => { + await putClient(EVICTED); + await connect(); + accepted = new Set([EVICTED.clientId]); + + /** Suite-scoped, so it is not a name Notion really advertises nor one `writeTools` covers. */ + const destructiveName = `notion-destroy-${suite}`; + + await withMockedNotionListing( + dynamicServerId, + [ + { + name: "notion-fetch", + description: + "A read no write list names, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: "notion-create-pages", + description: "A reviewed write, declaring itself read-only.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: destructiveName, + description: "Advertised, on no write list, declared destructive.", + inputSchema: { type: "object", properties: {} }, + annotations: { destructiveHint: true }, + }, + ], + async () => { + expect( + await dynamicStore.refreshTools(dynamicServerId, dynamicUserId), + ).toEqual({ tools: 3 }); + + const rows = await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, dynamicServerId)) + .orderBy(asc(mcpTools.name)); + + expect(rows).toEqual([ + // The reviewed write, which said it was read-only. Nothing recorded, so nothing to + // overrule: the write list is still the only thing that answers for this name. + { name: "notion-create-pages", effect: null, destructive: false }, + // The declaration that narrows, taken at its word. + { name: destructiveName, effect: "write", destructive: true }, + // The unreviewed read, which also said it was read-only, and is believed about nothing. + { name: "notion-fetch", effect: null, destructive: false }, + ]); + + const listed = (await dynamicStore.listServers()).find( + (server) => server.id === dynamicServerId, + ); + + // What the Plugins page derives, which is what an administrator actually reads: the + // reviewed name is a write because review says so, the declared one is a write because the + // vendor narrowed it, and the third is the read it was already classified as. + expect( + listed?.tools.map((tool) => ({ + name: tool.name, + effect: tool.effect, + })), + ).toEqual([ + { name: "notion-create-pages", effect: "write" }, + { name: destructiveName, effect: "write" }, + { name: "notion-fetch", effect: "read" }, + ]); + }, + ); + }); + /** * What a failed refresh writes into `lastError`, and how much of it. * From 6192122f1f0960aad34865396d48a59b21c9a10f Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:31:50 +0200 Subject: [PATCH 083/215] Send an unresolvable row's sentence to the operator and to nobody else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ServerRowAmbiguousError` refuses a row whose provenance says composio and whose id is a curated slug, and it was caught nowhere. The refresh route rethrew it into the default handler — a 500 with no JSON, which the admin page renders as "That did not work" — while `grantedTools` copied its message into a model's context, so a sentence naming a column of ours and telling the reader to correct it became a Bot's explanation to an end user. The one audience that could act on it saw nothing; the one that could not saw all of it. The distinction the codebase already draws by hand is now asked once, as `isDeploymentFault`: `PluginRefusedError` is the class relayed verbatim because the asker can act on it, and this is its opposite. The refresh route, which is admin-gated, answers 409 with the sentence in full. `grantedTools` answers "That tool could not be called." and nothing more. The tool-call route, which is not admin-gated, answers 500 with a neutral sentence instead of 502 and `failed: true`, which asserted a vendor had been reached. Landed with the second refusal on that shelf rather than after it, because the shelf is what makes either of them safe. A catalogue entry may no longer declare `transport: "composio"`: `CuratedTransportKind` makes it a compile error, and `accessFor` refuses it at resolution for when the type is bypassed. Unrefused it produced a Composio dial with `toolkit: null` and a credential taken from the entry's auth kind, so both gates that keep one person's brokered account out of another's were keyed on a null app and skipped, and the trail named whose account had been reached from a field unrelated to it. No entry declares it today, which is why this is a door being shut. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/access.ts | 69 ++++++- server/src/plugins/catalogue.ts | 8 +- server/src/plugins/routes.ts | 43 ++++ server/src/plugins/store.ts | 39 +++- server/src/plugins/tools.ts | 22 +- server/src/plugins/transport.ts | 19 ++ server/tests/plugin-routes.test.ts | 85 ++++++++ server/tests/plugin-store.integration.test.ts | 190 ++++++++++++++++++ 8 files changed, 470 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index d0fa2f2a1..825b5acad 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -101,6 +101,29 @@ const REACHED_AS_BY_AUTH: Record< builtin: "person", }; +/** + * The shelf both refusals below sit on: this deployment cannot say how to reach a row, and will + * not guess. + * + * CRITERION. Nothing on this shelf is a vendor's doing, a credential's doing, or anything the + * person asking can act on. An operator gets the sentence, because it names two of our own columns + * and what to do about them; every other audience — a model's context, a person's browser — gets + * the fact that the call did not happen, and none of the sentence. + * + * REASON. `store.ts` draws exactly this line already, between {@link PluginRefusedError} — a + * refusal somebody CAN act on, and the one class the codebase relays verbatim — and + * `PluginInvariantError`, a state this deployment's own code says cannot exist. These two are that + * second kind, found one step earlier: in resolving the row rather than in querying against it. + * + * A BASE CLASS RATHER THAN A LIST AT EACH AUDIENCE. `ServerRowAmbiguousError` shipped with no + * `catch` anywhere, so the refresh route rethrew it into the default handler — an administrator + * got a 500 with no body and a page that said "That did not work" — and `grantedTools` copied its + * message into a model's context, offering an end user's Bot a sentence about correcting a + * provenance column. A third contradiction added here has to be refused everywhere without a + * second edit, so the audiences ask one question: `isDeploymentFault` in `store.ts`. + */ +export abstract class ServerUnresolvableError extends Error {} + /** * A row that claims to be two servers at once, which makes it neither. * @@ -126,13 +149,51 @@ const REACHED_AS_BY_AUTH: Record< * rather than imported from `store.ts` because this module is a leaf — `store.ts` imports it, and * it imports nothing back. */ -export class ServerRowAmbiguousError extends Error { +export class ServerRowAmbiguousError extends ServerUnresolvableError { constructor(message: string) { super(message); this.name = "ServerRowAmbiguousError"; } } +/** + * A reviewed entry that names a transport no entry can be reached over. + * + * CRITERION. An entry declaring `transport: "composio"` is refused at resolution, and no answer is + * produced for it. + * + * REASON. {@link CuratedTransportKind} already keeps the value out of the catalogue at compile + * time, which is where it belongs — nothing writes an entry at runtime. This is what stands behind + * a cast, a JSON fixture in a test, or a future loader that reads entries from somewhere: what the + * unrefused answer WAS is a Composio dial with `toolkit: null` and a `reachedAs` copied from the + * entry's auth kind, so both store gates that keep one person's brokered account out of another's + * were skipped and the trail said the wrong thing about whose account was reached. Fail-closed + * costs one comparison; the alternative is a hole that opens the first time the type is bypassed. + */ +export class CatalogueTransportUnroutableError extends ServerUnresolvableError { + constructor(message: string) { + super(message); + this.name = "CatalogueTransportUnroutableError"; + } +} + +/** + * Whether a kind is the broker's, asked through a function so the question survives being answered. + * + * CRITERION. This comparison must stay live even though {@link CuratedTransportKind} makes it + * unreachable from the catalogue as the catalogue stands today. + * + * REASON. Written inline against `entry.transport`, the compiler narrows the operand to the three + * curated kinds and rejects the comparison as pointless — correctly, and only while nothing + * bypasses the type. A cast, a test fixture, or a loader that ever reads entries from outside the + * build would each produce the state this refuses, and each arrives at runtime where a type says + * nothing. Widening to {@link TransportKind} at a parameter costs one call and keeps both the + * compile-time door and the runtime one shut, rather than trading the second for the first. + */ +function isBrokerTransport(kind: TransportKind): boolean { + return kind === "composio"; +} + /** * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is * refused rather than resolved. @@ -163,6 +224,12 @@ export function accessFor( } if (entry) { + if (isBrokerTransport(entry.transport ?? "mcp")) { + throw new CatalogueTransportUnroutableError( + `${entry.key} is a catalogue entry declaring the composio transport, which is reached from a row's provenance and the app slug in its url — neither of which an entry has. There is no app to broker to and no connection to check, so this entry is not resolved at all: give it the transport it is actually reached over.`, + ); + } + return { transport: entry.transport ?? "mcp", credential: CREDENTIAL_BY_AUTH[entry.auth.kind], diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index c3a91fdbc..afd265821 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -30,7 +30,7 @@ // cloud credentials. `target.ts` imports nothing itself, so asking it here adds no dependency. import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. -import type { TransportKind } from "./transport"; +import type { CuratedTransportKind } from "./transport"; export type CatalogueAuth = /** Answers without any credential at all. */ @@ -132,8 +132,12 @@ export type CatalogueEntry = { * serves Drive over both an MCP endpoint and an ordinary REST API, and which one this deployment * uses is a decision about availability and risk rather than a property of the vendor. Naming it * here keeps that decision beside the host it applies to, and makes reversing it a one-line diff. + * + * NOT EVERY KIND, and the narrowing is the point: see {@link CuratedTransportKind}. The broker's + * transport is reached from a row's provenance and its url, never from an entry, and an entry + * naming it resolves to a Composio dial with no app and no brokered gate. */ - transport?: TransportKind; + transport?: CuratedTransportKind; docsUrl: string; }; diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8fdc88535..639b72d69 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -19,6 +19,7 @@ import { CustomServerRefusedError, type OAuthClient, type PluginKind, + isDeploymentFault, PluginRefusedError, type PluginStore, } from "./store"; @@ -330,6 +331,26 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * The one audience the sentence was written for, and the only route that may show it. + * + * CRITERION. A contradiction between this deployment's own columns comes back to an + * administrator as itself: a body, naming the row and what to do about it. + * + * REASON. Unmapped, it reached the framework's default handler — a 500 with no JSON at all, + * which the admin page reads as "That did not work", the fallback it uses when a response + * carries no message. So the one refusal that names exactly which row is wrong and how to + * correct it was the one an operator could not see, while the same sentence WAS reaching a + * model on the tool-call path. This route is `requireAdmin`, which is what makes showing it + * here safe and showing it anywhere else not. + * + * 409 rather than 500: nothing broke, and nothing about the request was malformed. Two rows + * of ours disagree, and the request cannot be answered until one of them changes — which is + * what the sentence tells the reader to go and do. + */ + if (isDeploymentFault(error)) { + return context.json({ error: error.message }, 409); + } throw error; } }); @@ -879,6 +900,28 @@ export function createPluginRoutes( if (error instanceof CatalogueEntryUnknownError) { return context.json({ error: error.message }, 404); } + /* + * Ours, and so neither the vendor's fault nor this caller's business. + * + * CRITERION. A fault on the `isDeploymentFault` shelf is not reported through the branch + * below, and its sentence does not leave this process by this route. + * + * REASON. Two things would be wrong at once. `failed: true` and 502 say somebody else's + * software did not answer, which is a false statement about a call that never went out — + * and this route is `requireUser`, not `requireAdmin`, so the sentence naming our columns + * and the correction to make would be readable by anybody with a session. The operator who + * can act on it reads it on the refresh route above, which is admin-gated; here the honest + * answer is that the deployment cannot make this call as it stands. + */ + if (isDeploymentFault(error)) { + return context.json( + { + error: + "That tool is not configured in a way this deployment can act on. An administrator has to look at the server it belongs to.", + }, + 500, + ); + } // A server that failed is not a refusal, and saying so matters: one means the deployment // decided against it, the other means somebody else's software did not answer. return context.json( diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 2edabc0cd..42eb3477f 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -40,7 +40,11 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { accessFor, type ServerAccess } from "./access"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; @@ -250,6 +254,39 @@ export class PluginInvariantError extends Error { } } +/** + * Whether a throw is this deployment contradicting itself, rather than anything anybody asked for. + * + * CRITERION. Every audience boundary asks THIS instead of listing classes of its own. A fault it + * answers true for reaches an operator as its own sentence, on a surface only an operator can + * reach, and reaches everybody else as the fact that the call did not happen — no message, no + * column names, no instruction about a row. + * + * REASON. The distinction already existed and was drawn by hand, once, in each place that + * remembered to draw it: {@link PluginRefusedError} is relayed verbatim because it is a refusal + * the asker can act on, and everything else fell into a branch that copies `error.message` + * onwards. {@link ServerUnresolvableError} was caught by none of them — the refresh route rethrew + * it into the framework's default handler, which answers a bodiless 500, so the admin page said + * "That did not work" and named nothing; `grantedTools` put its message in a model's context, + * where a sentence telling an operator to correct a provenance column became a Bot's explanation + * to an end user of why their tool failed. Two audiences, one refusal, neither served. + * + * {@link PluginInvariantError} is on the same shelf and answers true for the same reason: it is + * this deployment finding a state its own code says cannot exist. That is not a vendor + * misbehaving and not a person's to act on mid-call, and its own docblock has said so since it + * was written — what it lacked was anywhere that asked. + * + * A PREDICATE RATHER THAN A SHARED BASE CLASS, because the two live in different modules and must + * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared + * once without one of them importing the other back. + */ +export function isDeploymentFault(error: unknown): error is Error { + return ( + error instanceof ServerUnresolvableError || + error instanceof PluginInvariantError + ); +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 4e54bf20c..905a815fa 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import type { AuditInitiator } from "../audit"; import type { SelectableSkill } from "./selection"; -import { PluginRefusedError, type PluginStore } from "./store"; +import { + isDeploymentFault, + PluginRefusedError, + type PluginStore, +} from "./store"; /** * The tools a Bot may call, as the runtime's own tool definitions, executed on the server. @@ -203,6 +207,22 @@ export async function grantedTools(options: { if (error instanceof PluginRefusedError) { return `${REFUSAL_MARKER} ${error.message}`; } + /* + * A contradiction in this deployment's own tables says nothing to a model. + * + * CRITERION. Nothing on the `isDeploymentFault` shelf may have its message relayed from + * here, whatever it says. + * + * REASON. The branch below hands `error.message` to the model, which is right for a + * vendor's own words — that is somebody else's software explaining itself, and the + * diagnosis is worth having. These are not that. `ServerRowAmbiguousError` names two of + * our columns and tells the reader to rename a row or correct its provenance: an + * instruction only an operator can carry out, arriving in an end user's model context as + * the reason their tool failed, from which the model can only invent something to tell + * them. The operator who can act on it is served on the admin surface instead, where the + * refresh route now answers with the sentence in full. + */ + if (isDeploymentFault(error)) return "That tool could not be called."; // A vendor that failed is not a refusal, and the difference matters to the person reading // the answer: one means "not allowed", the other means "it broke". return error instanceof Error diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index cead6af40..cf225df8c 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -113,6 +113,25 @@ export type TransportKind = | "builtin-routines" | "composio"; +/** + * The kinds a CATALOGUE ENTRY may name, which is every one except the broker's. + * + * CRITERION. `composio` is not writable in a reviewed entry, and the compiler is what says so. + * + * REASON. A brokered row is reached by an app slug read off its url and a per-person connection + * looked up by that slug; a catalogue entry has neither, and `accessFor` answers `toolkit: null` + * and `credential` from the entry's auth kind for everything it resolves. So an entry declaring + * `transport: "composio"` yielded a Composio dial with no app named, no brokered gate, and + * `reachedAs` taken from an auth kind that has nothing to do with whose account the broker would + * have run in — a row that walks past both store gates while satisfying every type in the module + * that claims to enumerate how a row can be reached. No entry declares it, which is why this is a + * door being shut rather than a bug being fixed, and why shutting it costs nothing. + * + * `Exclude` rather than a hand-written second union, so a kind added above is offered to the + * catalogue automatically and only the broker stays out. + */ +export type CuratedTransportKind = Exclude; + const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 14c072769..205928e5f 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; +import { ServerRowAmbiguousError } from "../src/plugins/access"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -104,6 +105,90 @@ describe("adding a curated server", () => { }); }); +/** + * What a refresh that cannot be resolved at all looks like to the administrator who pressed it. + * + * CRITERION. A contradiction between two of this deployment's own columns comes back with a body + * that names the row and says what to correct, on this route and only on this route. + * + * REASON. `ServerRowAmbiguousError` was mapped nowhere, so it left the route on the framework's + * default path: a 500 whose body is not JSON, which the admin client turns into its fallback + * sentence — "That did not work" — having found no `error` field to read. The one refusal that + * names exactly which row is wrong was the one an operator could not see, while the same sentence + * was reaching a model on the tool-call path. This route is admin-gated, which is what makes + * showing it here the right answer and showing it anywhere else the wrong one. + */ +function refreshApp( + refreshTools: () => Promise, + role: "admin" | "user" = "admin", +) { + const store = { + refreshTools, + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return () => + app.request("http://openbot.test/api/plugins/servers/notion/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); +} + +describe("refreshing a server that cannot be resolved", () => { + test("the administrator is told which row and what to do about it", async () => { + const sentence = + "notion is a server this deployment ships an entry for, and a row with that id says its " + + "provenance is composio. Rename it, or correct its provenance."; + const request = refreshApp(async () => { + throw new ServerRowAmbiguousError(sentence); + }); + + const response = await request(); + + // 409 rather than 500: nothing broke and nothing about the request was malformed. Two rows + // disagree, and the request cannot be answered until one of them changes. + expect(response.status).toBe(409); + // A body at all is the fix. Unmapped, this was a 500 carrying no JSON, and the page said + // "That did not work" because that is what it says when it finds no message. + expect(await response.json()).toEqual({ error: sentence }); + }); + + test("a failure that is not one of ours is still not dressed up as one", async () => { + // The must-not case, the same one the add route above carries: a database that is down is not + // a row an administrator can go and correct, and answering 409 would send them to do it. + const request = refreshApp(async () => { + throw new Error("the database is unreachable"); + }); + + expect((await request()).status).toBe(500); + }); + + test("somebody who is not an administrator cannot press it at all", async () => { + const request = refreshApp(async () => { + throw new Error("the store must not be reached"); + }, "user"); + + // Which is what makes showing the sentence above safe: nobody else reaches this route. + expect((await request()).status).toBe(403); + }); +}); + /** * Granting one Bot to another, through the API an administrator actually has. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 4c01199d2..9f3c6aa98 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -32,12 +32,19 @@ import { pluginGrants, users, } from "../src/db/schema"; +import { + accessFor, + CatalogueTransportUnroutableError, + ServerRowAmbiguousError, +} from "../src/plugins/access"; +import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; import { type ComposioResult, useComposioClient, } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { type AccessToken, CustomServerRefusedError, @@ -48,6 +55,7 @@ import { PluginInvariantError, PluginRefusedError, type PluginStore, + isDeploymentFault, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; @@ -5011,6 +5019,188 @@ describe("a refresh the vendor answered with no actions at all", () => { }); }); +/** + * WHO IS TOLD WHAT, when the row itself is the thing that cannot be resolved. + * + * `ServerRowAmbiguousError` refuses a row whose provenance says `composio` and whose id is a + * curated catalogue slug: nothing in the row and the entry tells a tampered curated row apart from + * a brokered app that took the name, so there is no answer that is not wrong in one of the two + * worlds. It shipped caught NOWHERE. Every audience therefore got the wrong thing at once — the + * admin page a bodiless 500 it renders as "That did not work", and a model the operator's own + * sentence about correcting a provenance column, offered to an end user as the reason their tool + * failed. + * + * The store's half is asserted here: it refuses, and it records nothing about a vendor while doing + * so. What each audience then sees is asserted where that audience is — the model below, and the + * administrator in `plugin-routes.test.ts`, which is the file that exists for that mapping. + */ +describe("a row that resolves to two servers at once", () => { + /** A colliding row, spelled the way the collision actually occurs: a curated slug, brokered. */ + async function seedCollidingNotion(database: Database) { + await database.insert(mcpServers).values({ + id: "notion", + title: "Notion", + vendor: "Composio", + url: "composio://notion", + provenance: "composio", + }); + } + + test("a refresh refuses it, and writes nothing about a vendor", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + + await expect(store.refreshTools("notion", "admin_user")).rejects.toThrow( + ServerRowAmbiguousError, + ); + + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + // Two of our columns disagreeing is not a vendor's answer, so it must not be written where the + // page draws what the vendor said. Raised instead, which is what the route reads. + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + }); + + test("the model is told the call did not happen, and nothing about our columns", async () => { + const { store, database } = await freshStore(); + await seedCollidingNotion(database); + await database.insert(mcpTools).values({ + serverId: "notion", + name: "notion-fetch", + description: "Fetch a page.", + }); + await database.insert(agents).values({ + id: "bot_helper", + name: "Helper", + type: "built_in", + configuration: {}, + }); + await store.grant( + "mcp", + "notion/notion-fetch", + "bot_helper", + "admin@example.com", + ); + + const [tool] = await grantedTools({ + store, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * Every part of the operator's sentence, named rather than summarised. + * + * The message says the row is one the deployment ships an entry for, that its provenance says + * composio, and that somebody should rename it or correct the column. Each of those is a fact + * about our database and an instruction only an administrator can act on; a model handed any + * of them can only relay or embroider it. Asserted piecewise so a reworded sentence that still + * leaks cannot pass by not matching one long string. + */ + expect(answer).not.toContain("provenance"); + expect(answer).not.toContain("rename"); + expect(answer).not.toContain("notion"); + // And not dressed as a refusal either: nothing was decided against, so the marker the + // transcript draws as a boundary holding would be a lie about which of the two happened. + expect(answer.startsWith(REFUSAL_MARKER)).toBe(false); + expect(answer).toBe("That tool could not be called."); + }); + + test("it is on the same shelf the store already raises rather than records", async () => { + /* + * The distinction, asked the way every audience asks it. + * + * Both audiences above branch on `isDeploymentFault` rather than on a class list of their own, + * so what makes them correct is this answer and not the two `catch` blocks. A class added to + * the shelf and forgotten here is the defect being fixed, one round later. + */ + expect(isDeploymentFault(new ServerRowAmbiguousError("x"))).toBe(true); + expect(isDeploymentFault(new CatalogueTransportUnroutableError("x"))).toBe( + true, + ); + expect(isDeploymentFault(new PluginInvariantError("x"))).toBe(true); + // And the refusal somebody CAN act on is not on it: its message is the one thing this codebase + // relays verbatim, to a model and to a browser alike. + expect(isDeploymentFault(new PluginRefusedError("x", null))).toBe(false); + expect(isDeploymentFault(new Error("the vendor did not answer"))).toBe( + false, + ); + }); +}); + +/** + * A catalogue entry naming the broker's transport, which no entry can be reached over. + * + * NOT A LIVE BUG AND NOT MEANT TO BECOME ONE. No entry declares it, and `CuratedTransportKind` now + * makes declaring it a compile error — which is the real fix, since entries are code. This is what + * holds when the type is bypassed: a cast, a fixture like the one below, or a loader that ever + * reads an entry from outside the build. + * + * WHAT THE UNREFUSED ANSWER WAS. `transport: "composio"` with `toolkit: null`, a credential taken + * from the entry's auth kind rather than `brokered`, and `reachedAs` from the same table. So the + * dial went to the broker while both gates that keep one person's brokered account out of + * another's — the connection lookup in `connectionTokenFor` and the app-slug check in + * `refreshTools` — were keyed on a null app and skipped, and the trail recorded whose account had + * been reached from a field that had nothing to do with it. Refusing is the only answer that does + * not assert something false. + */ +test("a catalogue entry declaring the broker's transport is refused, not dialled", () => { + /* + * Cast at the fixture, deliberately and in one place. The type is what keeps this out of the + * catalogue, so a test about what happens when the type is bypassed has to bypass it — and doing + * it here rather than in a helper keeps the bypass visible beside the thing it is testing. + */ + const brokered = { + key: "brokered-entry", + title: "Brokered Entry", + vendor: "Somebody", + summary: "An entry that names a transport an entry cannot be reached over.", + host: "https://mcp.example.com", + path: "/mcp", + auth: { + kind: "user-oauth" as const, + authorizationUrl: "https://example.com/auth", + tokenUrl: "https://example.com/token", + revokeUrl: "https://example.com/revoke", + scopes: [], + }, + writeTools: [], + transport: "composio", + docsUrl: "https://example.com/docs", + } as unknown as CatalogueEntry; + + expect(() => + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + brokered, + ), + ).toThrow(CatalogueTransportUnroutableError); + + // The same entry with the transport it is actually reached over resolves as any other curated + // per-person vendor does, so what is refused is the value and not the fixture. + expect( + accessFor( + { provenance: "first-party", url: "https://mcp.example.com/mcp" }, + { ...brokered, transport: undefined }, + ), + ).toEqual({ + transport: "mcp", + credential: "person-oauth", + reachedAs: "person", + toolkit: null, + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From f892e6147cf2c17ec99509f35f5d55b0aaa43f71 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:39:21 +0200 Subject: [PATCH 084/215] Keep the statement and its parameters out of a failed replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor naming one action twice hits `mcp_tools`' `(server_id, name)` primary key, and a U+0000 anywhere in what a vendor wrote is a byte PostgreSQL will not encode. Both aborted the wholesale replace from inside its transaction, which sits outside the vendor `try` above it, so what left `refreshTools` was drizzle's `DrizzleQueryError` — `Failed query:` plus the whole insert, then `params:` and every value bound to it. That is the disclosure shape of a credential leak one layer out, and it went to the logs and to any caller that prints an error while `lastError` kept whatever it held before. Neither reaches a statement now. `storableTools` drops a name the vendor listed twice, keeping the first occurrence because nothing says which of two identical names is real, and strips U+0000 from the name, the description, the version and the schema. What is left is a transaction failing for reasons that are genuinely not the vendor's — which is what the comment on the replace has always claimed — and that is raised as a `PluginInvariantError` carrying the driver's own complaint and none of the query, through `databaseComplaint`. The count returned and the set the stranded-grant audit compares against are both taken from what was stored rather than from what was listed, so a duplicate is one action in both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 175 +++++++++++++--- server/tests/plugin-store.integration.test.ts | 195 ++++++++++++++++++ 2 files changed, 342 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 42eb3477f..ceca63e71 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -280,6 +280,109 @@ export class PluginInvariantError extends Error { * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared * once without one of them importing the other back. */ +/** + * The one character no PostgreSQL `text` or `jsonb` value can hold, whatever the vendor sent. + * + * Not a length limit and not an encoding preference: the server rejects the statement outright, + * mid-transaction, and the rejection arrives as a query error rather than as anything about the + * value. + */ +const NUL = "\u0000"; + +/** + * The driver's own complaint about a query, without the query. + * + * CRITERION. What this returns never contains the statement or the values bound to it. + * + * REASON. drizzle's `DrizzleQueryError` puts both in its own `message` and hangs the driver's + * error off `cause`. The driver's message is the useful half — `duplicate key value violates + * unique constraint`, `invalid byte sequence`, `canceling statement due to statement timeout` — + * and it is the half that names nothing anybody sent. An error shaped differently gets a fixed + * sentence rather than its own message, because the reason this exists is that a message from an + * unexamined shape is exactly what leaked the last one. + * + * Capped where every other quoted failure in this file is capped, for the same reason: parts of + * it come from somewhere else and none of it is a promise about length. + */ +function databaseComplaint(error: unknown): string { + const cause = error instanceof Error ? error.cause : undefined; + return cause instanceof Error + ? cause.message.slice(0, 400) + : "The database gave no reason this deployment can quote."; +} + +/** + * What a vendor listed, as rows this database will actually take. + * + * CRITERION ONE. No two rows carry the same name, whatever the vendor listed. + * + * CRITERION TWO. No string reaching the insert contains U+0000, in a column or inside a schema. + * + * REASON. Both of these used to abort the replace from INSIDE the transaction and OUTSIDE the + * vendor `try` above it, so they came out of `refreshTools` as a raw `DrizzleQueryError` — whose + * message is `Failed query: ` followed by `params:` and every value bound to + * it. That reached an operator's page and the logs as a SQL dump, which is the same disclosure + * shape as a leaked credential one layer out, and it left `lastError` holding whatever was there + * before: stale, or null, on a refresh that had in fact failed. + * + * FIXED BY NOT REACHING THE DATABASE WITH IT, rather than by catching it better. A vendor that + * names one action twice is answering about one action — `mcp_tools`' `(server_id, name)` primary + * key says so, and the first listing is as good an answer as the second, so the duplicate is + * dropped rather than made into an error somebody has to act on. A control character in a + * description is not content anybody wants to keep either. What is left after this is a + * transaction that fails for reasons that are genuinely not the vendor's, which is what the + * comment on the replace has always claimed. + * + * FIRST OCCURRENCE WINS, and the order is the vendor's own. Anything else needs a rule for which + * of two identical names is the real one, and there is no such rule. + */ +function storableTools(serverId: string, listed: ListedTool[]) { + const byName = new Map< + string, + { + serverId: string; + name: string; + description: string; + inputSchema: Record; + effect: "read" | "write" | null; + destructive: boolean; + version: string | null; + } + >(); + + for (const tool of listed) { + const name = tool.name.replaceAll(NUL, ""); + if (byName.has(name)) continue; + byName.set(name, { + serverId, + name, + description: tool.description.replaceAll(NUL, ""), + /* + * Through JSON rather than by walking the object, because the escape is what has to go and + * the schema is JSON by definition — it is stored in a `jsonb` column and came off the wire + * as JSON. `JSON.stringify` writes a literal U+0000 as the six characters `\u0000`, so that + * is the sequence removed here; a schema with none is rebuilt identical. + */ + inputSchema: JSON.parse( + JSON.stringify(tool.inputSchema).replaceAll("\\u0000", ""), + ), + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes an effect and a version, and an MCP server publishes a + * destructive hint — see `mcp.ts`. All three stay null or false for a transport that says + * nothing, and `classifyTool` reads null as silence rather than as a value, which is what + * leaves Notion and Drive classified by their reviewed write list exactly as they were. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version?.replaceAll(NUL, "") ?? null, + }); + } + + return [...byName.values()]; +} + export function isDeploymentFault(error: unknown): error is Error { return ( error instanceof ServerUnresolvableError || @@ -2482,32 +2585,43 @@ export function createPluginStore(options: PluginStoreOptions) { * rather than being copied into `lastError`, because a transaction this database would not take * is not something the vendor did. */ - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (listed.length > 0) { - await transaction.insert(mcpTools).values( - listed.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - /* - * What the vendor said, when the vendor said anything. - * - * Only Composio publishes any of this today, so all three stay null or false for - * every other transport — and `classifyTool` reads null as silence rather than as a - * value, which is what leaves Notion and Drive classified by their reviewed write list - * exactly as they were before. - */ - effect: tool.effect ?? null, - destructive: tool.destructive ?? false, - version: tool.version ?? null, - })), - ); - } - }); + // Names deduplicated and vendor text made storable before a transaction is opened on any of + // it, because both failures used to abort the replace from inside one. See + // {@link storableTools}. + const storable = storableTools(serverId, listed); + + try { + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (storable.length > 0) { + await transaction.insert(mcpTools).values(storable); + } + }); + } catch (error) { + /* + * A database failure, with the statement and its parameters left behind. + * + * CRITERION. Nothing raised from here carries the SQL or the values bound to it. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError`, whose message is + * `Failed query:` followed by the whole statement and then every parameter — here, the + * vendor's entire tool list. That message is what an unhandled throw puts in the logs and + * what any caller that prints an error puts on a screen. A SQL dump on an error path is + * the same disclosure shape as a credential leak one layer out, and it is gratuitous: the + * driver's own complaint says what went wrong without any of it. + * + * RAISED, NOT RECORDED, which is what the paragraph above this transaction argues for and + * is now true rather than merely intended: with duplicate names and unstorable text + * removed before the statement is built, what is left is this database refusing something + * this deployment's own schema says it will take, and `lastError` is where a VENDOR's + * answer goes. + */ + throw new PluginInvariantError( + `${row.id}: the actions this app listed were not stored, so what it already had is unchanged. ${databaseComplaint(error)}`, + ); + } await database .update(mcpServers) @@ -2533,7 +2647,9 @@ export function createPluginStore(options: PluginStoreOptions) { * committed, because the guard above returns before this on an empty answer that would have * named every grant the app holds. */ - const advertised = new Set(listed.map((tool) => tool.name)); + // The names as STORED, so a grant is compared against a row that exists: a duplicate the + // vendor listed twice is one row, and a name is spelled here the way the insert spelled it. + const advertised = new Set(storable.map((tool) => tool.name)); const stranded = [...(await mcpGrantsForServers([serverId])).entries()] .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) .sort(([left], [right]) => left.localeCompare(right)); @@ -2583,7 +2699,10 @@ export function createPluginStore(options: PluginStoreOptions) { }); } - return { tools: listed.length }; + // What was recorded, which is what "this app offers N actions" means on the page. Counting + // the listing instead reported a duplicate the vendor named twice as two actions the + // deployment holds, when `mcp_tools` holds one row for it. + return { tools: storable.length }; }, async listServers(): Promise { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 9f3c6aa98..5ed224acc 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -5201,6 +5201,201 @@ test("a catalogue entry declaring the broker's transport is refused, not dialled }); }); +/** + * WHAT A VENDOR SENT THAT THIS DATABASE WILL NOT TAKE, and what came out when it did not. + * + * Both of these aborted the wholesale replace from INSIDE its transaction, which sits OUTSIDE the + * vendor `try` above it — so neither was recorded and neither was caught. What left `refreshTools` + * was drizzle's `DrizzleQueryError`, whose message is `Failed query:` followed by the entire + * statement and then `params:` and every value bound to it. A SQL dump on an error path is the + * same disclosure shape as a credential leak one layer out, and it reached the logs and any caller + * that prints an error, while `lastError` sat holding whatever it held before: stale, or null, on + * a refresh that had failed outright. + * + * Both are now settled before a statement is built, which is why the assertions below are about + * the rows rather than about a better error. + */ +describe("a listing this database would not have taken", () => { + test("a vendor naming one action twice records it once", async () => { + const { store, database } = await freshStore(); + /* + * The same slug twice, with different text, which is what a paginated listing that overlaps + * or a broker with two entries for one action produces. `(server_id, name)` is the primary + * key, so as one multi-row insert this refused the whole statement and rolled the delete back + * with it — leaving the app holding its old actions and the refresh throwing a dump. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email, listed again.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + // One action, because the vendor named one action. Not two rows, and not a refusal. + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const rows = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + // The first occurrence, because the order is the vendor's own and there is no rule that says + // which of two identical names is the real one. + expect(rows).toEqual([ + { name: "GMAIL_SEND_EMAIL", description: "Send an email." }, + ]); + + const [row] = await database + .select({ lastError: mcpServers.lastError }) + .from(mcpServers) + .where(eq(mcpServers.id, "gmail")); + // A healthy refresh, because that is what it was. + expect(row?.lastError).toBeNull(); + }); + + test("a U+0000 in what the vendor wrote is dropped rather than aborting the replace", async () => { + const { store, database } = await freshStore(); + /* + * In the name, in the description and inside the schema, because all three reach the insert + * and the column types differ: `text` refuses the byte and `jsonb` refuses the escape, and + * each aborts the same transaction from a different statement position. + */ + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND\u0000_EMAIL", + description: "Send\u0000 an email.", + inputParameters: { + type: "object", + properties: { subject: { description: "The\u0000 subject" } }, + }, + version: "2026\u00000903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + expect(await store.refreshTools("gmail", "admin_user")).toEqual({ + tools: 1, + }); + + const [stored] = await database + .select({ + name: mcpTools.name, + description: mcpTools.description, + inputSchema: mcpTools.inputSchema, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")); + + expect(stored?.name).toBe("GMAIL_SEND_EMAIL"); + expect(stored?.description).toBe("Send an email."); + expect(stored?.version).toBe("20260903_00"); + // Inside the schema too, and the rest of the schema rebuilt exactly as it arrived. + expect(stored?.inputSchema).toEqual({ + type: "object", + properties: { subject: { description: "The subject" } }, + }); + }); + + test("a replace this database still refuses raises without the statement", async () => { + /* + * A transaction forced to fail, because after the two cases above nothing a vendor can send + * reaches this branch — and this branch is the one that used to publish the dump. What is + * asserted is the SHAPE of what comes out: the driver's own complaint, and none of the + * statement or the values bound to it. + */ + const { store, database } = await freshStore(); + useComposioClient({ + listActions: async () => [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + inputParameters: { type: "object", properties: {} }, + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + await seedComposioGmail(database, store); + + /* + * Derived from the real one rather than stubbed, so every other query the refresh makes is + * the real query. The failure is spelled the way drizzle spells one: the statement and every + * bound value in `message`, the driver's own error hung off `cause`. That message is what + * used to escape. + */ + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "transaction", { + value: async () => { + throw Object.assign( + new Error( + 'Failed query: insert into "mcp_tools" ("server_id", "name") values ($1, $2) params: gmail, GMAIL_SEND_EMAIL', + ), + { + cause: new Error( + 'duplicate key value violates unique constraint "mcp_tools_pkey"', + ), + }, + ); + }, + }); + + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("gmail", "admin_user"); + } catch (error) { + thrown = error; + } + + const message = thrown instanceof Error ? thrown.message : String(thrown); + // The driver's complaint, which names what went wrong. + expect(message).toContain("duplicate key value violates unique constraint"); + // And nothing of the statement or of what was bound to it. + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("insert into"); + expect(message).not.toContain("params:"); + // On the shelf that is raised rather than recorded and never relayed to a model, because a + // transaction this database would not take is not something the vendor did. + expect(isDeploymentFault(thrown)).toBe(true); + + // And what the app already had is still there, because nothing was committed. + expect( + await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, "gmail")), + ).toEqual([{ name: "GMAIL_FETCH_EMAILS" }]); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From 0f187d99771cbafa2ab1c77e63e1396a4103d951 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:41:12 +0200 Subject: [PATCH 085/215] File a brokered disconnect under the app, whichever act ended it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcp.account_disconnected` is written by the two acts that can end a brokered connection, and they keyed it differently: offboarding on the connection's toolkit, removing the app on `mcp_servers.id`. Nothing holds those two strings equal — the id is a display key and the slug in the url is what the broker is asked about — so on any renamed row half the trail is filed under a name the other half never mentions and no single query answers what happened to one person's brokered access. Both were written by recent waves, and every fixture in this suite spelled the two the same, which is why it went unseen. The app is the key, because the app is what was consented to: the gate is `(toolkit, user_id)`, the delete is by toolkit, and the row outlives the server row entirely, so the id may not exist by the time anybody asks. Which server row was removed is still recorded — the `configuration.changed` row written in the same call names it. ALSO CARRIES ONE CHANGE THAT BELONGS TO THE COMMIT BEFORE IT, in the same file and named here rather than left to be found: `storableTools` reads `.replaceAll` off a description and stringifies a schema, where the mapping it replaced passed both straight through — so a transport handing back undefined used to get the `""` and `{}` those columns default to, and would now get a TypeError thrown from outside the vendor `try`. Both fields are required by `McpTool` and supplied by every transport here, so this restores the tolerance the insert already had rather than adding an answer of its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 35 +++++++-- server/tests/composio-connections.test.ts | 87 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index ceca63e71..353c6eae8 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -356,7 +356,15 @@ function storableTools(serverId: string, listed: ListedTool[]) { byName.set(name, { serverId, name, - description: tool.description.replaceAll(NUL, ""), + /* + * Defaulted where the COLUMN has a default, because that is what the previous mapping leaned + * on: it passed these two straight through, so a transport handing back undefined got the + * `""` and `{}` the schema declares. Reading a method off the value instead would turn the + * same absence into a TypeError thrown from outside the vendor `try`. Both fields are + * required by `McpTool` and supplied by every transport here; this keeps the tolerance the + * insert already had rather than adding a new answer. + */ + description: (tool.description ?? "").replaceAll(NUL, ""), /* * Through JSON rather than by walking the object, because the escape is what has to go and * the schema is JSON by definition — it is stored in a `jsonb` column and came off the wire @@ -364,7 +372,7 @@ function storableTools(serverId: string, listed: ListedTool[]) { * is the sequence removed here; a schema with none is rebuilt identical. */ inputSchema: JSON.parse( - JSON.stringify(tool.inputSchema).replaceAll("\\u0000", ""), + JSON.stringify(tool.inputSchema ?? {}).replaceAll("\\u0000", ""), ), /* * What the vendor said, when the vendor said anything. @@ -2321,10 +2329,29 @@ export function createPluginStore(options: PluginStoreOptions) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", - targetId: serverId, + /* + * THE APP, not this row's id, and the same key `retireConnectionsFor` files under. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces is + * keyed on the app at the broker, whichever act produced it, so one query answers + * what happened to one person's brokered access. + * + * REASON. The two acts that can end such a connection were keyed differently: this + * one on `mcp_servers.id`, offboarding on `composio_connections.toolkit` — which is + * all that row records and all that is left once the server row is gone. Nothing + * holds the two strings equal, so on any renamed row half the trail is filed under a + * name the other half never mentions, and the disagreement is invisible everywhere + * they happen to match. + * + * THE APP IS WHAT WAS CONSENTED TO. The gate is `(toolkit, user_id)`, the delete + * above is by toolkit, and the row outlives the server row entirely; the id is a + * display key that may not exist by the time somebody asks. Which server row was + * removed is not lost — the `configuration.changed` row written below names it. + */ + targetId: toolkit, payload: { actor: by, - server: serverId, + server: toolkit, owner: connection.userId, // The same three-way distinction the vault loop above draws, and the same answer: an // administrator took the whole app away and the person did nothing. diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 33c274dbb..ad7772588 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -62,6 +62,14 @@ const botId = `agent_revoke_${suite}`; const askerId = `user_asker_${suite}`; /** Somebody who connected it and whose `users` row is then deleted out from under the connection. */ const leaverId = `user_leaver_${suite}`; +/** + * The same app under a display id that is NOT its slug, which is a legal row and an ordinary one. + * + * `mcp_servers.id` is what an operator sees and what a grant is written against; the slug in the + * url is what the broker is asked about. Nothing holds the two equal, and every fixture above + * spells them the same — which is exactly why a defect that only shows when they differ survived. + */ +const renamedId = `renamed-${suite}`; const admin = "admin@openbot.local"; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -155,8 +163,12 @@ function useAnsweringClient(actions: Partial = {}) { async function clean() { await database.delete(pluginGrants).where(eq(pluginGrants.agentId, botId)); await database.delete(agents).where(eq(agents.id, botId)); - await database.delete(mcpTools).where(eq(mcpTools.serverId, toolkit)); - await database.delete(mcpServers).where(eq(mcpServers.id, toolkit)); + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, [toolkit, renamedId])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [toolkit, renamedId])); await database .delete(composioConnections) .where(eq(composioConnections.toolkit, toolkit)); @@ -385,6 +397,77 @@ test("removing the app takes every brokered connection to it", async () => { }); }); +/** + * ONE KEY FOR "WHAT HAPPENED TO THIS PERSON'S ACCESS", across both acts that can end it. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces names the APP at + * the broker — in `targetId` and in `payload.server` — whichever act produced it. + * + * REASON. The two acts were written in different waves and keyed differently. Offboarding files + * under `connection.toolkit`, which is all a connection row records and all that is left once the + * server row is gone. Removing the app filed under the `mcp_servers` id. Where the two spellings + * agree — which they do in every other fixture in this file, and in the product whenever nobody + * renamed anything — the disagreement is invisible; where they differ, no single query answers + * what happened to one person's access, because half the rows are filed under a name the other + * half never mentions. + * + * THE APP IS THE RIGHT KEY, not the row id. A brokered connection is consent to an app: the gate + * is `(toolkit, user_id)`, `removeServer` clears it by toolkit, and the row outlives the + * `mcp_servers` row entirely — so the id is not always available and is never what was consented + * to. Which server row was removed is not lost either: the `configuration.changed` row written in + * the same call names it. + */ +test("both acts that end a brokered connection file it under the app", async () => { + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + // The row id and the app slug deliberately different, which is the only shape that can tell the + // two keys apart. + await database.insert(mcpServers).values({ + id: renamedId, + title: "Revocable App", + vendor: "Composio", + url: `composio://${toolkit}`, + provenance: "composio", + }); + await database.insert(composioConnections).values([ + { toolkit, userId: askerId }, + { toolkit, userId: leaverId }, + ]); + + // Offboarding one person, then removing the app out from under the other. + expect((await store.retireConnectionsFor(leaverId, admin)).retired).toBe(1); + await store.removeServer(renamedId, admin); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // Both rows, under one key. Asked as the set of keys rather than row by row, because what the + // criterion is about is a query finding all of them at once. + expect(new Set(disconnected.map((event) => event.targetId))).toEqual( + new Set([toolkit]), + ); + expect( + new Set( + disconnected.map((event) => (event.payload as { server: string }).server), + ), + ).toEqual(new Set([toolkit])); + + // And each still says which person and which of the three things happened to them, which is the + // other half of the question and was never the part that was wrong. + expect( + disconnected + .map((event) => event.payload as { owner: string; reason: string }) + .map(({ owner, reason }) => ({ owner, reason })) + .sort((left, right) => left.owner.localeCompare(right.owner)), + ).toEqual([ + { owner: askerId, reason: "mcp_server_removed" }, + { owner: leaverId, reason: "person_removed" }, + ]); +}); + /** * CONSENT MUST NOT REATTACH. * From 3ae8161fa6c450e1576543acc357373288acbb36 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 05:54:53 +0200 Subject: [PATCH 086/215] Ask one question about which connection rows this file owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refuse-to-run guard, the per-test sweep and the teardown each decided for themselves what makes a `composio_connections` row this file's, and all three answered differently. Two asked by PERSON across every app; one test's cleanup asked by the ANONYMOUS ACTOR across every app; the pair `("gmail", "")` was in none of them. So the file refused to run over rows it does not create, deleted rows it does not create, and left behind one that it does. Both halves of the key, once, as `ownedConnections`: every connection row this file writes is at `gmail`, for the two people it invents and the anonymous actor alike. A `("slack", "user_asker")` row is now neither refused over nor swept — and a `composio_connections` row is the entire gate on a brokered call, referenced by nothing, so deleting one is not recoverable by any operation the product has. One test did genuinely depend on owning `user_asker` at every app: it asserted a refusal because the row's url dialled `slack` and nobody had connected `slack`, which a real deployment row would have turned into a completed call reading as this gate being broken. The app it dials is now suite-scoped, which is what the property actually needs. The `user_leaver` select beside it is narrowed to the pair for the same reason. The witness row at `sweep_witness_${suite}`, which this file added and only its own `finally` removed, joins the teardown by its exact suffixed name: nothing else could reach it — the sweep is keyed on `gmail`, and the anonymous actor is what `retireConnectionsFor` refuses to act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 149 +++++++++++------- 1 file changed, 96 insertions(+), 53 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5ed224acc..b702749df 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -10,7 +10,7 @@ import { import { randomUUID } from "node:crypto"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; -import { and, asc, eq, gte, inArray, like, or, sql } from "drizzle-orm"; +import { and, asc, eq, gte, inArray, like, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import { @@ -252,6 +252,26 @@ let ownsFixtureIds = false; * So the deletes in {@link freshDatabase} are authorised by {@link ownsFixtureIds} and this is what * makes them safe. */ +/** + * Every `composio_connections` row this file may claim, as one clause used by all three sites. + * + * CRITERION. The refuse-to-run guard, the per-test sweep and the teardown ask exactly the same + * question, and a fixture added at a fourth pair has to change one place rather than three. + * + * REASON. They disagreed. The guard and the sweep asked by person across every app; one test's + * cleanup asked by the anonymous actor across every app; the pair `("gmail", "")` was in none of + * them. So the file refused to run on rows it does not create, deleted rows it did not create, and + * left behind one that it did — three faces of one confusion about what makes a row this file's. + * The app is half the answer: every row here is at `gmail`, because a Composio app IS its toolkit + * slug and this file asserts things about the real one. + */ +function ownedConnections() { + return and( + eq(composioConnections.toolkit, "gmail"), + inArray(composioConnections.userId, ["user_asker", "user_leaver", ""]), + ); +} + beforeAll(async () => { /* * Stamped here, in the first hook the file registers, so no row this run writes is older than it @@ -274,20 +294,25 @@ beforeAll(async () => { .from(agents) .where(eq(agents.id, "bot_helper")), /* - * The anonymous actor is one of these ids too, but only at the app this file writes it - * against. + * Brokered connections, keyed on the PAIR rather than on the person. + * + * CRITERION. This guard refuses on `("gmail", )` and on no other + * `composio_connections` row, because those three pairs are the only ones this file inserts + * and the only ones it deletes. * - * CRITERION. This guard refuses on the PAIR `("gmail", "")` and on no other row at the - * anonymous actor, because that pair is the only one this file inserts and the only one it - * deletes. + * REASON. Every connection row this file writes is at `gmail` — the two people it invents + * and the anonymous actor alike — so the app is half of what makes a row this file's, and + * asking by person alone claims rows at every other app as well. Both spellings of that + * over-reach have already cost something. `user_id = ''` caught the anonymous row + * `composio-connections.test.ts` writes against its own run-suffixed app, so a run of that + * file killed before its cleanup refused every test here for good; `user_id IN (asker, + * leaver)` claims a `("slack", "user_asker")` row the same way, and `freshDatabase` would + * then DELETE it — a `composio_connections` row is the entire gate on a brokered call, and + * nothing else can find it again. * - * REASON. `composio_connections.user_id` is notNull and notNull does not exclude the empty - * string, so `("gmail", "")` is a row a deployment can legally hold — which is the whole - * point of the test that inserts one. Asked as `user_id = ''` alone, this also caught the - * anonymous row `composio-connections.test.ts` writes against its OWN run-suffixed app: a - * run of that file killed between its insert and its cleanup left a row nobody here owns, - * and every one of this file's tests then refused for good over it. Keyed on the pair, a - * stranded row belonging to another file is simply not this one's business. + * The anonymous actor is one of the three because `user_id` is notNull and notNull does not + * exclude the empty string, so `("gmail", "")` is a row a deployment can legally hold, which + * is the whole point of the test that inserts one. */ database .select({ @@ -295,15 +320,7 @@ beforeAll(async () => { userId: composioConnections.userId, }) .from(composioConnections) - .where( - or( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), - and( - eq(composioConnections.toolkit, "gmail"), - eq(composioConnections.userId, ""), - ), - ), - ), + .where(ownedConnections()), /* * The person, who was missing from this guard entirely. * @@ -3687,32 +3704,23 @@ async function freshDatabase(): Promise { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); - // By person, never by toolkit: the table has no foreign key to `users` — which is the property the - // first test below is about, so nothing else removes these rows — and a delete by toolkit alone - // would take every person's Gmail connection, leaving one orphaned at the broker with no local row - // left to find it by. Only the two people this file invents. - await database.delete(composioConnections).where( - or( - inArray(composioConnections.userId, ["user_asker", "user_leaver"]), - /* - * And the one pair at the anonymous actor this file writes, which no person id names. - * - * CRITERION. Exactly `("gmail", "")`, never `user_id = ''` across every app: the second - * spelling reaches the anonymous row another file owns at its own run-suffixed app. - * - * REASON. The test that inserts this pair takes it back in a `finally`, which covers a - * failed assertion and not a killed process — and the row it would otherwise leave is - * precisely what the guard at the top of this file refuses on. Swept here, a pair stranded - * earlier in this same run is gone before the next test looks at it; a pair that was - * already there when the run started is still the guard's to refuse, because at that point - * nothing has established it is ours. - */ - and( - eq(composioConnections.toolkit, "gmail"), - eq(composioConnections.userId, ""), - ), - ), - ); + /* + * Brokered connections, by the pair and never by half of it. + * + * NEITHER HALF ALONE. By toolkit it would take every person's Gmail connection, leaving one + * orphaned at the broker with no local row to find it by — the table has no foreign key to + * `users`, which is the property the first test below is about, so nothing else would ever + * remove it. By person it would take a `("slack", "user_asker")` row belonging to somebody else, + * for the same reason and at the same cost. {@link ownedConnections} is what the guard at the + * top of this file has already established nothing else holds. + * + * The anonymous pair is swept here as well as in the `finally` of the test that inserts it, + * because that `finally` covers a failed assertion and not a killed process — and the row it + * would leave is what the guard refuses on. A pair stranded earlier in this run is therefore + * gone before the next test looks; a pair that was already there when the run started is still + * the guard's to refuse, because at that point nothing has established it is ours. + */ + await database.delete(composioConnections).where(ownedConnections()); // The person the connection outlives, who is a row in `users` like anybody else. Reached only // through the check at the top of this function, because there is no suffix on this id to tell a // fixture apart from somebody's account and ten cascades sit behind the difference. @@ -3862,9 +3870,21 @@ afterAll(async () => { await database .delete(mcpServers) .where(inArray(mcpServers.id, ["gmail", "notion"])); + await database.delete(composioConnections).where(ownedConnections()); + /* + * And the witness row, which is at neither `gmail` nor any person. + * + * CRITERION. Nothing at `sweep_witness_${suite}` outlives this run. + * + * REASON. It is removed in its own test's `finally`, which a killed process does not run — and + * nothing else would reach it: `ownedConnections` is keyed on `gmail`, and the anonymous actor + * is precisely what `retireConnectionsFor` refuses to act on, so no operation in the product + * could clear it either. Named exactly rather than by prefix, because another run's witness is + * that run's to take back. + */ await database .delete(composioConnections) - .where(inArray(composioConnections.userId, ["user_asker", "user_leaver"])); + .where(eq(composioConnections.toolkit, `sweep_witness_${suite}`)); await database.delete(agents).where(eq(agents.id, "bot_helper")); await database.delete(users).where(eq(users.id, "user_leaver")); }); @@ -3881,10 +3901,23 @@ test("a Composio connection row survives the person being deleted", async () => await database.delete(users).where(eq(users.id, "user_leaver")); + /* + * Asked at the app this test connected, not at every app this person might hold. + * + * The guard at the top of this file is keyed on the pair, so a `("slack", "user_leaver")` row + * belonging to somebody else is deliberately allowed to exist — and asking by person alone would + * then read it into this assertion and fail over a row that has nothing to do with the property + * under test. + */ const rows = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) - .where(eq(composioConnections.userId, "user_leaver")); + .where( + and( + eq(composioConnections.toolkit, "gmail"), + eq(composioConnections.userId, "user_leaver"), + ), + ); // The whole reason this table exists rather than reusing mcp_user_credentials: offboarding has to // still find the connection and revoke it at Composio after the person is gone, and there is no @@ -4151,9 +4184,19 @@ test("a Composio call whose row id and url name different apps is refused", asyn return vendorAnswered(); }, }); - // The row is called `gmail` and the person has connected `gmail`; the url dials Slack, which is - // the app the call would actually run in. - await seedComposioGmail(database, store, { url: "composio://slack" }); + /* + * The row is called `gmail` and the person has connected `gmail`; the url dials some OTHER app, + * which is the one the call would actually run in. + * + * SUITE-SCOPED, and that is not cosmetic. Spelled `slack`, this test asserted a refusal on the + * strength of `("slack", "user_asker")` not existing anywhere in the database — so it depended + * on this file owning a production person id at every app in the world, and a real deployment + * row at that pair would have turned the refusal into a completed call and read as this gate + * being broken. An app nobody can have connected is what the property actually needs. + */ + await seedComposioGmail(database, store, { + url: `composio://unconnected_${suite}`, + }); await expect( store.callTool({ From ba8519a5c5c4e7ad55fbb0bb1470d8b1fbadbac8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:06:14 +0200 Subject: [PATCH 087/215] Put a failed query on the shelf that is raised rather than relayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-list replace was fixed at its own site; every other query of ours throws the same `DrizzleQueryError` — `Failed query:` plus the statement, then `params:` and every bound value — into a `catch` that copies `error.message` onward. On the call path those values are credential ids, user ids and server ids, and the message reached three audiences at once: a model's tool result ("That tool could not be called: "), the `failure` field of the `mcp.call_failed` audit row, and a signed-in browser as a 502 labelled `failed: true`, which also asserted a vendor had answered. On the refresh path it went into `lastError`, the column the Plugins page draws as what the vendor said. `isDeploymentFault` now answers for a query failure too, recognised by shape: `query` and `params` as own properties of an `Error`. By shape and not by class deliberately — drizzle's class is reachable only through a deep import that is not part of its published surface, and the shape is what makes the message dangerous. `withoutStatement` is what every site that copies a message now asks, and `deploymentFaultSentence` is what the admin route shows. The narrowing in the refresh's vendor `catch` was DEAD: it tested for `PluginInvariantError`, and its own comment named two throws that cannot arrive there — `connectionTokenFor` is not called for `composio`, the only brokered transport, and the `person-oauth` narrowing is unreachable because `accessFor` answers that credential only for a `user-oauth` entry. It could be deleted with every test green while the arrival it should have caught went past it into the column. It asks about the whole shelf now, and the query failure is the member that actually gets there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 6 +- server/src/plugins/store.ts | 107 ++++++- server/tests/plugin-routes.test.ts | 35 +++ server/tests/plugin-store.integration.test.ts | 273 ++++++++++++++++++ 4 files changed, 412 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 639b72d69..4b8868553 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -19,6 +19,7 @@ import { CustomServerRefusedError, type OAuthClient, type PluginKind, + deploymentFaultSentence, isDeploymentFault, PluginRefusedError, type PluginStore, @@ -349,7 +350,10 @@ export function createPluginRoutes( * what the sentence tells the reader to go and do. */ if (isDeploymentFault(error)) { - return context.json({ error: error.message }, 409); + // `deploymentFaultSentence` rather than `error.message`: the shelf now includes a query + // this database refused, and that one's message is the statement and every value bound to + // it. An administrator is entitled to the reason, not to the dump. + return context.json({ error: deploymentFaultSentence(error) }, 409); } throw error; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 353c6eae8..166b5b81d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -289,6 +289,50 @@ export class PluginInvariantError extends Error { */ const NUL = "\u0000"; +/** + * Whether a throw is a query failure carrying the statement and the values bound to it. + * + * CRITERION. Anything this answers true for has a message that must never be relayed — not to a + * model, not to a browser, not into a column an operator reads. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError` and puts `Failed query: ` and `params: ` in its `message`. Along the tool-call path those + * values are credential ids, user ids and server ids; along the refresh path they are the vendor's + * entire tool list. + * + * BY SHAPE, NOT BY CLASS, and that is the one place this file departs from its own "tell them apart + * by a class, never by prose" rule. The class is drizzle's, reachable only through a deep import + * that is not part of its published surface, so an `instanceof` here would pin this deployment to + * an internal path a minor release may move. `query` and `params` as own properties on an `Error` + * is not prose — it is the shape the constructor assigns, it is what makes the message dangerous, + * and anything else carrying both fields is a query failure too. + */ +function isQueryFailure( + error: unknown, +): error is Error & { query: unknown; params: unknown } { + return ( + error instanceof Error && + Object.hasOwn(error, "query") && + Object.hasOwn(error, "params") + ); +} + +/** + * As much of a failure as may be shown to whoever is entitled to see it. + * + * CRITERION. Every place that copies a message out of a caught error asks this instead of reading + * `.message`. What comes back never contains a statement or a bound value. + * + * REASON. The message is the useful thing for a vendor's refusal, a person's missing connection or + * an invariant of ours — that is why those paths quote it, and they should go on quoting it. It is + * the wrong thing for exactly one kind of error, and that kind announces itself by shape. Asking + * here rather than at each site means a new audience cannot be added without the question already + * answered for it. + */ +function withoutStatement(error: Error): string { + return isQueryFailure(error) ? databaseComplaint(error) : error.message; +} + /** * The driver's own complaint about a query, without the query. * @@ -394,10 +438,27 @@ function storableTools(serverId: string, listed: ListedTool[]) { export function isDeploymentFault(error: unknown): error is Error { return ( error instanceof ServerUnresolvableError || - error instanceof PluginInvariantError + error instanceof PluginInvariantError || + /* + * A query this database refused is on the shelf for the reason the other two are: it is not a + * vendor's doing, it is not the asker's to act on, and its message is the one thing here that + * must not travel. The replace in `refreshTools` was fixed at its own site; every other query + * on the call path — the advertised-tool read, the connection gate, the vault read, the locked + * credential swap — throws the same shape into a `catch` that copies `error.message` onward, + * so answering it here is what makes the four audiences agree without four more branches. + * + * Callers that SHOW the sentence to an operator must still ask {@link withoutStatement} for + * it rather than reading `.message`; this predicate settles who may be told, not what. + */ + isQueryFailure(error) ); } +/** The operator-facing sentence for a fault on that shelf, with no statement in it. */ +export function deploymentFaultSentence(error: Error): string { + return withoutStatement(error); +} + /** * The vendor's `error` code, when a token endpoint refuses an exchange. * @@ -2492,14 +2553,34 @@ export function createPluginStore(options: PluginStoreOptions) { }); } catch (error) { /* - * The narrowing throws in `connectionTokenFor` are ours, not a vendor's. + * Ours rather than a vendor's, asked as one question about the whole shelf. * - * They fire for a row that resolved to a brokered credential with no app in its url, or to a - * per-person credential with no `user-oauth` entry — contradictions between this deployment's - * own tables and its own code. Raised rather than recorded, so the page does not send whoever - * reads it to somebody else's status page. + * CRITERION. Nothing on the `isDeploymentFault` shelf is written into `lastError`, and + * nothing raised from here carries a statement or a bound value. + * + * WHAT THIS USED TO BE, and why the difference is not cosmetic. It read `error instanceof + * PluginInvariantError` — which was DEAD, and its own comment named two throws that cannot + * arrive here: `connectionTokenFor` is only called when `transport.listNeedsCredential`, + * which is false for `composio`, the only brokered transport, so the brokered narrowing + * cannot fire inside this `try`; and the `person-oauth` narrowing is unreachable because + * `accessFor` answers that credential only for a `user-oauth` entry. So the line could be + * deleted with every test still green while the arrival it should have been catching — + * a query of ours failing — went straight past it into the column below. + * + * A QUERY FAILURE IS THE REACHABLE ONE. `connectionTokenFor`'s vault read, its connection + * lookup and its locked credential swap all run inside this `try` for an MCP listing, and + * each throws a `DrizzleQueryError` whose message is the statement plus every value bound + * to it. Recorded, that put a SQL dump in the column the Plugins page draws, under a + * heading that says a vendor said it. Raised as an invariant of ours, with the driver's + * complaint and none of the query. */ - if (error instanceof PluginInvariantError) throw error; + if (isDeploymentFault(error)) { + throw isQueryFailure(error) + ? new PluginInvariantError( + `${row.id}: asking this app what it offers failed on a query of this deployment's own, so nothing about the app was learned and nothing it holds was changed. ${databaseComplaint(error)}`, + ) + : error; + } const message = error instanceof McpServerError || error instanceof Error @@ -3819,8 +3900,18 @@ export function createPluginStore(options: PluginStoreOptions) { ...(input.initiator ? { initiator: input.initiator } : {}), payload: { ...decided, + /* + * Asked through {@link withoutStatement}, because not every throw in this block is a + * vendor's sentence. + * + * The vendor's own words are what this field is for and are kept. But every query on + * the way here throws a `DrizzleQueryError` whose message is our statement and its + * bound values — credential ids, user ids, server ids — and `audit_events` is read by + * an operator and exported. A dump in the row that records a failed call is the same + * disclosure the tool-list replace was fixed for, in the trail rather than on a page. + */ failure: (error instanceof Error - ? error.message + ? withoutStatement(error) : String(error) ).slice(0, 400), }, diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 205928e5f..84272ff0a 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -169,6 +169,41 @@ describe("refreshing a server that cannot be resolved", () => { expect(await response.json()).toEqual({ error: sentence }); }); + test("a failed query comes back as the reason, never as the statement", async () => { + /* + * The shape drizzle throws: `Failed query:` plus the whole statement, then `params:` and every + * value bound to it, with the driver's own error on `cause`. It is on the same shelf as the + * refusals above — not a vendor's doing, not the asker's to act on — so this route is where an + * operator is told about it, and it is the one member of that shelf whose `message` must not be + * what they are told. + */ + const request = refreshApp(async () => { + throw Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: someone', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["someone"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + }); + + const response = await request(); + expect(response.status).toBe(409); + const body = (await response.json()) as { error?: string }; + // The reason, which is what an administrator can act on. + expect(body.error).toContain( + "canceling statement due to statement timeout", + ); + // And none of the query. This route answers an administrator, but the browser it answers is + // still on somebody's laptop and the sentence still ends up in a screenshot and a ticket. + expect(body.error).not.toContain("Failed query"); + expect(body.error).not.toContain("params:"); + expect(body.error).not.toContain("mcp_user_credentials"); + }); + test("a failure that is not one of ours is still not dressed up as one", async () => { // The must-not case, the same one the add route above carries: a database that is down is not // a row an administrator can go and correct, and answering 409 would send them to do it. diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index b702749df..6a28ac053 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -5439,6 +5439,279 @@ describe("a listing this database would not have taken", () => { }); }); +/** + * A QUERY OF OURS THAT FAILED, on the two paths that copy a caught message onward. + * + * WHAT THE SHAPE IS. drizzle wraps every failure as a `DrizzleQueryError`: `message` is `Failed + * query:` plus the whole statement, then `params:` and every value bound to it, with the driver's + * own error on `cause`. On the tool-call path those values are credential ids, user ids and server + * ids; on the refresh path they are the vendor's tool list. + * + * WHERE IT COMES FROM. A per-person MCP listing is the shape that runs a query of ours inside the + * block that catches the vendor's failures: `connectionTokenFor` reads the asking person's stored + * grant there. `composio` never gets that far — `listNeedsCredential` is false for it, the only + * brokered transport — and a server added by URL reads its one token from the vault rather than + * from a query. So the failure is injected at that one read and arrives exactly where it would in + * production, rather than being handed to the `catch` from somewhere it could not come from. + */ +describe("a query of this deployment's own that failed", () => { + /** The drizzle shape, spelled once: statement and bound values in `message`, driver on `cause`. */ + function queryFailure() { + return Object.assign( + new Error( + 'Failed query: select "credential_id" from "mcp_user_credentials" where "user_id" = $1 params: user_asker', + ), + { + query: 'select "credential_id" from "mcp_user_credentials"', + params: ["user_asker"], + cause: new Error("canceling statement due to statement timeout"), + }, + ); + } + + test("a refresh raises it rather than recording it as what the vendor said", async () => { + const { database } = await freshStore(); + + /* + * Notion, because a per-person MCP listing is the only shape that runs a query of ours inside + * the vendor `try`. `composio` never gets there — `listNeedsCredential` is false for it, so + * `connectionTokenFor` is not called at all — and a server added by URL reads its one token + * from the vault rather than from a query. Resolved from the catalogue rather than spelled, so + * a renamed slug breaks this file instead of quietly emptying it. + */ + const notion = catalogueEntry("notion"); + if (!notion) { + throw new Error( + "catalogue slug `notion` is gone, so nothing here reaches a per-person listing", + ); + } + + // `user_leaver` rather than a new id: this file already owns a `users` row at it, so the + // person, the connection and the server row are all cleaned by machinery that exists. + await database.insert(users).values({ + id: "user_leaver", + email: "leaver@example.com", + name: "Leaver", + }); + const [grant] = await database + .insert(credentialRows) + .values({ + kind: "mcp_user_token", + provider: "notion", + keyId: "user_leaver", + encryptedValue: "{}", + metadata: {}, + }) + .returning({ id: credentialRows.id }); + if (!grant) throw new Error("grant row was not created"); + await database.insert(mcpServers).values({ + id: "notion", + title: notion.title, + vendor: notion.vendor, + url: `${notion.host}${notion.path}`, + provenance: "first-party", + }); + await database.insert(mcpUserCredentials).values({ + serverId: "notion", + userId: "user_leaver", + credentialId: grant.id, + scope: "", + }); + + /* + * The stored-grant read, failed — and nothing else. + * + * Derived from the real database so every other query the refresh makes is the real query. + * The second `select` is the one: `requireServer` reads the server row first, outside the + * block that catches vendor failures, and `connectionTokenFor`'s read of this person's grant + * is the next one and is inside it. Counting is what makes the failure land there rather than + * somewhere a blanket override would put it, and the assertions below distinguish the two — + * the row id in the message is added only by the conversion in that `catch`, so a failure + * escaping the earlier read would arrive as the raw dump and redden. + */ + let selects = 0; + const refusing: Database = Object.create(database); + Object.defineProperty(refusing, "select", { + value: (...args: never[]) => { + selects += 1; + if (selects === 2) throw queryFailure(); + return database.select(...args); + }, + }); + const failing = createPluginStore({ + database: refusing, + auditStore: { insert: async () => {} }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + }); + + let thrown: unknown; + try { + await failing.refreshTools("notion", "user_leaver"); + } catch (error) { + thrown = error; + } + + try { + const [row] = await database + .select({ + lastError: mcpServers.lastError, + toolsRefreshedAt: mcpServers.toolsRefreshedAt, + }) + .from(mcpServers) + .where(eq(mcpServers.id, "notion")); + + /* + * Nothing in the column, asserted FIRST because it is the half that was actually broken and + * because its failure prints what leaked. + * + * `lastError` is drawn on the Plugins page beside a refresh that looks merely to have + * failed, and the narrowing meant to keep our own faults out of it tested for a class that + * cannot arrive inside that `try` at all — so it could be deleted with every test green + * while the statement and every value bound to it went into a column an operator reads and + * an export carries. + */ + expect(row?.lastError).toBeNull(); + expect(row?.toolsRefreshedAt).toBeNull(); + + // Raised, because a query this database refused is not something the vendor did — the same + // criterion the replace further down this method is held to. + expect(isDeploymentFault(thrown)).toBe(true); + const message = thrown instanceof Error ? thrown.message : String(thrown); + expect(message).toContain("canceling statement due to statement timeout"); + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("params:"); + expect(message).not.toContain("mcp_user_credentials"); + // Named by the conversion inside the refresh's own `catch`, which is how this asserts WHERE + // the failure was classified and not merely that something was thrown. + expect(message).toContain("notion:"); + } finally { + /* + * Locally, and in this order. `mcp_user_credentials.credential_id` is a real foreign key + * that deliberately does not cascade, so the join row has to go before the vault row — and + * the teardown that clears vault rows for this file runs before the one that clears server + * rows, which is what would otherwise leave a delete refusing. + */ + await database + .delete(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, "notion"), + eq(mcpUserCredentials.userId, "user_leaver"), + ), + ); + await database + .delete(credentialRows) + .where(eq(credentialRows.id, grant.id)); + } + }); + + test("the model is told the call did not happen, and none of the query", async () => { + /* + * At the seam that decides, which is where the leak was. + * + * `grantedTools` takes a store, and the question is what it hands the model when that store + * throws — so the store is the thing stubbed and nothing else is. Every query on the call path + * runs inside `callTool`'s own recording block and comes out of it unchanged, so this shape + * arriving here is the production arrival, not an approximation of one. + */ + const [tool] = await grantedTools({ + store: { + listForAgent: async () => ({ + tools: [ + { + ref: "gmail/GMAIL_FETCH_EMAILS", + toolName: "gmail__GMAIL_FETCH_EMAILS", + description: "Fetch emails.", + inputSchema: { type: "object", properties: {} }, + }, + ], + skills: [], + }), + callTool: async () => { + throw queryFailure(); + }, + } as unknown as PluginStore, + botId: "bot_helper", + actorId: "user_asker", + }); + if (!tool) throw new Error("the Bot was offered no tool to call"); + + const answer = await tool.execute({}); + + /* + * What the model is handed, exactly. + * + * Not the statement and not the values bound to it — on this path those are credential ids, + * user ids and server ids. Not a sentence blaming the vendor either: the call never reached + * one, and `That tool could not be called: ` is what the model used to be given to + * explain the failure to the person asking. + */ + expect(answer).toBe("That tool could not be called."); + expect(answer).not.toContain("Failed query"); + expect(answer).not.toContain("params:"); + expect(answer).not.toContain("mcp_user_credentials"); + }); + + test("the trail gets the reason and none of the query", async () => { + const database = await freshDatabase(); + const events: { eventType: string; payload: unknown }[] = []; + /* + * Thrown at the vendor seam, and recorded by the block above it. + * + * WHERE THIS ARRIVES FROM IN PRODUCTION: `connectionTokenFor`, three lines earlier and inside + * the same `try` — its connection gate read, its vault read and its locked credential swap + * are all queries of ours. Reaching one of those and failing only it needs a counted override + * of every `select` the call path makes, which pins a test to the order of queries rather than + * to the property. `callVendor` is the one seam this store hands a caller, and a throw through + * it lands in exactly the `catch` those queries land in; what that `catch` can do about a + * throw is classify it, which is the property. + */ + const failing = createPluginStore({ + database, + auditStore: { + insert: async (event) => { + events.push(event as (typeof events)[number]); + }, + }, + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + callVendor: async () => { + throw queryFailure(); + }, + }); + await seedComposioGmail(database, failing); + + await expect( + failing.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(); + + const failed = events.filter( + (event) => event.eventType === "mcp.call_failed", + ); + expect(failed).toHaveLength(1); + const failure = + (failed[0]?.payload as { failure?: string } | undefined)?.failure ?? ""; + /* + * The reason, because "is this connector working" is asked of this row and the driver's + * complaint answers it. Not the statement and not the values bound to it: on this path those + * are credential ids, user ids and server ids, and `audit_events` is read by an operator and + * carried out of the deployment by an export. + */ + expect(failure).toContain("canceling statement due to statement timeout"); + expect(failure).not.toContain("Failed query"); + expect(failure).not.toContain("params:"); + expect(failure).not.toContain("mcp_user_credentials"); + }); +}); + /** * The genuine empty listing, which has to stay recordable. * From d09f91ff5e8392cd0c57721bcfc67780bbc80c03 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:07:43 +0200 Subject: [PATCH 088/215] Map a deployment fault on the routes that call the same method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a server refreshes it before answering — deliberately, so a bad credential is reported now rather than the first time a Bot uses it — so everything `refreshTools` raises arrives on the add routes too, and the two faults added in this branch are both reachable there: a vendor listing one action twice, and a query of ours failing. Registering an OAuth client resolves the row first, so a row whose columns contradict each other refuses there as well. All three mapped only `CatalogueEntryUnknownError` and `CustomServerRefusedError`, so the same fault was a named sentence on the refresh button and "That did not work" on the add form. Mapping one route and not its siblings is the shape that made this class hard to see the first time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 27 +++++++++++++++++++++++++++ server/tests/plugin-routes.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 4b8868553..14746b975 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -213,6 +213,23 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + /* + * The same mapping the refresh route makes, on the routes that call the same method. + * + * CRITERION. Every admin route whose store call can reach a fault on the + * `isDeploymentFault` shelf answers with the sentence rather than leaving it to the default + * handler. + * + * REASON. Adding a server REFRESHES it before answering — deliberately, so a bad credential + * is reported now rather than the first time a Bot uses it — so every fault `refreshTools` + * raises arrives here too, and a vendor listing one action twice or a query of ours failing + * is exactly that. Mapped on one route and not on its siblings, the same fault is a named + * sentence or "That did not work" depending on which button was pressed, which is the shape + * that made this class hard to see the first time. + */ + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -257,6 +274,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // As on the curated add above, and for the same reason: this path refreshes before it + // answers. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); @@ -301,6 +323,11 @@ export function createPluginRoutes( ) { return context.json({ error: error.message }, 400); } + // Registering a client resolves the row first, so a row this deployment cannot say how to + // reach refuses here as well. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } throw error; } }); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 84272ff0a..529ba6311 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -5,6 +5,7 @@ import { ServerRowAmbiguousError } from "../src/plugins/access"; import { CatalogueEntryUnknownError, CustomServerRefusedError, + PluginInvariantError, } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; @@ -85,6 +86,29 @@ describe("adding a curated server", () => { expect((await request({ key: "nope" })).status).toBe(400); }); + test("a row the deployment cannot resolve comes back with its sentence", async () => { + /* + * ADDING REFRESHES, which is what puts this fault on this route. + * + * `addServer` asks the vendor what it offers before it answers — deliberately, so a bad + * credential is reported now rather than the first time a Bot uses one — so everything + * `refreshTools` raises arrives here as well: a vendor listing one action twice, a query of + * ours failing, a row whose two columns contradict each other. Unmapped, all of it left the + * route on the default path and the admin page said "That did not work", while the SAME fault + * on the refresh button said which row and what to do about it. + */ + const sentence = + "notion: the actions this app listed were not stored, so what it already had is unchanged."; + const request = appWith(async () => { + throw new PluginInvariantError(sentence); + }); + + const response = await request({ key: "notion" }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: sentence }); + }); + test("a failure that is not a refusal is not dressed up as one", async () => { // The must-not case. Mapping every throw to 400 would tell an administrator to correct their // input when the database is down, and would hide a real fault behind a message about From d4ed3d31a3121c7ab0867d9002c970d85ad969a9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:08:58 +0200 Subject: [PATCH 089/215] Cover the read that decides whether to revoke a credential again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeServer` reads liveness from `credentials` before asking the vault to revoke, and nothing asserted it. The test beside this one inserts a live row and takes the true branch; the one after it has no credential at all and never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite green — while in production `credentials.revoke` throws "not found or already revoked", which propagates before `delete(mcpServers)` and leaves a server row no number of attempts can remove, on a DELETE route with no catch. A previous removal that failed after the revoke, or a key rotated by hand, both produce the row. Asserted as "the vault was not asked" rather than as the absence of a throw: the stub here is deliberately forgiving, so a test waiting for it to complain would pass with the clause gone. What the read decides is whether the call is made at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 6a28ac053..2adb355e0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -955,6 +955,80 @@ describe("removing an MCP server", () => { } }); + /** + * A credential that was already retired, and the read that decides whether to retire it again. + * + * CRITERION. `removeServer` must not ask the vault to revoke a credential whose row already + * carries a `revoked_at`, and must still remove the server row. + * + * REASON. It reads liveness from the table before deciding, and nothing asserted that. The test + * above inserts a LIVE row and so takes the true branch; the one below has no credential at all + * and so never runs the query. So `isNull(revoked_at)` could be dropped with the whole suite + * green — and in production `credentials.revoke` throws "not found or already revoked", which + * propagates before `delete(mcpServers)` and leaves a server row that cannot be removed by any + * number of attempts, on a route with no `catch`. Two ordinary states produce the row: a + * previous removal that failed after the revoke, and a key rotated by hand. + * + * ASSERTED AS "revoke was not called", not as the absence of a throw. The vault here is a stub + * that is deliberately forgiving — it stamps whatever id it is handed — so a test waiting for it + * to complain would pass with the clause gone. What the read decides is whether the call is made + * at all, and that is what {@link revokedCredentialIds} records. + */ + test("does not ask the vault to revoke a credential already revoked", async () => { + const removalServerId = `removal-target-retired-${suite}`; + revokedCredentialIds.length = 0; + const revokedAt = new Date(); + const [credentialRow] = await database + .insert(credentialRows) + .values({ + kind: "mcp", + provider: removalServerId, + keyId: `mcp-${removalServerId}`, + encryptedValue: "{}", + metadata: {}, + revokedAt, + updatedAt: revokedAt, + }) + .returning({ id: credentialRows.id }); + const credentialId = credentialRow?.id; + if (!credentialId) throw new Error("credential row was not created"); + issuedCredentialIds.push(credentialId); + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target with a retired credential", + vendor: "test", + url: "https://example.invalid/mcp", + credentialId, + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + // Not asked, because the row already says it is retired. + expect(revokedCredentialIds).toEqual([]); + // And the server row is gone, which is the act an administrator asked for and the thing a + // throw from the vault would have prevented. + expect( + await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, removalServerId)), + ).toEqual([]); + // No second revocation in the trail either: a row saying access ended twice is a row an + // auditor has to reconcile against nothing having happened. + expect( + await database + .select({ id: auditEvents.id }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "credential.revoked"), + eq(auditEvents.targetId, credentialId), + ), + ), + ).toEqual([]); + }); + test("does not call revoke when the server had no credential", async () => { const removalServerId = `removal-target-nocred-${suite}`; revokedCredentialIds.length = 0; From 5c66242d50ad915c7558b8f471297c0cec5da413 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:14:17 +0200 Subject: [PATCH 090/215] Read the vendor call's fields by name in the stubs that record them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport's execute took its app, action, person and version positionally and now takes them as one named record, so slug and toolkit cannot silently transpose. These stubs still destructured positionally, which bound the whole record to what they called the slug — so the assertions about which person and which version reached the vendor were comparing against an object. Re-proved by mutation rather than by the suite going green: passing the Bot's id where the asker's belongs still reddens both identity tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 2 +- server/tests/plugin-store.integration.test.ts | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index ad7772588..60ea85124 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -151,7 +151,7 @@ const answered: ComposioResult = { data: {}, error: null, successful: true }; function useAnsweringClient(actions: Partial = {}) { useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return answered; }, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 2adb355e0..1da146823 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4030,7 +4030,7 @@ test("a Composio call with nobody attributed is refused before it reaches the ve const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4072,7 +4072,7 @@ test("a Composio call with nobody attributed is refused even when a connection r const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4155,7 +4155,7 @@ test("a Composio call by somebody who has not connected the app is refused with const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4198,7 +4198,7 @@ test("a Composio call by somebody who has not connected the app is refused even const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4226,7 +4226,7 @@ test("a Composio call whose url names no app is refused rather than falling back const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4253,7 +4253,7 @@ test("a Composio call whose row id and url name different apps is refused", asyn const reached: string[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug) => { + execute: async ({ slug }) => { reached.push(slug); return vendorAnswered(); }, @@ -4293,7 +4293,7 @@ test("a Composio call sends the version recorded for that action", async () => { const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4326,7 +4326,7 @@ test("a version a model supplied in its own arguments cannot beat the recorded o const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4355,7 +4355,7 @@ test("a version a model supplied cannot stand in for an action with none recorde const calls: { slug: string; version: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, _userId, version) => { + execute: async ({ slug, version }) => { calls.push({ slug, version }); return vendorAnswered(); }, @@ -4411,7 +4411,7 @@ test("a Composio call reaches the vendor as the person asking, not as the Bot", const reached: { slug: string; userId: string }[] = []; useComposioClient({ listActions: async () => [], - execute: async (slug, userId) => { + execute: async ({ slug, userId }) => { reached.push({ slug, userId }); return vendorAnswered(); }, @@ -4458,7 +4458,7 @@ test("an identity a model puts in the arguments does not change whose account th const reached: { userId: string; args: Record }[] = []; useComposioClient({ listActions: async () => [], - execute: async (_slug, userId, _version, args) => { + execute: async ({ userId }, args) => { reached.push({ userId, args }); return vendorAnswered(); }, From 848272b3324317cd9d1c222dbe15a3060da6b346 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 06:16:02 +0200 Subject: [PATCH 091/215] Stop the seam claiming an MCP server publishes no effect The paragraph said the optional fields on a listing were ones a broker publishes and an MCP server does not, and that mcp.ts read none of them. Both were false. The MCP specification defines a destructive hint, servers publish it, and it was being dropped -- so a tool its own vendor called destructive classified as a read wherever a curated write list had omitted it. Says what is true now, including which hint is deliberately not read and why: a hint may narrow what a Bot may do and may never widen it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/transport.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index cf225df8c..60a18b333 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -20,11 +20,19 @@ import * as mcp from "./mcp"; * * What has been added since is a SUPERSET of that shape rather than a departure from it, so an * MCP-shaped implementation still satisfies the seam unchanged. `listTools` answers `ListedTool[]`, - * which is `McpTool` plus optional fields a broker publishes and an MCP server does not; the - * connection carries an `actorId` and a `botId` for the transports whose authorization is the - * actor rather than a credential; and one reserved key on `args` hands a transport the recorded - * version of the action being called. Every addition is optional, which is why `mcp.ts` reads none - * of them and is still an implementation of this interface rather than an exception to it. + * which is `McpTool` plus fields describing what a listing said about an action; the connection + * carries an `actorId` and a `botId` for the transports whose authorization is the actor rather + * than a credential; and one reserved key on `args` hands a transport the recorded version of the + * action being called. Every addition is OPTIONAL, and that is what keeps an MCP-shaped + * implementation an implementation of this interface rather than an exception to it. + * + * This paragraph used to say those fields were ones "a broker publishes and an MCP server does + * not", and that `mcp.ts` therefore read none of them. Both were false: the MCP specification + * defines `annotations.destructiveHint`, servers do publish it, and `mcp.ts` was dropping it — so a + * tool a vendor declared destructive classified as a read wherever a curated write list omitted it. + * `mcp.ts` now reads that hint, and deliberately does not read `readOnlyHint`, because a hint may + * narrow what a Bot may do and may never widen it. The effect column is therefore not one + * transport's vocabulary; it is what any listing was willing to say. * * There are exactly two call sites in the whole system — the tool listing and the tool call — and * both take a transport from here. Nothing else reads a `TransportKind` at all: the OAuth flow, From 95012eb2624759bc47e297590eda65f3e2dab1b0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 14:18:11 +0200 Subject: [PATCH 092/215] Say that the initiator kind and the actor answer different questions The rebase brought main's initiator_kind alongside our actor field, and a run nobody can be named for now writes person as the initiator and unattributed as the actor. That reads as a contradiction and is not one: one field says what set the run in motion, the other says whose account it reached. Recorded rather than resolved. DEPLOYMENT_INITIATOR's own sentence claims the unidentifiable-caller case and would answer it the other way, but nothing sends it there, so the overlap is in the prose. Narrowing that sentence, or adding a third initiator kind, is not this branch's call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 166b5b81d..bbd8d3039 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -569,6 +569,20 @@ const iso = (value: Date | string | null): string | null => * * Neither is an address, so neither can collide with a user id: every actor written here otherwise * is `users.id` or the email a session resolved to. + * + * NOT THE SAME AXIS AS `initiator_kind`, and a row carrying both is not contradicting itself. + * `initiator_kind` answers what set a run in motion; this field answers whose account it reached + * and who can be named for it. So `initiator_kind: "person"` beside `actor: "unattributed"` reads + * correctly as a person-initiated request whose person this deployment could not identify. That is + * the honest reading, and it is the reason this is NOT recorded as `deployment`: that would assert + * the call went out on the deployment's own credential, and it did not go out at all. + * + * `DEPLOYMENT_INITIATOR`'s own doc claims the case of "refusing a caller it could not identify", + * which overlaps this one and would answer it the other way. Nothing sends it there — the tool path + * defaults its initiator to person and `identifyActor` returns an empty id rather than a deployment + * — so the overlap is in the prose, not in the behaviour. It is left alone deliberately rather than + * resolved by widening either vocabulary unilaterally; whoever owns that constant should narrow its + * sentence, or a third initiator kind should exist, and neither is this branch's call to make. */ const DEPLOYMENT_ACTOR = "deployment"; const UNATTRIBUTED_ACTOR = "unattributed"; From 21218e2266770bc5c9e0d5e957962cd186e3a88a Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 10 Sep 2026 15:31:35 +0200 Subject: [PATCH 093/215] Sort the import members this branch appended out of order Three files gained members appended after the type members of an existing import rather than in sorted position. No workflow runs biome's assist domain, so nothing would have caught it, and the pre-push gate found it by measuring this branch against main rather than by trusting that the findings were old. Applied by biome's own safe fix; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 4 ++-- server/src/plugins/store.ts | 10 +++++----- server/tests/plugin-store.integration.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 14746b975..68c54eba9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -17,10 +17,10 @@ import { import { CatalogueEntryUnknownError, CustomServerRefusedError, - type OAuthClient, - type PluginKind, deploymentFaultSentence, isDeploymentFault, + type OAuthClient, + type PluginKind, PluginRefusedError, type PluginStore, } from "./store"; diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index bbd8d3039..5c0d5373b 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -32,6 +32,11 @@ import { skills, skillTools, } from "../db/schema"; +import { + accessFor, + type ServerAccess, + ServerUnresolvableError, +} from "./access"; import { type CatalogueEntry, catalogueEntry, @@ -40,11 +45,6 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { - accessFor, - type ServerAccess, - ServerUnresolvableError, -} from "./access"; import { VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1da146823..c9b44055f 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -44,21 +44,21 @@ import { useComposioClient, } from "../src/plugins/composio"; import { redirectUriFor } from "../src/plugins/oauth"; -import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { type AccessToken, CustomServerRefusedError, createPluginStore, exchangeRefreshTokenOverHttp, INVALID_CLIENT, + isDeploymentFault, type OAuthClient, PluginInvariantError, PluginRefusedError, type PluginStore, - isDeploymentFault, TokenRefusedError, unlistedAdvertisedTools, } from "../src/plugins/store"; +import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; import { TEST_POOL } from "./support/database"; /** From fede96fa822eb798775d7d6a10fffb1b0640c1cd Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 03:38:38 +0200 Subject: [PATCH 094/215] Read the Composio key this deployment reaches the broker with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional, and unset is the ordinary state rather than a degraded one: it is what every deployment is today. Nothing here validates it, because length is not validation and a masked placeholder passes every local check — the first real request is what says whether the key works. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .env.example | 10 ++++++++++ docs/configuration.md | 1 + server/src/config.ts | 15 +++++++++++++++ server/tests/config.test.ts | 16 ++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/.env.example b/.env.example index 6d46581f2..ca78cafed 100644 --- a/.env.example +++ b/.env.example @@ -338,3 +338,13 @@ AGENT_TOOL_TOKEN= # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= + +# Composio, the broker that holds people's accounts for a few hundred apps, so a Bot can act in Gmail +# or Slack or Notion without this deployment registering an OAuth client with each of them. +# +# Optional. Without this key no Composio surface appears anywhere in the product: nothing to connect, +# nothing to grant, and no Composio tool for a Bot to call. +# +# One key for the whole deployment, not one per person. Composio keeps people apart by a user id sent +# with every call, so one account's connections are never reachable from another's. +COMPOSIO_API_KEY= diff --git a/docs/configuration.md b/docs/configuration.md index f7053e3f3..eae427254 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,6 +59,7 @@ at `agent-langgraph` on a laptop. | `AUDIT_RETENTION_DAYS` | unset | Whole number of days to keep audit rows; older ones are removed. Unset keeps the trail forever. | | `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. | | `OPENBOT_GENERATIVE_UI` | unset (capability off) | `true` or `1` lets a Bot answer with an interface it wrote itself. | +| `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, no Composio surface appears anywhere in the product. | **`OPENBOT_GENERATIVE_UI`** turns on generated interfaces. Set it, and a Bot may answer by writing the markup, styles and script for an interface and streaming it into the transcript, where it renders diff --git a/server/src/config.ts b/server/src/config.ts index 162dc36bf..90dcb1636 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -171,6 +171,20 @@ export type DeploymentConfig = { * packages but not a copy of one running alongside the original. See channels/thread-identity.ts. */ deploymentId: string | undefined; + /** + * The key this deployment talks to Composio with, the broker that holds people's accounts for a + * few hundred apps so a Bot can act in Gmail or Slack without an OAuth client of this + * deployment's own registered with each of them. + * + * Optional, and undefined is the ordinary state rather than a degraded one. A deployment that has + * not bought Composio is not a deployment missing something: no Composio surface appears anywhere + * in the product, and nothing else it does is any worse for that. + * + * Nothing here validates the key. There is no shape to check it against and no call worth making + * at boot to find out, so the first real request is what says whether it works — which is also + * where a key that was revoked last week would have surfaced regardless. + */ + composioApiKey: string | undefined; /** * Where this deployment is reached from outside, with no trailing slash. * @@ -975,6 +989,7 @@ export function loadConfig( ...(managedAgent ? { managedAgent } : {}), agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), + composioApiKey: optional(environment, "COMPOSIO_API_KEY"), publicUrl: ( optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl )?.replace(/\/+$/, ""), diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 45061e0f5..d481ef324 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -896,3 +896,19 @@ describe("how far a Bot may hand work on", () => { ).toThrow("BOT_HANDOFF_MAX_PER_RUN"); }); }); + +/** + * Composio, which a deployment either bought or did not. + * + * Unset is the ordinary state and not a degraded one, so the absence has to read as `undefined` + * rather than as an empty string that later code would have to keep asking about. Trimmed like + * every other secret here, because a key pasted into a hosting dashboard arrives with whatever + * whitespace came with it and the vendor would refuse the padded copy. + */ +test("a Composio key is read when set and absent when not", () => { + expect(loadConfig(baseEnvironment).composioApiKey).toBeUndefined(); + expect( + loadConfig({ ...baseEnvironment, COMPOSIO_API_KEY: " ak_example " }) + .composioApiKey, + ).toBe("ak_example"); +}); From dde57eb5b767bdbc47295d50f64a073d502b0f15 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 03:38:38 +0200 Subject: [PATCH 095/215] Name what this deployment needs of the broker beside calling a tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport seam covers the two things a call path does: list an app's actions, run one as a person. This is everything the product around it needs — find an app, set its authorization up, send somebody to consent, ask whether they came back, and end it. A narrow projection rather than the vendor's client, so one file will hold the SDK's shape and every test satisfies this with an object literal. That is what keeps the properties most worth asserting — whose id a link was minted for, what order a revoke happens in — out of the network's reach. The module imports nothing at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 131 ++++++++++++++++++++++ server/tests/composio-broker-seam.test.ts | 29 +++++ 2 files changed, 160 insertions(+) create mode 100644 server/src/plugins/broker.ts create mode 100644 server/tests/composio-broker-seam.test.ts diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts new file mode 100644 index 000000000..1fe25296f --- /dev/null +++ b/server/src/plugins/broker.ts @@ -0,0 +1,131 @@ +/** + * What openbot needs of Composio the BROKER, as against Composio the transport. + * + * `./composio` is about calling an action once an app is connected. This file is about everything + * that has to be true before that: which apps exist to choose from, which of them this deployment + * has an auth config for, whose account is attached to one, and how a person attaches or detaches + * theirs. Two different questions, so two different projections rather than one wide client. + * + * IT IMPORTS NOTHING, AND THAT IS THE POINT OF IT. Not `@composio/core`, not a type from elsewhere + * in this tree. Everything below is a name for a shape, so the vendor's package stays confined to + * the adapter that implements {@link ComposioBroker} — one file, replaceable, and the only place a + * version bump can reach. A module that named the vendor's types here would put their package on + * the import graph of every test that touches enablement. + */ + +/** + * One app in the catalogue, as much of Composio's toolkit listing as anything here reads. + * + * `logo` is nullable because the vendor publishes none for some toolkits, and an administrator + * picking from a list of a few hundred apps is better served by a missing image than by a broken + * one. + */ +export type BrokerApp = { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + /** + * How many actions the app publishes, shown BEFORE anybody enables it. + * + * Because the size is the decision. Enabling an app writes every one of its actions into + * `mcp_tools` and puts them in front of a model, so the difference between an app with six + * actions and one with sixty-three is the difference between a small addition and a rewrite of + * what the model sees. An administrator who learns the number only after enabling has already + * made the choice this field exists to inform. + */ + actionCount: number; +}; + +/** + * What this deployment needs of Composio's broker, and nothing more. + * + * A NARROW PROJECTION RATHER THAN THEIR CLIENT, for the reason the transport's `ComposioActions` is + * one: six methods is a shape a test satisfies with an object literal, so every test about + * enablement, connection and revocation is a test about this deployment's logic and none of them + * reaches the network. The vendor's client would drag its constructor, its retries and its schemas + * into each of those tests, and the first thing every one of them would do is find a way not to + * dial. + */ +export type ComposioBroker = { + /** Every app the catalogue offers, which is what an administrator chooses from. */ + listApps(): Promise; + /** + * Make sure this deployment has an auth config for the app, and say nothing if it already did. + * + * IDEMPOTENT, AND CALLED AT ENABLE TIME. An auth config is per-deployment rather than per-person — + * it is the thing a person's connection is then created against — so the natural moment to create + * it is when an administrator enables the app, and the natural number of times that moment + * happens is "more than once": an app can be enabled, removed and enabled again, and two + * administrators can press the button together. An implementation that created a second config + * on the second call would leave a person's existing connections pointing at the first one. + */ + ensureAuthConfig(config: { toolkit: string; name: string }): Promise; + /** Drop this deployment's auth config for the app, which is what removing an app has to do. */ + deleteAuthConfig(toolkit: string): Promise; + /** + * Begin one person's connection to one app, answering the url they have to visit. + * + * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to this person's + * connection, so it is neither stored nor logged nor put in an audit row: it is handed to the + * browser that asked for it and then forgotten. A redirect url in a log is somebody else's + * mailbox for as long as it stays valid. + */ + authorize(request: { + userId: string; + toolkit: string; + }): Promise<{ redirectUrl: string }>; + /** Whether this person currently has an account attached to this app at the vendor. */ + isConnected(request: { userId: string; toolkit: string }): Promise; + /** + * Withdraw this person's grant at the vendor, answering WHAT ACTUALLY HAPPENED. + * + * True where a grant was withdrawn, false where there was none to withdraw — not "the call did + * not throw". The audit trail records that answer as `mcp.account_disconnected`'s + * `vendorRevoked`, and the whole value of that field is that a reader can tell a grant this + * deployment ended from one that outlives it somewhere else. A boolean that always said true + * would make the row a worse record than no row. + */ + revoke(request: { userId: string; toolkit: string }): Promise; +}; + +/** + * The state a deployment with no Composio key is in, raised rather than returned. + * + * A STATE, NOT A FAULT, in the same sense `./composio`'s unconfigured listing is one: unset + * `COMPOSIO_API_KEY` is the documented default, and the whole brokered surface is absent by design + * where it is unset. It is a thrown class rather than a null answer because the broker's methods + * answer apps, booleans and urls, and there is no value in any of those shapes that means "nobody + * was asked" — an empty app list is indistinguishable from a catalogue outage, and `false` from + * {@link ComposioBroker.isConnected} is a positive claim about somebody's account. + * + * The setting is named in the message because the message is the whole remedy: an operator reading + * it needs the name of the variable to set, and nothing else about this deployment will tell them. + */ +export class BrokerUnconfiguredError extends Error { + constructor() { + super( + "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set, no Composio surface appears anywhere in the product.", + ); + this.name = "BrokerUnconfiguredError"; + } +} + +/** + * The one broker failure worth explaining to a reader, and null for every other one. + * + * NULL RATHER THAN A FALLBACK SENTENCE, which is the whole reason this is a function instead of an + * `error.message` read at each call site. The only thing this module can say with certainty about a + * thrown broker error is that a deployment with no key was asked to do something; a socket that + * hung up, a 500 from the catalogue and a rate limit are all failures it knows nothing about. A + * function that answered those with a sentence of its own would be telling an operator to set a + * setting that is already set, and sending them to their configuration while Composio is down. + * + * So the caller chooses what to say about a failure it actually has, and this decides only the one + * case it can decide. {@link BrokerUnconfiguredError}'s own message is returned rather than a copy, + * so there is one wording of the remedy and it lives beside the class that raises it. + */ +export function brokerSentence(error: unknown): string | null { + return error instanceof BrokerUnconfiguredError ? error.message : null; +} diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts new file mode 100644 index 000000000..2ccdb60c1 --- /dev/null +++ b/server/tests/composio-broker-seam.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { BrokerUnconfiguredError, brokerSentence } from "../src/plugins/broker"; + +/** + * The broker seam's two decisions, asserted with no network and no database. + * + * Everything else in `./broker` is a type, which a type checker settles and a test cannot. What is + * left to assert is the pair of runtime facts the module exists to fix: what an unconfigured + * deployment says about itself, and what it refuses to say about anything else. + * + * The second of those is the one worth a file. `brokerSentence` is what a caller reaches for when a + * broker call throws, and the tempting shape — a fallback sentence for anything it does not + * recognise — would report a missing setting for a vendor outage: an operator told to set + * `COMPOSIO_API_KEY` when the key is set and Composio's socket hung up. Null is how the function + * declines to guess, and the caller is left to say something true about the failure it actually has. + */ + +describe("brokerSentence", () => { + test("reports the unconfigured deployment in the error's own words", () => { + const error = new BrokerUnconfiguredError(); + + expect(error.message).toContain("COMPOSIO_API_KEY"); + expect(brokerSentence(error)).toBe(error.message); + }); + + test("declines to explain a failure that is not about configuration", () => { + expect(brokerSentence(new Error("socket hang up"))).toBeNull(); + }); +}); From bfee96dea66f60a9e1c20c8fd3155724aa25389f Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 03:38:38 +0200 Subject: [PATCH 096/215] Let the browser's tool type carry what an action destroys Half of the change. The field exists on the type a screen reads, while the store that would fill it in still drops the column on its way out of the database, so nothing sets this to true yet. The server half follows. Beside the effect rather than folded into it: the rule engine judges reads and writes and gains nothing from a third value, while a person deciding whether to switch an action on is asking a different question. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/queries.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index f74f2a273..f9186a19b 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -11,6 +11,15 @@ export type PluginTool = { ref: string; /** Whether it changes something. Anything not positively known to be a read is a write. */ effect: "read" | "write"; + /** + * Whether the vendor warns that it destroys something. + * + * Beside {@link effect} rather than folded into it: the rule engine judges reads and writes and + * gains nothing from a third value, while a person deciding whether to switch an action on is + * asking a different question. Recorded from the vendor's own labels, so false is an absence of a + * claim rather than a claim of safety. + */ + destructive: boolean; grantedTo: string[]; }; From 1e02406bdb48816656dec03dbf11fe9c3c930607 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 03:38:38 +0200 Subject: [PATCH 097/215] Add the write that enables one app at the broker The endpoint it posts to does not exist yet, and neither does the screen that would press it. Both follow in later steps; this is the mutation they will share. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/mutations.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index e8bee1ffa..09a79a6b5 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -140,6 +140,25 @@ export function addCustomServerMutationOptions(queryClient: QueryClient) { }); } +/** + * Add a Composio app to the deployment, named by its slug. + * + * Its own endpoint rather than a curated key, because the catalogue is the vendor's rather than + * ours: the slug is all the server needs to look the app up and record the row. + */ +export function enableComposioAppMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: { slug: string }) => { + await client("/api/plugins/composio/apps", { + method: "POST", + body: input, + fallback: "That app could not be added.", + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + /** Re-read a server's tool list, which is what makes a newly-added tool appear. */ export function refreshPluginServerMutationOptions(queryClient: QueryClient) { return mutationOptions({ From 32eda619ddcdf28af0db22b56c3b5d7702e09a73 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 03:38:39 +0200 Subject: [PATCH 098/215] Document the broker, before anybody can reach it Written from the design rather than from use, and deliberately so: the connector is being built now, and the page an operator lands on should say what the product does rather than accumulate after the fact. Three things it states plainly, because each is easier to discover than to be told. This deployment's grants are the entire narrowing, since the vendor-side grant is as wide as Composio's own app asks for. One vendor ends up holding every person's connection to every app, which is the deal any broker offers and should be chosen rather than found out. And there is no approval step before a destructive action yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- docs/plugins/composio.md | 166 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/plugins/composio.md diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md new file mode 100644 index 000000000..9ed29ab6e --- /dev/null +++ b/docs/plugins/composio.md @@ -0,0 +1,166 @@ +# Composio + +Composio is a broker. It holds people's accounts for a few hundred apps — Slack, Linear, Gmail, +HubSpot and the long tail behind them — and publishes each app's actions as tools this deployment +can call on somebody's behalf. There is no OAuth client to register, no secret to paste and no +redirect URI to match character for character, because the account does not live here: the person +consents to Composio, and Composio holds what comes back. A Bot with a brokered action granted +reaches the app **as the person asking**, the same as every other per-person connector here, so two +people asking the same question get the answers their own accounts can see. + +Setting it up takes two people, and neither can do the other's half: + +| Who | Does | Where | +| ---------------- | ------------------------------------------------- | ------------------------------ | +| An administrator | Sets one key, enables an app, grants its actions | `/admin/plugins/composio` | +| Each person | Connects their own account to that app | `/settings/connected-accounts` | + +There is deliberately no endpoint for an administrator to connect an account on somebody's behalf. + +## The key + +`COMPOSIO_API_KEY` is one key for the whole deployment, and it is optional. It is the only Composio +setting there is — nothing per app, nothing per person. One key does not mean one shared account: +Composio keeps people apart by a user id sent with every call, so one person's connections are never +reachable from another's. + +Nothing validates the key at startup. There is no shape to check it against and no call worth making +at boot to find out, so the first real request is what says whether it works. + +Unset is a supported, fully described state rather than a degraded one: it is what every deployment +is today. With no key, the only Composio surface anywhere in the product is one line where the app +directory would be, saying that adding a Composio key enables a catalogue of tools and naming +`COMPOSIO_API_KEY` as the setting to set. It names the setting rather than hiding the feature, so +somebody who has heard of Composio can find out what it wants. There is no directory to browse, no +picker, and no brokered app on anybody's connected-accounts page. + +An app enabled while a key was set and then left without one keeps its row and its grants. Its page +says the key is missing, and its calls refuse with a sentence saying the same — distinguished, as +everywhere else here, from "the app advertises nothing". + +## What an administrator does + +### 1. Find the app, and read its action count + +At `/admin/plugins/composio`, search Composio's directory. The whole directory is fetched in one +request and searched in this process rather than at the vendor, so what is on screen is the whole +listing and not a page of it. + +Each row carries the app's **action count**, and that number is worth reading before pressing +**Add**. Slack publishes 167 actions, 73 of them reads — several times more than a model handles +well. There is no cap: grants remain the only ceiling, so a large app is possible and merely never +accidental. + +### 2. Enable the app + +**Add** takes the slug, which has to be one the directory itself answered with — a slug that arrived +from a caller and was written into a row's url would become the app every future call runs in. + +**The authorization config is created here, not on somebody's first click, and it is created +first.** Left to itself, the link-minting call would create one on demand, at Composio's managed +defaults, the first time any person pressed Connect. Creating it at enable time, named for this +deployment, makes it an object an operator can see in their Composio dashboard from the moment the +app exists — and tighten there, without a code change. It comes before anything is written here, so +a failure leaves no row behind and pressing the button again is the whole recovery. + +Then one ordinary `mcp_servers` row — id `composio-`, url `composio://`, provenance +`composio`, vendor Composio, title from the directory, and no credential of any kind — and then the +app's actions, recorded with each one's effect, destructive marker and version, so a bad key is +reported to the administrator who just pressed the button rather than the first time a Bot calls +something. The audit row is the existing `configuration.changed` / `mcp_server_added`, marked +`provenance: "composio"`. + +Nothing arrives switched on. Enabling an app names no Bot, and a switch drawn in the on position for +a grant nobody made is the one thing this codebase is most consistently careful about. + +### 3. Grant actions to a Bot + +Enabling the app gives no Bot access to it. From the app's page at `/admin/plugins/composio-`, +open one Bot to get a screen listing every action with a switch each — searchable, split into reads +and writes, with *turn on every read-only action* as the one bulk action, which says how many tools +that Bot will then carry before it does it. Every call then checks the grant, evaluates the action +policy, and writes an audit row. + +A destructive action renders as danger. Nothing renders as reassurance: an action that does not +claim to be destructive is not claiming to be safe, so the absence of the marker is drawn plain, +never as green. + +## What each person does + +At `/settings/connected-accounts`, a brokered app appears beside the OAuth connectors once an +administrator has enabled it. Open it and press **Connect**. That leaves OpenBot for Composio's +consent screen and returns to the same page. + +**The link is yours alone.** It is minted for the session's own id and can be asked for on nobody +else's behalf. It is a bearer capability — whoever opens it binds *their* account to the id it was +minted for — so it is handed to the browser that asked and is never stored, logged, audited, or put +anywhere a second person could read it. Do not forward it. + +Coming back does not by itself grant anything. The return trip is an ordinary redirect with nothing +signed in it, so the row that lets calls through is written only after Composio confirms the account +is live. The page asks on return, and asks again on load, so a row that drifted heals. Composio is +the source of truth and the row here is a cache of it. + +**One account per person per app, and the second is refused.** Composio would happily hold several +accounts for one person and one app, but the call that runs an action names the person and not the +account — so with two Gmail accounts connected, which mailbox a Bot reads would be Composio's +choice, and neither the table here nor the audit row could say which one it was. Connect therefore +refuses while a live connection for that app already exists, and names the way to switch: *You have +already connected Slack. To use a different account, disconnect this one first.* It is per app and +nothing more — Gmail and Linear and Notion connected alongside each other are untouched. + +### Disconnecting + +**Disconnect**, on the same page, revokes at Composio first and deletes the row second. The account +ends at Composio, not just here. Revoke-then-delete is the ordering everywhere in this connector, so +a failure between the two leaves access dead rather than live and unreachable; pressing Disconnect +again is the whole recovery. Audited as `mcp.account_disconnected`. + +## The two paths that end somebody else's access + +Both of these used to stop at this deployment's own tables, which was the only thing they could do +while there was nothing to revoke with. Both now reach the broker. + +**Removing the app** revokes every person's connection to it at Composio, clears the rows, and then +deletes the authorization config that enabling created. Re-adding the app afterwards starts empty +rather than silently restoring everybody who had connected before. + +**Removing the person** revokes each of their brokered connections at Composio before clearing the +rows, and reports what was revoked. The `composio_connections` table is keyed on +`(toolkit, user_id)` and outlives the user record for exactly this reason — so offboarding can still +find the connection after the person is gone. + +## What the grants narrow, and what they do not + +The authorization config keeps Composio's default scopes. Narrowing them properly would mean +choosing scopes before anybody has been granted anything, which is backwards, so it is not done +pre-emptively. Stated plainly, and stated in these words on the app's own page: + +> **The vendor-side grant is as wide as Composio's own app asks for, and this deployment's grants +> are the entire narrowing.** + +This is the position Notion is already in. What keeps a Bot's reach small is which actions are +switched on for it, and nothing at the vendor stands behind that. + +## Blast radius + +One vendor ends up holding every person's connection to every app — which is the deal any broker +offers, and should be chosen rather than discovered. + +## Not built yet + +**No approval step before a destructive action.** The destructive marker is recorded and now +visible, but it gates nothing: a Bot granted a destructive action performs it without anybody being +asked. That is the same position every other connector is in — but it is now a position reachable +through the UI rather than only through a database insert, which is a real change in exposure. + +**No way to give a Bot a whole large app to search.** A Bot carries the actions somebody switched on +for it, one at a time. There is no search-and-run path for an app too large to tick through, which +is why an app's action count is worth reading before it is enabled rather than afterwards. + +## See also + +- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit. +- [Configuration](../configuration.md) — `COMPOSIO_API_KEY`, and that it is optional. +- [Notion](notion.md) and [Google Drive](google-drive.md) — the same per-person shape, with the + OAuth client registered here instead of held by a broker. From 76c805650262f55164e0d8272d3987498b1936ac Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:00:01 +0200 Subject: [PATCH 099/215] Enable an app at the broker as a row the call path already understands No URL is taken from a caller, which is what lets this path skip the host rules the by-URL route needs: the address is built from a slug, and the route above this is obliged to have taken that slug from the broker's own directory. Nothing here dials an address or hands anything a credential. The authorization config is created before any row is written. Left to itself the link-minting call creates one on first consent, at the vendor's defaults, which puts an object in an operator's project that nobody chose and nobody can find until somebody has already connected against it. Created here it exists from the moment the app does, and a failure writes no row at all. The id is prefixed and the url is not. `composio-linear` cannot collide with a curated key or with a row an administrator added by URL, and it keeps this path away from the ids the integration suite reserves. Which app the row IS still comes off the url, as `accessFor` insists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 127 ++++++++++++++++- server/tests/plugin-store.integration.test.ts | 132 +++++++++++++++++- 2 files changed, 257 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5c0d5373b..0a7cd8160 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -37,6 +37,7 @@ import { type ServerAccess, ServerUnresolvableError, } from "./access"; +import { BrokerUnconfiguredError, type ComposioBroker } from "./broker"; import { type CatalogueEntry, catalogueEntry, @@ -45,7 +46,7 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { VERSION_ARG } from "./composio"; +import { toolkitOf, VERSION_ARG } from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -873,6 +874,17 @@ export type PluginStoreOptions = { registrationUrl: string; redirectUri: string; }) => Promise; + /** + * Composio the broker, absent on a deployment that has not configured one. + * + * OPTIONAL BECAUSE ITS ABSENCE IS A STATE RATHER THAN A MISCONFIGURATION. An unset + * `COMPOSIO_API_KEY` is the documented default and the whole brokered surface is meant to be + * missing where it is unset, so the store is constructible without one and every path that needs + * one says so by raising {@link BrokerUnconfiguredError}. A required field would make every + * caller that never enables an app — the routes, the tests above — invent a broker to get a + * store. + */ + broker?: ComposioBroker; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ redirectUri?: string; }; @@ -888,6 +900,9 @@ export function createPluginStore(options: PluginStoreOptions) { const exchangeRefreshToken = options.exchangeRefreshToken ?? exchangeRefreshTokenOverHttp; const registerClient = options.registerClient ?? registerDynamicClient; + // No default, unlike the seams above: there is no real implementation in this tree to fall back + // to, and a deployment with no Composio key is supposed to have no broker. See `./broker`. + const broker = options.broker; /* * One exchange at a time per (server, person). A rotating vendor invalidates the refresh @@ -2239,6 +2254,116 @@ export function createPluginStore(options: PluginStoreOptions) { return added; }, + /** + * Enable one app of the broker's catalogue, which is a third way for a server to arrive. + * + * NO URL IS TAKEN FROM A CALLER, WHICH IS WHY THERE IS NO HOST RULE HERE. `addCustomServer` + * guards the address because the address is what an administrator typed and what a credential + * would then be spent at; this one composes `composio://` itself, and a brokered row is + * never dialled at a host at all — the transport reads the app off that url and asks Composio, + * over the deployment's own key. So the only thing left to check about the url is that it says + * what this call meant, and {@link toolkitOf} is what checks it: the slug goes in, the url comes + * back out through the very function `accessFor` will read it with, and a slug those two + * disagree about is refused rather than stored. A pattern written here instead would be a second + * opinion about the shape of an app name, and the reading that decides which app a call runs + * against is the one that has to be satisfied. + * + * THE AUTH CONFIG COMES BEFORE THE ROW, in that order and not the other. An auth config is what + * a person's connection is then created against, so a row written first is an app an + * administrator can see on the page, grant to a Bot and press Connect on, with nothing at the + * vendor for any of it to attach to. Asking first is also what makes a failure leave nothing + * behind: the broker throws, this call throws, and no row, no action and no audit entry claims + * an app was enabled. {@link ComposioBroker.ensureAuthConfig} is idempotent precisely so that + * enabling an app twice — two administrators, or a retried request — is allowed to do this. + * + * THE ID IS PREFIXED AND THE URL IS NOT. `composio-linear` is what prefixes tool names and what + * a grant and a policy rule are written against, so it must not land on a curated entry's key — + * which `accessFor` refuses outright as a row claiming to be two servers at once — nor on one of + * the ids the integration suite reserves for its own fixtures: `gmail`, `notion`, `bot_helper`. + * The prefix puts every brokered row out of reach of all of them. WHICH APP THE ROW IS still + * comes off the url and only off the url, because that is where `accessFor` and the brokered + * gate behind it both read it from; the id names the row and never the app, and nothing may + * start reading one as the other. + */ + async addBrokeredApp(input: { + slug: string; + title: string; + by: string; + }): Promise { + // Before anything at all. A deployment with no key has no catalogue for this app to have been + // chosen from, so there is nothing here to half-do and nothing to say but the setting. + if (!broker) throw new BrokerUnconfiguredError(); + + const url = `composio://${input.slug}`; + if (toolkitOf(url) !== input.slug) { + throw new CustomServerRefusedError( + `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, + ); + } + + await broker.ensureAuthConfig({ toolkit: input.slug, name: input.title }); + + const id = `composio-${input.slug}`; + await database + .insert(mcpServers) + .values({ + id, + title: input.title, + // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule + // is checked against, and Composio is who this deployment is actually talking to. + vendor: "Composio", + url, + provenance: "composio", + // Nothing for the vault to hold. A brokered call runs as the person asking, on their own + // connection at the vendor, which is a `composio_connections` row rather than a secret. + credentialId: null, + addedBy: input.by, + }) + .onConflictDoUpdate({ + target: mcpServers.id, + set: { + title: input.title, + url, + addedBy: input.by, + updatedAt: new Date(), + /* + * `credential_id` is neither written here nor cleared here. + * + * Every row this method creates has none and no path in this module attaches one, so + * there is nothing for an enable to set. Clearing it anyway would matter in the single + * case it could apply — a pointer that arrived by hand edit or restore — because + * `removeServer` retires a server's secret by reading it off this column, and a null + * written over it leaves that secret live with nothing left to name it. + */ + }, + }); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: id, + payload: { + actor: input.by, + change: "mcp_server_added", + server: id, + url, + // Named for the same reason the custom path names its own: "who enabled an app whose + // actions nobody reviewed" is a question somebody will ask, and the answer should not + // require knowing how ids were spelled in a past build. + provenance: "composio", + }, + }); + + // Refreshed now for the reason the paths above are: the page that enabled the app can show + // what it offers, and a broker that will not list it says so here rather than at first use. + await this.refreshTools(id); + const added = (await this.listServers()).find( + (server) => server.id === id, + ); + if (!added) throw new CatalogueEntryUnknownError(id); + return added; + }, + /** * Remove a server, and stop every secret it was reached with being live. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index c9b44055f..3a6a5769f 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -37,6 +37,7 @@ import { CatalogueTransportUnroutableError, ServerRowAmbiguousError, } from "../src/plugins/access"; +import type { ComposioBroker } from "../src/plugins/broker"; import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; import { @@ -3813,8 +3814,11 @@ async function freshDatabase(): Promise { * NO `callVendor`. Whose account a call runs as and which transport a row resolves to are the * properties under test, and both are decided on the way to the vendor — so the real path has to * run, and the vendor is stubbed further out at {@link useComposioClient}. + * + * `options` is spread over the defaults rather than read field by field, so a test that needs one + * more seam — a broker, today — adds it at the call and nothing here has to learn its name. */ -async function freshStore() { +async function freshStore(options: { broker?: ComposioBroker } = {}) { const database = await freshDatabase(); const persisting = createAuditStore(database); const events: Parameters[0][] = []; @@ -3832,6 +3836,7 @@ async function freshStore() { credentials: credentialsStub, encryptionKey: "x".repeat(44), policy: () => policy, + ...options, }); return { store, database, auditStore }; @@ -5881,3 +5886,128 @@ test("an audit write that fails is not recorded as the vendor misbehaving", asyn expect(row?.lastError).toBeNull(); }); + +/** + * The moment a brokered app starts existing, which is an auth config before it is a row. + * + * The id and the url are different strings on purpose, and both are asserted. `composio-linear` is + * what a grant and a policy rule are written against, and it carries a prefix so it cannot land on + * a curated entry's slug or on one of the ids the fixtures above reserve; `composio://linear` is + * what `accessFor` reads the app off, and that is the field the transport and the connection gate + * both settle the app from. A test that asserted only one of them would pass on an implementation + * that made them equal, which is the arrangement those two rules exist to keep apart. + * + * THE BROKER IS ASKED FIRST AND EXACTLY ONCE. First because a row whose auth config does not exist + * is an app an administrator can see and nobody can connect to; once because `ensureAuthConfig` is + * idempotent at the vendor and a second call here would be this deployment leaning on that. + * + * `composio-linear` is cleaned up in a `finally` rather than by {@link freshDatabase}, which knows + * only the ids the guard at the top of this file cleared. The row is checked absent before the add + * for the same reason every delete in this file is guarded: the id is spelled the way production + * spells it, so a row already at it would be somebody's app rather than this test's. + */ +test("enabling an app writes a brokered row, and asks for its auth config first", async () => { + const asked: { toolkit: string; name: string }[] = []; + const rowsWhenAsked: string[] = []; + const unasked = (what: string) => async (): Promise => { + throw new Error(`enabling an app asked the broker to ${what}`); + }; + const broker: ComposioBroker = { + listApps: unasked("list the catalogue"), + ensureAuthConfig: async (config) => { + asked.push(config); + /* + * What the table held at the moment the broker was asked, which is how "first" is asserted + * rather than assumed. Counting the calls says nothing about the order, and the order is the + * whole property: an implementation that wrote the row and then asked would leave an app on + * the page that nobody can connect to whenever this call fails. + */ + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + rowsWhenAsked.push(...rows.map((row) => row.id)); + }, + deleteAuthConfig: unasked("delete an auth config"), + authorize: unasked("begin somebody's connection"), + isConnected: unasked("check somebody's connection"), + revoke: unasked("withdraw somebody's grant"), + }; + + const { store, database, auditStore } = await freshStore({ broker }); + useComposioClient({ + listActions: async () => [ + { + slug: "LINEAR_CREATE_ISSUE", + description: "Create an issue.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + + const [present] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + if (present) { + throw new Error( + "a row at 'composio-linear' was already here, so it is not this test's to write over", + ); + } + + try { + const record = await store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin@example.com", + }); + + expect(record.id).toBe("composio-linear"); + expect(record.url).toBe("composio://linear"); + expect(record.provenance).toBe("composio"); + // No credential of its own, and none to come: a brokered row is reached as the person asking, + // on their connection at the vendor, so there is nothing on this row for a vault to hold. + expect(record.hasCredential).toBe(false); + + expect(asked).toEqual([{ toolkit: "linear", name: "Linear" }]); + expect(rowsWhenAsked).toEqual([]); + + const changes = auditStore + .recorded() + .filter((event) => event.eventType === "configuration.changed"); + expect(changes).toHaveLength(1); + expect(changes[0]?.payload).toMatchObject({ + change: "mcp_server_added", + provenance: "composio", + }); + } finally { + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, "composio-linear")); + await database + .delete(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + } +}); + +/** + * The same call on a deployment that has no Composio key, which is the documented default. + * + * The refusal names the setting because the name is the whole remedy, and nothing else about a + * deployment with no broker will tell an administrator what to set. Asserted on the message rather + * than the class so that the sentence an operator actually reads is what this test is about. + */ +test("enabling an app with no broker says which setting is missing", async () => { + const { store } = await freshStore(); + + await expect( + store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin@example.com", + }), + ).rejects.toThrow("COMPOSIO_API_KEY"); +}); From 316445f2cc23972f0b8925d5b63b860168ce38fd Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:00:02 +0200 Subject: [PATCH 100/215] Hand the plugin routes a broker, and say whether one is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter is its own rather than a field on the connect object, because that object is the OAuth consent flow's — sealed state, a redirect URI, a sessionless access check — and a brokered app enters none of it. `composioConfigured` is a boolean about configuration and never the key, nor anything derived from its value. It exists so a screen can offer the directory where there is one and say which setting is missing where there is not, rather than rendering an empty catalogue that looks like a vendor with no apps. No route uses either yet. The import sort in app.ts was already failing the formatter before this branch touched the file; it is absorbed here rather than left for the pre-push gate to stop on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/app.ts | 74 +++++++++++++++++++++++------------- server/src/plugins/routes.ts | 27 +++++++++++++ 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 8f07911a4..7f6e2b417 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -7,9 +7,9 @@ import type { AgentProfileStore } from "./agents/profile-store"; import { createAgentRoutes } from "./agents/routes"; import { type AuditEventType, + AuditQueryError, type AuditReader, type AuditStore, - AuditQueryError, auditQueryFromUrl, DEPLOYMENT_INITIATOR, recordAuditEvent, @@ -41,6 +41,7 @@ import type { CredentialAdminService, CredentialInput } from "./credentials"; import { createIntelligenceClient } from "./intelligence-client"; import type { OnboardingStore } from "./people/onboarding"; import type { PeopleStore } from "./people/store"; +import type { ComposioBroker } from "./plugins/broker"; import { createPluginRoutes } from "./plugins/routes"; import type { PluginStore } from "./plugins/store"; import { REFUSAL_MARKER } from "./plugins/tools"; @@ -221,6 +222,19 @@ export function createApp( * shown an empty box, and the obvious thing to do with an empty box is fill it in again. */ userInstructions?: UserInstructionsStore, + /** + * The broker behind apps a person connects through Composio rather than an administrator + * registering an MCP server. + * + * Appended last, like everything above it: these are positional, so inserting one anywhere else + * silently shifts every existing call site's arguments by one. + * + * Passed in already built, like the copilot handler and the intent router, so this module never + * imports the vendor's package. Absent leaves the plugin surface reporting that no broker is + * configured, which is the correct degraded behaviour: a deployment with no Composio API key has + * no app directory to offer, rather than one that lists apps nobody can connect. + */ + composio?: { broker: ComposioBroker }, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -1061,33 +1075,39 @@ export function createApp( if (pluginStore) { app.route( "/api/plugins", - createPluginRoutes(pluginStore, requireUser, canUseBot, { - encryptionKey: config.keyEncryptionKey, - /* - * Whether the person a consent was started for still has access, asked when the callback - * lands rather than when the flow began. - * - * The callback carries no session — identity comes from the state — so this is where the - * question gets asked at all. `find` answers both halves of it: no row means a user id that - * names nobody, and `revoked` means an administrator removed them while they were away at - * the vendor. Either way there is no live person for a fresh refresh token to belong to. - * - * No people store means this deployment cannot answer the question, so it refuses rather - * than assuming yes. It also cannot remove anybody, which is exactly why guessing here - * would be a hole nothing else closes. - */ - personHasAccess: async (userId) => { - if (!peopleStore) return false; - const person = await peopleStore.find(userId); - return person !== undefined && !person.revoked; + createPluginRoutes( + pluginStore, + requireUser, + canUseBot, + { + encryptionKey: config.keyEncryptionKey, + /* + * Whether the person a consent was started for still has access, asked when the callback + * lands rather than when the flow began. + * + * The callback carries no session — identity comes from the state — so this is where the + * question gets asked at all. `find` answers both halves of it: no row means a user id that + * names nobody, and `revoked` means an administrator removed them while they were away at + * the vendor. Either way there is no live person for a fresh refresh token to belong to. + * + * No people store means this deployment cannot answer the question, so it refuses rather + * than assuming yes. It also cannot remove anybody, which is exactly why guessing here + * would be a hole nothing else closes. + */ + personHasAccess: async (userId) => { + if (!peopleStore) return false; + const person = await peopleStore.find(userId); + return person !== undefined && !person.revoked; + }, + // The deployment-wide fallback a Bot may present, as a yes or no. The secret itself stays + // in config and is checked in `/api/agent-tools/call`; the surface only needs to know + // whether a Bot without its own credential has any way to call back. + botsMayCallBack: Boolean(config.agentToolToken), + publicUrl: config.publicUrl, + appUrl: config.appUrl, }, - // The deployment-wide fallback a Bot may present, as a yes or no. The secret itself stays - // in config and is checked in `/api/agent-tools/call`; the surface only needs to know - // whether a Bot without its own credential has any way to call back. - botsMayCallBack: Boolean(config.agentToolToken), - publicUrl: config.publicUrl, - appUrl: config.appUrl, - }), + composio, + ), ); } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 68c54eba9..fb801450b 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; +import type { ComposioBroker } from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; import { authorizationUrlFor, @@ -106,6 +107,23 @@ export function createPluginRoutes( */ appUrl: string | undefined; }, + /** + * The broker this deployment talks to when an app is connected for somebody rather than + * registered by an administrator. + * + * Its own parameter rather than a field on `connect`, because nothing on that object applies + * here. `connect` is the OAuth consent flow this deployment runs itself: the key its state is + * sealed with, the redirect URI a vendor sends people back to, the access check the sessionless + * callback asks. A brokered app uses none of it — the vendor holds the consent, so there is no + * state to seal, no callback to land here and no redirect URI to publish. Folding it in would put + * a field on an object whose every other field is about a flow it never enters. + * + * Optional, and last for the same reason `connect` is: a deployment with no Composio API key + * configured simply has no broker, and the surface says so rather than pretending one exists. The + * trap `connect` documents applies here with one more argument in it — every parameter from that + * position on is optional, so a misplaced one typechecks and quietly does nothing. + */ + composio?: { broker: ComposioBroker }, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -164,6 +182,15 @@ export function createPluginRoutes( * every call was refused before it reached the boundary. */ botsMayCallBack: connect?.botsMayCallBack === true, + /* + * Whether this deployment has a broker at all, so the page knows whether brokered apps are + * on offer. + * + * A boolean about configuration, never the key. The API key that builds the broker is a + * deployment credential and the Plugins page is reachable by any signed-in person; what the + * screen needs is whether to draw the app directory, which is a yes or a no. + */ + composioConfigured: Boolean(composio), servers: await store.listServers(), // Scoped: the deployment's skills plus this person's own. An administrator sees them all. skills: await store.listSkills(skillActor(context)), From 4ad335653e9e286d27c38f3e5293e070a00c5e44 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:00:02 +0200 Subject: [PATCH 101/215] Install a real broker client, in the one file allowed to know the vendor's shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It takes the vendor object rather than a key, and that is the seam: the tests satisfy it with an object literal, so the three properties worth asserting — that a listing asks for the ceiling, that the directory arrives in this deployment's shape, and that an execute whose app disagrees with the resolved tool is refused rather than forwarded — are provable without the network. No session is created anywhere here. A session brings the vendor's remote shell and Python sandbox with it unless explicitly disabled, and nothing in this connector needs one: minting a consent link, listing, executing and revoking are all reachable without it. Four details of the vendor's SDK are not what the plan said, and the code follows the installed package rather than the plan. The toolkit listing is `get` and not `list`; it answers a bare array rather than an envelope; the action count arrives camel-cased, because the SDK renames it and then parses the result through a schema that strips what it does not name; and a category is an object, not a string. Reading the count under the name the plan gave would have reported every app as having no actions. The API key never leaves this file. It is a parameter, it reaches the vendor's constructor, and it appears in no log line, no thrown message and no field of either returned object. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 422 +++++++++++++++++++++++++ server/tests/composio-adapter.test.ts | 219 +++++++++++++ 2 files changed, 641 insertions(+) create mode 100644 server/src/plugins/composio-adapter.ts create mode 100644 server/tests/composio-adapter.test.ts diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts new file mode 100644 index 000000000..b943520fa --- /dev/null +++ b/server/src/plugins/composio-adapter.ts @@ -0,0 +1,422 @@ +import { Composio } from "@composio/core"; +import type { BrokerApp, ComposioBroker } from "./broker"; +import { + type ComposioAction, + type ComposioActions, + type ComposioResult, + LISTING_LIMIT, +} from "./composio"; + +/** + * The one file in `server/src` that imports `@composio/core`, and what it owes the rest of them. + * + * `./composio` describes calling an action and `./broker` describes everything that has to be true + * before one can be called; both are written as narrow projections that name no vendor type, so + * that the SDK's shape — its constructor, its retries, its zod schemas and whatever the next + * version renames — is confined here. This module is the adapter that satisfies both from one + * client. A second importer of `@composio/core` under `server/src` would undo that, because the + * point of a single import site is that a version bump has exactly one file to be read against. + * + * NO SESSION IS EVER CREATED, AND THAT IS A SECURITY BOUNDARY RATHER THAN A PREFERENCE. The SDK's + * `composio.create(...)` and `sessions.create(...)` open a Composio tool-router session, and a + * session brings Composio's own hosted surface with it — a remote shell and a Python sandbox that + * this deployment neither asked for, cannot see into, and could not audit if a model reached them. + * Everything below is a plain per-call request carrying a user id. A `create` of a session + * anywhere in this file is a defect, not an optimisation, and it will not look like one: the + * session API is the shortest path to most of what this file does the long way. + * + * EVERY LISTING PASSES AN EXPLICIT LIMIT. Composio's default page is 20, which is smaller than the + * number of actions Gmail alone publishes, and for a tool listing the default does a second thing + * as well: `getRawComposioTools` sets `important=true` whenever a toolkit query arrived with no + * limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so an + * omitted limit silently narrows the answer to the vendor's own "important" subset and nothing in + * the result says a filter was applied. A limit is therefore not tuning; it is the difference + * between the list and a fragment of it that reads exactly like the list. {@link LISTING_LIMIT} is + * the documented ceiling, and `./composio` explains why one page at the ceiling is the largest + * listing this SDK can express at all. + * + * THE API KEY NEVER LEAVES THIS FILE. It arrives as {@link createComposioClient}'s only argument, + * goes straight into the vendor's constructor, and is held from there on by the vendor's client + * inside a closure. Nothing below logs it, no thrown message quotes it, and neither of the two + * returned objects carries a field that could be read back to it — they expose eight methods and + * no state. A key in a log line is a key in a log aggregator, and a key in an error message is a + * key in an audit row and in a model's context. + */ + +/** + * One tool as the vendor hands it over, in as much detail as anything here reads. + * + * Declared structurally rather than imported as `Tool`, for the same reason the seams it feeds are + * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` + * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for + * what is done when it is in fact missing. + */ +type VendorTool = { + slug: string; + description?: string; + inputParameters?: Record; + tags?: string[]; + version?: string; + toolkit?: { slug: string }; +}; + +/** + * One catalogue row as the vendor hands it over. + * + * `meta` is where all of it lives and every field of it is optional, which is not the SDK being + * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. + * See {@link ComposioBroker.listApps} below for what each absence becomes. + */ +type VendorToolkit = { + slug: string; + name: string; + meta: { + description?: string; + logo?: string; + categories?: { slug: string; name: string }[]; + toolsCount?: number; + }; +}; + +/** + * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. + * + * A TEST SATISFIES IT WITH AN OBJECT LITERAL, which is the entire argument for it and the reason + * {@link buildComposioClient} takes one of these instead of an API key. The vendor's own client + * cannot be constructed without a key and answers nothing without a network, so an adapter that + * built its own client would be an adapter no test could reach — and this is the file where the + * field names, the argument order and the refusal below would go wrong unnoticed. + * + * Every member is written with METHOD syntax deliberately. Method parameters are compared + * bivariantly, so a real `Composio` — whose signatures carry optional request-options arguments + * and wider parameter types than the calls here use — satisfies this without a cast. + */ +export type ComposioVendor = { + tools: { + getRawComposioTools(query: { + toolkits: string[]; + limit: number; + }): Promise; + getRawComposioToolBySlug( + slug: string, + options?: { version?: string }, + ): Promise; + execute( + slug: string, + body: { + arguments: Record; + userId: string; + version: string; + }, + ): Promise; + }; + toolkits: { + get(query: { limit: number; sortBy: "usage" }): Promise; + authorize( + userId: string, + toolkit: string, + ): Promise<{ redirectUrl?: string | null }>; + }; + authConfigs: { + list(query: { + toolkit: string; + limit: number; + }): Promise<{ items: { id: string; name: string }[] }>; + create( + toolkit: string, + options: { type: "use_composio_managed_auth"; name: string }, + ): Promise; + delete(id: string): Promise; + }; + connectedAccounts: { + list(query: { + userIds: string[]; + toolkitSlugs: string[]; + /** + * The one status this file ever asks for, spelled as the literal the SDK's enum admits. + * + * Written as `"ACTIVE"[]` rather than `string[]` because the vendor's parameter is an enum + * and a widened array does not satisfy it. The narrowness is worth keeping for its own sake + * too: a status this deployment has not thought about cannot be asked for by accident. + */ + statuses: "ACTIVE"[]; + limit: number; + }): Promise<{ items: { id: string }[] }>; + delete(id: string): Promise; + }; +}; + +/** + * The suffix every auth config this deployment creates carries, so a reader can tell whose it is. + * + * An auth config is visible in Composio's own dashboard beside any that were made by hand there, + * and the two are otherwise indistinguishable. The name is the only field this deployment gets to + * choose, so it is where the provenance goes. + */ +const CONFIG_SUFFIX = "(OpenBot)"; + +/** + * Both seams, over one vendor client. + * + * TAKING THE VENDOR OBJECT RATHER THAN A KEY IS THE SEAM. It is what lets every test of this + * file's own decisions — which limit went out, which field became which, which call was refused + * before it was made — run against an object literal and never a socket. {@link createComposioClient} + * is the one line that turns a key into a vendor, and it is deliberately too thin to have a bug in. + * + * The vendor is captured in a closure rather than stored on either returned object, so neither + * `actions` nor `broker` offers a route back to the client or to the key it holds. + */ +export function buildComposioClient(vendor: ComposioVendor): { + actions: ComposioActions; + broker: ComposioBroker; +} { + /** + * This person's live accounts for this app, which two of the broker's questions are about. + * + * Shared because {@link ComposioBroker.isConnected} and {@link ComposioBroker.revoke} have to + * agree: "connected" and "there is something to revoke" are the same fact, and two listings + * written separately drift apart the first time one of them gains a status the other lacks. + * + * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never + * finished it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; + * counting either as connected tells a person their app is wired up and then fails every call + * they make with it. + */ + const activeAccounts = async (userId: string, toolkit: string) => { + const answer = await vendor.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses: ["ACTIVE"], + limit: LISTING_LIMIT, + }); + return answer.items; + }; + + /** + * This deployment's auth configs for one app. + * + * EVERYTHING THIS LISTING RETURNS BELONGS TO THIS DEPLOYMENT, which is what makes the two + * callers below legitimate. An auth config is scoped to the project the API key belongs to, so + * the key is the filter: there is no other tenant's config to be found here and none of ours is + * hidden from it. + */ + const authConfigsFor = async (toolkit: string) => { + const answer = await vendor.authConfigs.list({ + toolkit, + limit: LISTING_LIMIT, + }); + return answer.items; + }; + + const actions: ComposioActions = { + async listActions(toolkit, page): Promise { + /* + * The caller's page is passed through rather than defaulted here, because the seam made + * `page` required precisely so that no layer could quietly supply one. See the module + * comment on what an omitted limit does beyond truncating. + */ + const tools = await vendor.tools.getRawComposioTools({ + toolkits: [toolkit], + limit: page.limit, + }); + /* + * Mapped field by field rather than spread, so what crosses the seam is the four things + * `./composio` documents and not whatever else the vendor's tool object happens to carry. + * `inputParameters` in particular goes straight in front of a model. + */ + return tools.map((tool) => ({ + slug: tool.slug, + description: tool.description, + inputParameters: tool.inputParameters, + tags: tool.tags, + version: tool.version, + })); + }, + + async execute(call, args): Promise { + /* + * THE TOOL IS RESOLVED BEFORE IT IS RUN, AND THAT COSTS A ROUND TRIP ON PURPOSE. + * + * Composio's execute takes the slug alone — its REST parameters have no toolkit field — so + * the pair the caller was gated on cannot travel on the wire, and the obligation + * {@link ComposioActions.execute} writes down has to be discharged here instead. The + * resolved tool carries the app the vendor will actually run it against, so asking for it + * first is what makes the check possible at all. + * + * `tools.execute` resolves the same tool again internally, so this is a second request + * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is + * refused before anything runs, rather than discovered in an audit row afterwards. + */ + const resolved = await vendor.tools.getRawComposioToolBySlug(call.slug, { + version: call.version, + }); + const ran = resolved.toolkit?.slug; + if (ran !== call.toolkit) { + /* + * REFUSED RATHER THAN FORWARDED, and both apps are named. + * + * The gate in `./access` cleared this run against the app the connection's url names + * NOW; the slug was recorded by a listing made at some earlier time. Where the two + * disagree — a url edited between a refresh and a call — forwarding runs one person's + * Gmail action under a gate that only ever examined their Slack connection. A reader + * holding only one of the two names cannot tell which of the two is the wrong one, so + * both go in the sentence. An app the vendor did not name at all is the same refusal: + * this deployment cannot show that the call is about the app it was gated on. + */ + throw new Error( + `${call.slug} was not sent to Composio: this connection is for ${call.toolkit}, and Composio resolves that action to ${ + ran ?? "no app at all" + }. Refreshing this app's tools on its Plugins page recovers it where the action was recorded against a url that has since changed.`, + ); + } + + return vendor.tools.execute(call.slug, { + arguments: args, + userId: call.userId, + version: call.version, + }); + }, + }; + + const broker: ComposioBroker = { + async listApps(): Promise { + /* + * ONE PAGE AT THE CEILING, SORTED BY USAGE, AND NO SEARCH TERM. + * + * The order matters because the page is finite: at the ceiling the apps most likely to be + * wanted are the ones that must not be the ones cut off. The absent search term is the + * subtler half — the SDK's list params name no search field + * (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, `src/types/toolkit.types.ts:9-15`) + * and the parse strips what it does not name, so a term passed here would vanish before the + * request and leave a caller believing they had filtered a list nobody filtered. Searching + * the catalogue is this deployment's own job, over the rows below. + */ + const toolkits = await vendor.toolkits.get({ + limit: LISTING_LIMIT, + sortBy: "usage", + }); + return toolkits.map((toolkit) => ({ + slug: toolkit.slug, + name: toolkit.name, + /* + * Each absence becomes the value that reads honestly on an administrator's screen. An + * empty description shows as no description; a null logo is the field's documented way of + * saying the vendor published none, which renders as a gap rather than as a broken image. + * + * The categories are the DISPLAY names rather than the slugs, because this list is read + * by a person choosing an app and "Productivity" is what they are choosing by. + * + * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a + * number and the shape offers no way to say "not published", so a toolkit that publishes + * no count reads as an app with no actions. It is the conservative direction — it + * understates the size of a change rather than overstating it — and Composio publishes a + * count for every toolkit measured, so this is a guard against the vendor rather than a + * routine case. + */ + description: toolkit.meta.description ?? "", + logo: toolkit.meta.logo ?? null, + categories: (toolkit.meta.categories ?? []).map( + (category) => category.name, + ), + actionCount: toolkit.meta.toolsCount ?? 0, + })); + }, + + async ensureAuthConfig({ toolkit, name }): Promise { + /* + * IDEMPOTENT BY LOOKING FIRST, because a second config is not a duplicate — it is a split. + * A person's existing connection is created against one particular auth config, so creating + * another and connecting the next person to that leaves two populations of connections for + * one app, and removing "the" config later drops half of them. + * + * This is a read followed by a write and therefore not atomic: two administrators pressing + * enable at the same instant can both find nothing and both create. Composio offers no + * create-if-absent, so the window is the vendor's rather than this deployment's, and the + * cost of losing that race is a spare config rather than a lost connection. + */ + const existing = await authConfigsFor(toolkit); + if (existing.length > 0) return; + + await vendor.authConfigs.create(toolkit, { + type: "use_composio_managed_auth", + name: `${name} ${CONFIG_SUFFIX}`, + }); + }, + + async deleteAuthConfig(toolkit): Promise { + /* + * QUIET WHERE THERE IS NOTHING TO DELETE, because removing an app has to be able to happen + * twice. An app can be removed, re-enabled and removed again, two administrators can press + * the button together, and an app enabled before this deployment created configs at all has + * none to drop. In every one of those the end state is the one that was asked for, so a + * throw would report a failure while the caller got exactly what they wanted. + * + * ONE CONFIG, NOT ALL OF THEM. {@link ComposioBroker.ensureAuthConfig} creates at most one, + * so a second config for the same app was made by hand in Composio's dashboard for some + * purpose this deployment knows nothing about, and removing an app here is not a mandate to + * delete somebody's dashboard work. + */ + const [config] = await authConfigsFor(toolkit); + if (!config) return; + await vendor.authConfigs.delete(config.id); + }, + + async authorize({ userId, toolkit }): Promise<{ redirectUrl: string }> { + const request = await vendor.toolkits.authorize(userId, toolkit); + const redirectUrl = request.redirectUrl; + if (!redirectUrl) { + /* + * The SDK spells `redirectUrl` nullable because not every auth scheme has one — an API-key + * toolkit is connected by typing a secret, not by visiting a page. This deployment's + * enablement flow sends a person to a url, so no url is nothing to do rather than a + * success, and the sentence says which app it was about. The url itself is never quoted + * anywhere, here or elsewhere: whoever opens it attaches an account to this person's + * connection, so it is handed to the browser that asked and then forgotten. + */ + throw new Error( + `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send this person to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, + ); + } + return { redirectUrl }; + }, + + async isConnected({ userId, toolkit }): Promise { + return (await activeAccounts(userId, toolkit)).length > 0; + }, + + async revoke({ userId, toolkit }): Promise { + /* + * THE ANSWER IS WHAT HAPPENED, not whether the call threw. `false` here means there was no + * grant to withdraw, which is what the audit trail's `vendorRevoked` is for: a reader has to + * be able to tell a grant this deployment ended from one that outlives it somewhere else. + * + * EVERY ACCOUNT, not the first. One person can hold more than one account for one app — two + * mailboxes, or a stale account beside a fresh one — and each of them is a grant this + * deployment's calls could run under. Leaving one behind would report a revocation that did + * not revoke. + */ + const accounts = await activeAccounts(userId, toolkit); + for (const account of accounts) { + await vendor.connectedAccounts.delete(account.id); + } + return accounts.length > 0; + }, + }; + + return { actions, broker }; +} + +/** + * The one line that turns this deployment's API key into a vendor client. + * + * Everything this file decides lives in {@link buildComposioClient}, which is why this function has + * no body worth testing: it constructs the vendor and hands it over. The key is a parameter here + * and a private field of the vendor's client thereafter, and no path out of this module carries it + * — see the module comment. + */ +export function createComposioClient(apiKey: string): { + actions: ComposioActions; + broker: ComposioBroker; +} { + return buildComposioClient(new Composio({ apiKey })); +} diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts new file mode 100644 index 000000000..e516b8718 --- /dev/null +++ b/server/tests/composio-adapter.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { LISTING_LIMIT } from "../src/plugins/composio"; +import { buildComposioClient } from "../src/plugins/composio-adapter"; + +/** + * The three facts about the adapter that a type checker cannot settle, asserted with no network. + * + * {@link buildComposioClient} takes the vendor OBJECT rather than an API key, and that is the whole + * reason this file can exist: every test below hands it a literal whose methods record what they + * were asked and answer from memory, so the adapter's own decisions are what is under test and + * nothing here dials Composio. A `createComposioClient` that only took a key would have made this + * file either a live test or no test at all. + * + * WHAT IS WORTH ASSERTING IS WHAT IS EASY TO GET SILENTLY WRONG. The mapping of the vendor's fields + * onto ours is one such thing — a listing that omitted its limit, or a catalogue row that read the + * wrong key for an action count, both answer plausibly and both are wrong in a way no exception + * reports. The refusal is the other: it is the one place this adapter is required to NOT make a + * vendor call, and an implementation that forwarded a mismatch would pass every test that only + * looked at what came back. + */ + +/** A vendor method nothing in a given test should reach, which says so rather than answering. */ +function refuse(what: string) { + return async (): Promise => { + throw new Error(`${what} should not have been called in this test.`); + }; +} + +/** + * A vendor object whose every method refuses, with the few a test cares about substituted in. + * + * The refusals are the point rather than filler. A test about the catalogue that accidentally + * executed a tool, or one about a refusal that reached the vendor anyway, would otherwise fail on + * something unrelated — or, worse, pass. Here the unasked-for call names itself. + */ +function fakeVendor(parts: { + tools?: Record; + toolkits?: Record; + authConfigs?: Record; + connectedAccounts?: Record; +}) { + return { + tools: { + getRawComposioTools: refuse("tools.getRawComposioTools"), + getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), + execute: refuse("tools.execute"), + ...parts.tools, + }, + toolkits: { + get: refuse("toolkits.get"), + authorize: refuse("toolkits.authorize"), + ...parts.toolkits, + }, + authConfigs: { + list: refuse("authConfigs.list"), + create: refuse("authConfigs.create"), + delete: refuse("authConfigs.delete"), + ...parts.authConfigs, + }, + connectedAccounts: { + list: refuse("connectedAccounts.list"), + delete: refuse("connectedAccounts.delete"), + ...parts.connectedAccounts, + }, + }; +} + +describe("listing an app's actions", () => { + test("the caller's page reaches the vendor, so the vendor's default never applies", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioTools: async (query: unknown) => { + asked.push(query); + return [ + { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + description: "Fetch emails from Gmail.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + toolkit: { slug: "gmail" }, + }, + ]; + }, + }, + }), + ); + + const listed = await actions.listActions("gmail", { + limit: LISTING_LIMIT, + }); + + // An omitted limit is not "no opinion": Composio's page defaults to 20, and + // `getRawComposioTools` additionally sets `important=true` whenever a toolkit query carried no + // limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the + // short answer is also a filtered one and nothing in it says so. Passing the limit through is + // what turns that flag off, which is why the assertion is on the query and not on the answer. + expect(asked).toEqual([{ toolkits: ["gmail"], limit: LISTING_LIMIT }]); + expect(listed).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + description: "Fetch emails from Gmail.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }, + ]); + }); +}); + +describe("the app catalogue", () => { + test("a toolkit becomes the row an administrator chooses from", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async (query: unknown) => { + asked.push(query); + return [ + { + slug: "gmail", + name: "Gmail", + isLocalToolkit: false, + meta: { + description: "Send and read mail.", + logo: "https://logos.composio.dev/gmail.png", + categories: [ + { slug: "productivity", name: "Productivity" }, + { slug: "email", name: "Email" }, + ], + toolsCount: 63, + }, + }, + // A toolkit that publishes none of the optional fields, because several do. An + // administrator picking from a few hundred apps is better served by a missing logo + // than by a broken one, so the absence has to survive as null rather than as "". + { + slug: "sparse", + name: "Sparse", + isLocalToolkit: false, + meta: {}, + }, + ]; + }, + }, + }), + ); + + const apps = await broker.listApps(); + + // The catalogue is asked for one page at the documented ceiling, ordered by usage so the apps + // anybody actually connects are at the top. No search term: the SDK's list params have no such + // field, so one passed here would be stripped before the request and the caller would be + // filtering against a list nobody filtered. + expect(asked).toEqual([{ limit: LISTING_LIMIT, sortBy: "usage" }]); + expect(apps).toEqual([ + { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: "https://logos.composio.dev/gmail.png", + categories: ["Productivity", "Email"], + actionCount: 63, + }, + { + slug: "sparse", + name: "Sparse", + description: "", + logo: null, + categories: [], + actionCount: 0, + }, + ]); + }); +}); + +describe("executing an action", () => { + test("an action belonging to another app is refused before anything is sent", async () => { + const executed: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + toolkit: { slug: "gmail" }, + }), + execute: async (...call: unknown[]) => { + executed.push(call); + return { data: {}, error: null, successful: true }; + }, + }, + }), + ); + + // The gate in `./access` cleared this person for slack, because slack is what the connection's + // url names. The slug was recorded by some earlier listing, and Composio's execute takes the + // slug ALONE — there is no toolkit field on the wire — so forwarding this would run a Gmail + // action under a gate that only ever looked at a Slack connection. + const refused = actions.execute( + { + toolkit: "slack", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + { max_results: 5 }, + ); + + // Both apps are named, because a reader holding only one of them cannot tell whether the url + // is wrong or the recorded action is. + await expect(refused).rejects.toThrow(/slack/); + await expect(refused).rejects.toThrow(/gmail/); + expect(executed).toEqual([]); + }); +}); From 4647be16f5940d53890b7a833b4e1a5c76fc1bf7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:00:02 +0200 Subject: [PATCH 102/215] Read the broker's directory through this deployment rather than from the vendor The search term goes to our own endpoint because the vendor's client drops one: its listing query is built from a fixed set of fields that does not include a search, so a term handed to the SDK comes back as an unfiltered first page that looks like a result. Filtering has to happen somewhere that admits to doing it. Each term is its own cache entry, and the action count travels with the row, because the size of an app is the thing worth knowing before enabling it rather than after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/queries.ts | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index f9186a19b..9f6e82c1f 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -102,6 +102,18 @@ export type CatalogueItem = { perInstance: boolean; }; +/** One app in Composio's directory, as the picker shows it. */ +export type ComposioApp = { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + /** The size of the decision, shown before enabling. */ + actionCount: number; + enabled: boolean; +}; + export type PluginsPage = { catalogue: CatalogueItem[]; servers: PluginServer[]; @@ -123,6 +135,14 @@ export type PluginsPage = { * consent flow at all. */ redirectUri: string | null; + /** + * Whether this deployment has Composio configured. + * + * A boolean about configuration and never the key: the page needs to know whether the directory + * can be browsed at all, and that question is answerable without the API key ever leaving the + * server. + */ + composioConfigured: boolean; }; /** What one Bot holds, which is all the runtime needs to offer it. */ @@ -146,6 +166,8 @@ export const pluginKeys = { page: () => ["plugins", "page"] as const, forAgent: (agentId: string) => ["plugins", "for-agent", agentId] as const, connections: () => ["plugins", "connections"] as const, + composioApps: (query: string) => + ["plugins", "composio", "apps", query] as const, }; /** One account this person has connected, from their own point of view. */ @@ -191,6 +213,25 @@ export function pluginsPageQueryOptions() { }); } +/** + * Composio's app directory, narrowed by a search term. + * + * The term goes to our own endpoint because the vendor's client drops a search parameter and + * answers with an unfiltered page, so filtering has to happen somewhere that admits to doing it. + */ +export function composioAppsQueryOptions(query: string) { + return queryOptions({ + queryKey: pluginKeys.composioApps(query), + queryFn: async (): Promise<{ apps: ComposioApp[] }> => { + const response = await client( + `/api/plugins/composio/apps?q=${encodeURIComponent(query)}`, + { fallback: "Composio's app directory could not be read." }, + ); + return response.json(); + }, + }); +} + /** * Polled grant snapshot for what the active Bot should be offered; call-time checks still enforce. */ From 30301f9428f64dd1bcd1add5ab077f5516fc373c Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:00:02 +0200 Subject: [PATCH 103/215] Ask the broker whether a connection is real, and end one where it lives Two writes, no screen yet. Confirming exists because the return trip from consent proves nothing: it is an ordinary redirect with nothing signed in it, so anybody who can visit the address could otherwise grant themselves somebody's mailbox. The broker holds the account and is the only thing that can answer, which is also why calling this again on an ordinary page load is safe and heals a row that drifted. Disconnecting ends the account at the broker rather than only here. Starting a connection needs no new write: the existing one already reads an authorization url off the connect route, and a brokered app answers that route too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/mutations.ts | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index 09a79a6b5..4718a624e 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -271,6 +271,58 @@ export function connectAccountMutationOptions( }); } +/** + * Ask the vendor whether a brokered connection actually completed. + * + * Exists because the return trip from consent proves nothing. The callback is an ordinary redirect + * with nothing signed in it, so somebody arriving back on the page is not evidence that they + * finished the flow — or that the account they finished it with is the one the row claims. So the + * vendor is asked, and its answer is what the connected state is written from. + * + * Answers with the body rather than a bare success, because "asked, and told not connected" is a + * different thing for a screen to say than "could not ask". + * + * There is no start half here: beginning a brokered connect is the same write as any other consent + * flow, so callers use `connectAccountMutationOptions` above, which already reads the vendor's + * `authorizationUrl` off the connect route. + */ +export function confirmBrokeredConnectionMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (serverId: string): Promise<{ connected: boolean }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection/confirm`, + { method: "POST", fallback: "That connection could not be confirmed." }, + ); + return (await response.json()) as { connected: boolean }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * End the signed-in person's brokered connection. + * + * Ends the account at Composio rather than only here. Forgetting the row on our side would leave + * the vendor still holding a live grant on somebody's mailbox, which is not what the person who + * pressed disconnect was told would happen. + */ +export function disconnectBrokeredMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (serverId: string) => { + await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection`, + { + method: "DELETE", + fallback: "That account could not be disconnected.", + }, + ); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + export function removeSkillMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (slug: string) => { From 77d1f4163ccaccea3834b0915394aa7e90a29893 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 104/215] Record a brokered connection on the broker's word, and heal it in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three methods beside the ones that serve a personal OAuth vendor: read this person's connection to one app, confirm it, and end it. THE VENDOR IS ASKED, NOT THE BROWSER. Coming back from consent proves nothing — it is an ordinary redirect with nothing signed in it, so anybody who can visit the address could otherwise grant themselves somebody's mailbox. Composio holds the account and is the only thing that can answer, which makes our row a cache of that answer rather than the fact itself. WHICH IS WHY IT HEALS IN BOTH DIRECTIONS. A yes writes the row where none was; a no deletes the row where one stood. Without the second half an account somebody ended in Composio's own dashboard would read as connected here forever, on a screen that reads our row rather than the vendor's, while the app's own page asked the vendor and said the opposite. Neither deletion files a trail entry: nobody disconnected anything, this is our record catching up, and the trail records acts by actors. The connected event is written only where the row is new, for the same reason. The confirm runs on every page load, so filing one each time would fill the trail with acts nobody performed. REVOKE BEFORE DELETE on the disconnect. Deleting first leaves a live account at the broker that no operation here can reach, because a brokered row's app is only ever readable off the row that would by then be gone. Also here, because it belongs to the same table: a listing of this person's brokered connections for their settings page, taking each app's row id off the row itself rather than composing it from the slug. And a refusal of the server id `composio`, which an administrator could until now have added by URL and then never opened, because the app directory's own screen already answers there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 273 ++++++++++++ server/tests/plugin-store.integration.test.ts | 420 ++++++++++++++++++ 2 files changed, 693 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0a7cd8160..581283f00 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2123,6 +2123,19 @@ export function createPluginStore(options: PluginStoreOptions) { `${input.id} is the name of a server this deployment already knows. Choose another.`, ); } + + /* + * Nor may it take the name of a screen. `/admin/plugins/composio` is a static route — the app + * directory's own page — and a static route is matched ahead of `/admin/plugins/$key`, so a + * server sitting at this id would be listed and then open somebody else's page instead of its + * own. Brokered servers are `composio-` and no catalogue entry is called this, which + * leaves a hand-typed id as the only way to reach it. + */ + if (input.id === "composio") { + throw new CustomServerRefusedError( + "composio is the name of this deployment's own Composio screen, so a server added there could never be opened. Choose another.", + ); + } if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(input.id)) { throw new CustomServerRefusedError( "A server name is lower-case letters, numbers and hyphens.", @@ -3555,6 +3568,266 @@ export function createPluginStore(options: PluginStoreOptions) { })); }, + /** + * Which brokered apps this person has connected, for the same settings page. + * + * A SECOND METHOD RATHER THAN A WIDER {@link connectionsFor}, because the two answer out of + * different tables for a reason the schema is built on: a `user-oauth` connection is a pointer + * into the vault, and a brokered one holds no secret at all because Composio keeps the account + * (see {@link brokeredConnection}). Reading only the vault side is what left a brokered + * connection invisible to the browser, so the settings screen could not honestly say whether + * somebody was connected. + * + * THE SERVER ID IS JOINED, NOT SPELLED. `addBrokeredApp` writes the app into the url, and every + * later call resolves against that url — so matching on it asks the row what it is, where + * composing `composio-${toolkit}` by hand would re-derive the id from a convention nothing + * holds it to. It is the reasoning the directory route already uses when it reads a row's + * toolkit off its url rather than off its id. An app this deployment has since removed + * therefore drops out of the answer, which is the honest result: there is no server row left + * for a page to name. + * + * `scope` IS EMPTY for the reason {@link confirmBrokeredConnection} sets out: Composio grants + * none that it tells us about, and the field exists to record what the vendor said it granted + * rather than what we suppose. It is returned all the same, so the shape matches what + * {@link connectionsFor} answers and one screen can draw both kinds of row. + */ + async brokeredConnectionsFor( + userId: string, + ): Promise<{ serverId: string; scope: string; connectedAt: string }[]> { + const rows = await database + .select({ + serverId: mcpServers.id, + connectedAt: composioConnections.connectedAt, + }) + .from(composioConnections) + .innerJoin( + mcpServers, + sql`${mcpServers.url} = 'composio://' || ${composioConnections.toolkit}`, + ) + .where(eq(composioConnections.userId, userId)) + .orderBy(asc(mcpServers.id)); + + return rows.map((row) => ({ + serverId: row.serverId, + scope: "", + connectedAt: iso(row.connectedAt) ?? "", + })); + }, + + /** + * Whether this person has one brokered app connected, and since when. + * + * THE ROW IS A CACHE OF COMPOSIO'S ANSWER, not a record of a flow this deployment watched + * finish. Nothing here holds a secret for a brokered app: the vendor keeps the account, and + * what {@link composioConnections} holds is the sentence "Composio said yes when we asked", + * written down so that every later call can be gated without a round trip. That makes drift + * possible by construction — somebody can end the connection in Composio's own dashboard, and + * this row would go on saying yes — and it is why {@link confirmBrokeredConnection} asks the + * vendor again rather than trusting what is here. Calling confirm on any page load is + * therefore how a row that drifted heals. + * + * Read by the pair, because the pair is the primary key: an app has many people's connections + * and a person has many apps, and the only question anybody asks is about one of each. + */ + async brokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ connectedAt: string } | null> { + const [row] = await database + .select({ connectedAt: composioConnections.connectedAt }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ) + .limit(1); + + if (!row) return null; + return { connectedAt: iso(row.connectedAt) ?? "" }; + }, + + /** + * Ask Composio whether this person's account is really attached, and write down the answer. + * + * THE VENDOR IS ASKED, NOT THE BROWSER. The return trip from a consent screen is an ordinary + * redirect carrying nothing signed, so a person arriving back on the page is not evidence that + * they finished the flow, nor that the account they finished it with is the one a row would + * claim. A confirm that wrote a row because somebody came back would hand every later brokered + * call a gate that passes for an account nobody has — and the first anyone would hear of it is + * the vendor's own error about a connection it cannot find, at the moment a Bot was asked to do + * something. + * + * SO THE ANSWER NO LEAVES NO ROW BEHIND. `false` from {@link ComposioBroker.isConnected} is a + * positive claim that there is no account, and the honest local state for that claim is an + * absence — so a row already sitting here is deleted rather than left standing. Leaving it + * would have the settings list go on drawing "Connected" for an account nobody has, and would + * go on passing the gate every later brokered call is decided on, while the app's own detail + * page asks the vendor and says the opposite. + * + * AND THAT DELETION FILES NO TRAIL ENTRY. Nobody disconnected anything here: the grant ended + * somewhere else, and this is our record catching up with a fact. {@link disconnectBrokered} + * owns `mcp.account_disconnected` and files it for the act it performed; a second filer here + * would have the trail claim an act that did not happen, credited to whichever page load + * happened to notice. + * + * UPSERT RATHER THAN INSERT, keyed on the pair the table itself is keyed on. This is safe to + * call repeatedly and is meant to be: because the row is only a cache of the vendor's answer + * (see {@link brokeredConnection}), a row that drifted out of step — an account ended in + * Composio's own dashboard, a connect this deployment missed the callback for — is healed by + * the next confirm on any page load, in whichever direction it drifted: by the upsert here + * where the vendor says yes, and by the delete above where it says no. + * + * `scope` IS EMPTY BECAUSE COMPOSIO GRANTS NONE THAT IT TELLS US ABOUT. The field exists so a + * later refusal for want of a permission can be explained by what the vendor actually granted, + * and Composio's connection answer is a boolean with no scope in it. Writing a plausible claim + * there — the app's full access, say — would put words in the vendor's mouth in the one field + * whose whole job is to say what it said. + * + * `reconnected` IS FALSE FOR THE SAME REASON, and trivially so. The flag distinguishes somebody + * replacing a grant from somebody making one, and the only confirms that reach the trail are + * the ones that found no row at all — so there was nothing here to replace. + * + * AND THE EVENT IS WRITTEN ONLY WHERE THE ROW IS NEW. This method runs on every page load + * rather than only when a person acts, so an event per yes from the vendor would file ten + * "account connected" rows for somebody who opened the connector page ten times having + * connected once. A confirm that heals a row nothing changed is a read, and the trail records + * acts: where a row was already there the connection has been recorded once already, by the + * confirm that first found none. + */ + async confirmBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ connected: boolean }> { + // Before anything, and for the reason `addBrokeredApp` says it first too: a deployment with + // no key has no broker to have connected anybody at, so there is nothing here to ask. + if (!broker) throw new BrokerUnconfiguredError(); + + const connected = await broker.isConnected({ + userId: input.userId, + toolkit: input.toolkit, + }); + if (!connected) { + // Deleted rather than left alone, because the row is only the vendor's last answer: an + // account ended in Composio's own dashboard reaches this deployment as the no above and + // as nothing else, and a row that outlived it would go on saying yes about an account the + // vendor has just denied. + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ); + return { connected: false }; + } + + // Read before the write, because the upsert leaves nothing behind that tells the two cases + // apart, and whether a row was already here is the whole of what decides if anybody acted. + const existing = await this.brokeredConnection(input); + + await database + .insert(composioConnections) + .values({ toolkit: input.toolkit, userId: input.userId }) + .onConflictDoUpdate({ + target: [composioConnections.toolkit, composioConnections.userId], + // `connected_at` is left alone on purpose: the person connected when they connected, and + // a confirm that moved it would make every page load look like a fresh connection on + // their own settings page. + set: { updatedAt: new Date() }, + }); + + if (!existing) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, which is all a brokered connection is keyed on — the same id + // `retireConnectionsFor` files its rows under, so one query answers what happened to one + // person's access to one app however it ended. + targetId: input.toolkit, + payload: { + actor: input.userId, + server: input.toolkit, + scope: "", + reconnected: false, + }, + }); + } + + return { connected: true }; + }, + + /** + * End this person's brokered account at the vendor, and then forget where it was. + * + * REVOKE BEFORE DELETE, AND THAT ORDER IS THE WHOLE METHOD. The row is the only thing in this + * deployment that says which app this person connected: the app is read off + * `composio_connections`, and a revoke needs it. Delete first and a revoke that then fails + * leaves a live grant on somebody's mailbox that no operation here can reach, because the one + * value it would have to be revoked under is gone. The other order costs nothing by + * comparison — a revoke that throws leaves the row standing, the person presses disconnect + * again, and the second attempt has everything the first one had. + * + * WHICH ALSO MEANS THE FAILURE IS LOUD. Nothing is caught here: a broker that will not answer + * ends this call, and no row and no trail entry claims an account was disconnected when the + * account is still live. + * + * `vendorRevoked` IS WHAT HAPPENED, NOT WHAT WAS ATTEMPTED — {@link ComposioBroker.revoke}'s + * own answer, passed through. True where a grant was withdrawn, false where there was none to + * withdraw, and the value of the field is exactly that a reader can tell a grant this + * deployment ended from one that outlives it somewhere else. + */ + async disconnectBrokered(input: { + toolkit: string; + userId: string; + by: string; + /** + * Why the account ended, which is the closed pair and not free text. A brokered account ends + * in exactly two ways — the person disconnecting their own, and the person being removed + * from the People screen, which is the word {@link retireConnectionsFor} already files its + * own rows under. A reader asking the trail which of the two happened can be answered only + * if it is the same word every time, so the type is the pair rather than whatever sentence a + * caller happened to spell. + */ + reason: "self" | "person_removed"; + }): Promise<{ vendorRevoked: boolean }> { + if (!broker) throw new BrokerUnconfiguredError(); + + const vendorRevoked = await broker.revoke({ + userId: input.userId, + toolkit: input.toolkit, + }); + + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ); + + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + // Whose account this was, which is not always who ended it: an administrator offboarding + // somebody and a person disconnecting themselves write the same shape of row, and only + // these two fields tell them apart. + owner: input.userId, + reason: input.reason, + vendorRevoked, + }, + }); + + return { vendorRevoked }; + }, + /** * Retire every connector credential belonging to one person. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 3a6a5769f..affffe889 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3341,6 +3341,36 @@ describe("a dynamic client the vendor has evicted", () => { }); }); +/** + * A custom server may not take a name the app directory already answers to. + * + * `/admin/plugins/composio` is a static route and `/admin/plugins/$key` is the one every server is + * opened through, and a static route wins. So a server whose id is literally `composio` would be + * listed, saved, refreshed and then never openable: the row for it would send the operator to the + * Composio screen instead. Brokered ids are `composio-`, so this is only reachable by typing + * the id into the custom-server form, which the id pattern otherwise allows. + */ +describe("a custom server may not be named after one of the app's own screens", () => { + test("the id composio is refused, and no server is written", async () => { + await expect( + store.addCustomServer({ + id: "composio", + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + // Written-and-unopenable is the whole harm, so the refusal has to stop the write rather than + // report on it afterwards. + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio")); + expect(rows).toHaveLength(0); + }); +}); + /** * Which credential a custom server is allowed to be pointed at. * @@ -6011,3 +6041,393 @@ test("enabling an app with no broker says which setting is missing", async () => }), ).rejects.toThrow("COMPOSIO_API_KEY"); }); + +/** + * A broker that answers only the methods a test names, in the order it was asked. + * + * WHAT IS NOT NAMED THROWS, which is the half that carries the assertions below. "The disconnect + * asked the broker to revoke" is worth very little on its own; "and asked it nothing else" is the + * property, because a connection path that also listed the catalogue or created an auth config + * would be doing work on somebody's behalf that nobody here has reasoned about. A default stub + * answering plausibly would let all of that pass unremarked. + * + * `order` records the calls rather than counting them, so a test can say which of two things + * happened first. The state a call found the database in is NOT recorded here: the handlers are + * the test's own functions, so a test that needs to know what a row looked like at the moment of + * the call reads it inside its own handler — the same way the enablement test above establishes + * that the auth config comes before the row. + */ +function brokerSpy(answers: { + isConnected?: (request: { + userId: string; + toolkit: string; + }) => Promise; + revoke?: (request: { userId: string; toolkit: string }) => Promise; +}): { broker: ComposioBroker; order: string[] } { + const order: string[] = []; + const unasked = (what: string) => async (): Promise => { + throw new Error(`the connection path asked the broker to ${what}`); + }; + const asked = ( + name: string, + handler: ((request: Request) => Promise) | undefined, + what: string, + ) => { + return async (request: Request): Promise => { + order.push(name); + if (!handler) + throw new Error(`the connection path asked the broker to ${what}`); + return await handler(request); + }; + }; + + return { + order, + broker: { + listApps: unasked("list the catalogue"), + ensureAuthConfig: unasked("create an auth config"), + deleteAuthConfig: unasked("delete an auth config"), + authorize: unasked("begin somebody's connection"), + isConnected: asked( + "isConnected", + answers.isConnected, + "check somebody's connection", + ), + revoke: asked("revoke", answers.revoke, "withdraw somebody's grant"), + }, + }; +} + +/** + * The row is the vendor's answer written down, and nothing else may write it. + * + * CRITERION. After a confirm the deployment says somebody is connected if and only if Composio + * said so when asked. + * + * REASON. The return trip from consent is an ordinary redirect with nothing signed in it, so a + * browser arriving back on the page is evidence of nothing at all — not that the flow finished, + * and not that the account it finished with is the one this row would claim. A confirm that wrote + * a row because somebody came back would hand every subsequent brokered call a gate that passes + * for an account that may not exist, and the first thing anybody would see of the mistake is the + * vendor's own error about a connection it cannot find. + * + * BOTH ANSWERS IN ONE TEST, over one store, because the second is what makes the first mean + * something: a confirm that never wrote a row would pass the "not connected" assertion on its own. + */ +test("a brokered connection row is written only where the vendor says the account is live", async () => { + let live = false; + const { broker, order } = brokerSpy({ isConnected: async () => live }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: false, + }); + // Nothing at all, which is the whole of the first half: the gate a brokered call is decided on + // must not exist for somebody the vendor does not recognise. + expect(await store.brokeredConnection(pair)).toBeNull(); + expect(auditStore.recorded()).toHaveLength(0); + + live = true; + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + const connection = await store.brokeredConnection(pair); + expect(connection?.connectedAt).toBeTruthy(); + + expect(order).toEqual(["isConnected", "isConnected"]); + const connected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_connected"); + expect(connected).toHaveLength(1); + expect(connected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + // Empty because Composio grants no scope this deployment is told about, and the field explains + // a later refusal for want of one. A guess written here would be an explanation nobody gave. + scope: "", + reconnected: false, + }); +}); + +/** + * An account ended in Composio's own dashboard is forgotten here the next time we ask. + * + * CRITERION. Where a row is already written down and the vendor says the account is not live, the + * confirm removes the row, and it files nothing in the trail for having removed it. + * + * REASON. The row is a cache of the vendor's answer, and nothing tells this deployment when that + * answer changes: a grant withdrawn at Composio ends the account with no callback arriving here. + * A confirm that only ever wrote rows would leave the settings list drawing "Connected" for an + * account nobody has, leave the gate every brokered call is decided on passing for it, and leave + * the app's own detail page — which asks the vendor on mount — contradicting the list beside it. + * + * THE TRAIL STAYS EMPTY, which is asserted rather than assumed. Nobody disconnected anything: the + * grant ended elsewhere and this is the record catching up, so an `mcp.account_disconnected` row + * written here would credit a page load with an act it did not perform. `disconnectBrokered` is + * what files that event, for the disconnect it actually carried out. + */ +test("confirming a brokered connection the vendor no longer has removes the row", async () => { + const { broker, order } = brokerSpy({ isConnected: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: false, + }); + + expect(await store.brokeredConnection(pair)).toBeNull(); + expect(order).toEqual(["isConnected"]); + expect(auditStore.recorded()).toHaveLength(0); +}); + +/** + * A confirm that healed a row nothing changed is a read, and the trail does not record reads. + * + * CRITERION. Confirming a connection that is already written down leaves exactly the one + * `mcp.account_connected` row the first confirm filed, however many times it is called. + * + * REASON. This method runs on page load and not on a button: the connector page calls it once per + * mount for every brokered app it draws. An event per yes from the vendor therefore writes ten + * "account connected" rows for somebody who opened the page ten times having connected once, and + * a trail padded with acts nobody performed cannot answer the only question it is kept for. It is + * the failure the `reconnected` field is already written to avoid, arriving one level up at the + * event itself. + * + * THE VENDOR IS STILL ASKED EVERY TIME, which is asserted here rather than assumed: the row is a + * cache of Composio's answer and the re-asking is how a row that drifted heals. What stops on the + * second call is the writing-down of the heal as somebody's act, not the heal. + */ +test("confirming a brokered connection already recorded writes no second trail row", async () => { + const { broker, order } = brokerSpy({ isConnected: async () => true }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + const first = await store.brokeredConnection(pair); + expect(first?.connectedAt).toBeTruthy(); + + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + expect(await store.confirmBrokeredConnection(pair)).toEqual({ + connected: true, + }); + + expect(order).toEqual(["isConnected", "isConnected", "isConnected"]); + // Unmoved, because the person connected when they connected: the confirms above are page loads. + expect(await store.brokeredConnection(pair)).toEqual(first); + expect( + auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_connected"), + ).toHaveLength(1); +}); + +/** + * Disconnecting ends the account at the vendor BEFORE it forgets where the account was. + * + * CRITERION. The broker is asked to revoke while the row is still there, and the row goes only + * after it answered. + * + * REASON. The row is the only thing in this deployment that names which app this person connected: + * delete it first and a revoke that then fails leaves a live grant on somebody's mailbox that no + * operation here can reach, because the toolkit it would have to be revoked under is readable off + * a row that is by now gone. Ordering the other way is recoverable by definition — pressing + * disconnect again asks again. + * + * ASSERTED ON WHAT THE REVOKE SAW, not on a call count, because a count says nothing about order + * and the order is the entire property. + */ +test("disconnecting a brokered connection revokes at the vendor before the row goes", async () => { + const rowsWhenRevoked: string[] = []; + const { broker, order } = brokerSpy({ + revoke: async () => { + const rows = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(ownedConnections()); + rowsWhenRevoked.push(...rows.map((row) => row.userId)); + return true; + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + const outcome = await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }); + + expect(outcome).toEqual({ vendorRevoked: true }); + expect(rowsWhenRevoked).toEqual(["user_asker"]); + expect(order).toEqual(["revoke"]); + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + // What happened, not what was attempted. The broker said it withdrew a grant, so the trail + // says so; a field that always said true would make the row a worse record than none. + vendorRevoked: true, + }); +}); + +/** + * A revoke that throws leaves the connection exactly where it was, so pressing again finishes it. + * + * CRITERION. A failed disconnect removes nothing and records nothing, and the same call made again + * against a broker that now answers completes the job. + * + * REASON. This is the payoff of the ordering above, stated as the behaviour somebody actually + * meets: Composio is down for a minute, the person presses disconnect, and the alternative to + * keeping the row is an account still live at the vendor with nothing left here that knows which + * app it belongs to. Keeping it means the only cost of the failure is that they press the button + * again. + */ +test("a brokered connection outlives a revoke that failed, and a second attempt ends it", async () => { + let broken = true; + const { broker } = brokerSpy({ + revoke: async () => { + if (broken) throw new Error("Composio would not answer (502)."); + return true; + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + await expect( + store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).rejects.toThrow("Composio would not answer (502)."); + + expect(await store.brokeredConnection(pair)).not.toBeNull(); + // No row in the trail either. "Their account was disconnected" is a claim about the vendor, and + // nothing was disconnected anywhere. + expect(auditStore.recorded()).toHaveLength(0); + + broken = false; + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevoked: true }); + expect(await store.brokeredConnection(pair)).toBeNull(); +}); + +/** + * A revoke that found nothing to withdraw says so, and the row goes all the same. + * + * CRITERION. Where the broker answers `false`, both the outcome and the trail carry + * `vendorRevoked: false`, and the `composio_connections` row is deleted regardless. + * + * REASON. This is the grant somebody already ended in Composio's own dashboard. The account is + * gone at the vendor, so the local row is the stale half of a pair that has drifted and deleting + * it is what makes the two agree again. What must not happen is the trail claiming this + * deployment withdrew something: a row saying the grant was ended here when it was ended + * somewhere else is a worse record than none, because whoever reads back for who ended it is + * given the wrong answer in the same words as the right one. + * + * THE FALSE IS THE WHOLE TEST. `vendorRevoked` is indistinguishable from a hardcoded `true` until + * a revoke answers no, and no other test in this file exercises one. + */ +test("a brokered disconnect that withdrew no grant records that it withdrew none", async () => { + const { broker, order } = brokerSpy({ revoke: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + await database.insert(composioConnections).values(pair); + + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevoked: false }); + + expect(order).toEqual(["revoke"]); + // Gone, because there was nothing at the vendor and the row was therefore the half that had + // drifted. Keeping it would leave the gate on every brokered call passing for an account that + // no longer exists anywhere. + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + vendorRevoked: false, + }); +}); + +/** + * A brokered connection is visible to the person who made it, under the server row's own id. + * + * CRITERION. `brokeredConnectionsFor` answers one row per app this person has connected, shaped + * exactly as `connectionsFor` answers, and the `serverId` in it is the id of the `mcp_servers` row + * whose url names that app. + * + * REASON. `connectionsFor` selects from the vault's join table alone, so a brokered connection was + * invisible to the browser and the settings screen could not honestly say whether somebody was + * connected. The id has to be joined rather than composed because the url is where the app is + * recorded: `composio-${toolkit}` spelled by hand answers with whatever production happens to + * spell today, and this fixture — a server row at `gmail`, not at `composio-gmail` — is the case + * that tells a joined id from a guessed one. + */ +test("a brokered connection is listed for its owner under the server row's own id", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + const connections = await store.brokeredConnectionsFor("user_asker"); + expect(connections).toHaveLength(1); + expect(connections[0]).toMatchObject({ + serverId: "gmail", + // Empty for the reason the confirm gives: Composio grants no scope it tells us about, and the + // field is returned anyway so one settings screen can draw both kinds of connection. + scope: "", + }); + expect(connections[0]?.connectedAt).toBeTruthy(); + + // Nobody else's, which is the only other thing this query promises. + expect(await store.brokeredConnectionsFor("user_leaver")).toEqual([]); +}); + +/** + * And it is the url that decides which server row a connection belongs to, not the id. + * + * CRITERION. A person connected to `gmail`, with the only Composio server row sitting at + * `composio://slack`, is listed against nothing. + * + * REASON. The id and the url are two fields and nothing in the schema holds them equal — which is + * the shape `seedComposioGmail` exists to reproduce. Reading the connection's app off the url is + * the same thing the directory route does when it decides which apps are enabled, and it is what + * stops this query telling somebody they have a Slack connection because a row called `gmail` + * happened to be pointed somewhere else. + */ +test("a brokered connection is not listed against a server row whose url names another app", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store, { url: "composio://slack" }); + + expect(await store.brokeredConnectionsFor("user_asker")).toEqual([]); +}); From f6b24c618eef38aecb6b06d129d612f7acca6ada Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 105/215] Offer the broker's directory, and answer what one person has connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two admin routes and one widened read. The directory is fetched whole and searched in this process. The vendor's client builds its listing query from a fixed set of fields that does not include a search, so a term handed to the SDK comes back as an unfiltered first page that looks like a result — filtering has to happen somewhere that admits to doing it. No key is 503 with the sentence naming the setting, never an empty list: an empty directory and an absent one are different facts, and a screen must not conflate them. THE SLUG CHECK IS THE WHOLE ENABLE ROUTE. A slug becomes the address every future call for that row runs against, so only a name the broker itself published may reach the store. The title comes off the directory entry too, never off the body. The admin check is called inside each handler and its response returned. It is a function, not middleware, and mounting it in the middleware position typechecks against the framework's variadic signature while gating nothing. And the connections read now answers for both kinds at once. The person asking has one question — what have I connected — and which table the answer lives in is a fact about how a vendor is reached rather than something a settings page should have to know. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 160 ++++++++++++++- server/tests/plugin-routes.test.ts | 316 +++++++++++++++++++++++++++++ 2 files changed, 474 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index fb801450b..08de91a35 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3,8 +3,9 @@ import { Hono } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; -import type { ComposioBroker } from "./broker"; +import { BrokerUnconfiguredError, type ComposioBroker } from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; +import { toolkitOf } from "./composio"; import { authorizationUrlFor, challengeFor, @@ -413,6 +414,133 @@ export function createPluginRoutes( } }); + /** + * The broker's catalogue, as an administrator chooses an app out of it. + * + * THE SEARCH IS OURS, AND HAS TO BE. `@composio/core`'s toolkit listing forwards category, + * managed_by, sort_by, cursor and limit, and takes no search term at all — a term handed to it is + * dropped without a word, and what comes back is an unfiltered first page that looks exactly like + * a result. So the whole directory is read and filtered in this process, over the three fields an + * administrator would actually be typing at: the slug, the name and the description. A few + * hundred rows is a list, not a query. + * + * NO BROKER IS A 503 NAMING THE SETTING, not an empty list. An empty directory and an absent one + * are different facts: the first says Composio has nothing to offer, the second says nobody was + * asked. Answered as `{ apps: [] }`, a deployment with no key draws "no apps available" over a + * remedy that is one environment variable long, which is why + * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written + * here. + * + * `enabled` comes off `toolkitOf(server.url)` and never off the row's id. The url is where the + * transport reads which app a call is against, so it is the only reading that decides anything; + * the id names the row — `composio-linear` — and reading one as the other would quietly work + * until somebody renamed a row. + */ + routes.get("/composio/apps", requireUser, async (context) => { + /* + * INSIDE THE HANDLER, and the response returned. `requireAdmin` is a function that answers a + * response, not Hono middleware: put in the middleware position it typechecks against Hono's + * variadic signature, runs, and gates nothing at all, because nobody reads what it returned. + */ + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + const directory = await composio.broker.listApps(); + const term = (context.req.query("q") ?? "").trim().toLowerCase(); + const matched = term + ? directory.filter((app) => + [app.slug, app.name, app.description].some((field) => + field.toLowerCase().includes(term), + ), + ) + : directory; + + const enabled = new Set( + (await store.listServers()) + .map((server) => toolkitOf(server.url)) + .filter((toolkit): toolkit is string => toolkit !== null), + ); + return context.json({ + apps: matched.map((app) => ({ ...app, enabled: enabled.has(app.slug) })), + }); + }); + + /** + * Enable one app of that catalogue, which is a third way for a server to arrive. + * + * THE SLUG VALIDATION IS THE WHOLE ROUTE. `addBrokeredApp` composes `composio://`, and that + * url is what every future call for the app is resolved against — so a slug the directory never + * answered with is a row pointing at an app that does not exist: added, grantable, enabled on a + * Bot, and dead at the first call, with nothing on the page saying so. The live directory is what + * it is checked against rather than a pattern, because the question is not whether the text is + * well formed but whether Composio has such an app right now. + * + * The title comes off the directory entry too. The caller chose an app; they did not choose a + * name for it. + */ + routes.post("/composio/apps", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + const body = (await context.req.json().catch(() => null)) as { + slug?: string; + } | null; + const slug = body?.slug?.trim(); + const app = slug + ? (await composio.broker.listApps()).find( + (candidate) => candidate.slug === slug, + ) + : undefined; + if (!app) { + return context.json( + { + error: slug + ? `${slug} is not an app Composio lists for this deployment.` + : "An app is required.", + }, + 400, + ); + } + + try { + const server = await store.addBrokeredApp({ + slug: app.slug, + title: app.name, + by: actorEmail(context), + }); + return context.json({ server }, 201); + } catch (error) { + // The same mapping the add routes above make, for the same reason: a refusal an + // administrator can correct comes back as itself rather than as a 500. + if ( + error instanceof CustomServerRefusedError || + error instanceof PluginRefusedError + ) { + return context.json({ error: error.message }, 400); + } + // And, as on those routes, enabling refreshes before it answers, so every fault + // `refreshTools` raises arrives here as well. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } + throw error; + } + }); + /** * Where a person's own connections are, and how to start a new one. * @@ -420,7 +548,35 @@ export function createPluginRoutes( * everybody connects their own account. Somebody can only ever see or start their own. */ routes.get("/connections", requireUser, async (context) => { - const connections = await store.connectionsFor(context.var.actor.id); + /* + * BOTH TABLES, ONE LIST, because the person asking has one question. + * + * "Am I connected to this?" is the same question whether the grant is a refresh token in this + * deployment's vault or an account Composio holds on our behalf. Which table a connection lives + * in is a fact about how the vendor is reached — the transport, the credential, who keeps the + * secret — and none of that is something a settings page should have to know in order to draw a + * word beside a row. Answering out of `connectionsFor` alone left the brokered half invisible, + * so the page could only either say "Not connected" over a live account or say nothing at all, + * and it chose to say nothing. + * + * A brokered row is an ordinary connection — `brokeredConnectionsFor` answers in the shape + * `connectionsFor` answers, deliberately — so nothing here marks which source a row came from + * and nothing downstream asks. + * + * SORTED, so two requests answer in the same order. Each read is ordered by server id within + * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain + * strings rather than by `localeCompare`, because the order only has to be the SAME one every + * time, and a collation that varies with the deployment's locale is not that. + */ + const [held, brokered] = await Promise.all([ + store.connectionsFor(context.var.actor.id), + store.brokeredConnectionsFor(context.var.actor.id), + ]); + const connections = [...held, ...brokered].sort((left, right) => { + if (left.serverId < right.serverId) return -1; + return left.serverId > right.serverId ? 1 : 0; + }); + return context.json({ connections, // Shown to an administrator so they can register the client at the vendor with the exact value diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 529ba6311..76c08d550 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -551,3 +551,319 @@ describe("granting a Bot itself", () => { expect(calls).toEqual([]); }); }); + +/** + * The app directory an administrator picks a brokered app out of. + * + * TWO THINGS ARE BEING PINNED, and they are the two a reader would assume the vendor does for us. + * The search is ours, because `@composio/core` forwards only category, managed_by, sort_by, cursor + * and limit and drops a search term without saying so — a forwarded term comes back as an + * unfiltered first page, which looks exactly like a result. And the slug on a POST is checked + * against the directory that was just read, because that slug becomes the url every future call + * for the app runs against. + * + * The no-broker answer is a 503 naming the setting rather than an empty list: an empty directory + * and an absent one are different facts, and only one of them has a remedy. + */ +const DIRECTORY = [ + { + slug: "slack", + name: "Slack", + description: "Post messages and read channels.", + logo: null, + categories: ["communication"], + actionCount: 63, + }, + { + slug: "gmail", + name: "Gmail", + description: "Read and send mail.", + logo: null, + categories: ["communication"], + actionCount: 24, + }, + { + slug: "linear", + name: "Linear", + description: "Track issues.", + logo: null, + categories: ["project-management"], + actionCount: 18, + }, +]; + +function directoryApp( + /** Null is a deployment with no COMPOSIO_API_KEY, which is the shipped default. */ + listApps: (() => Promise) | null = async () => DIRECTORY, + role: "admin" | "user" = "admin", + /** What this deployment has already added, which is where `enabled` comes from. */ + servers: Array<{ id: string; url: string }> = [], +) { + const added: Array<{ slug: string; title: string; by: string }> = []; + const store = { + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => servers, + listSkills: async () => [], + listGrants: async () => [], + addBrokeredApp: async (input: { + slug: string; + title: string; + by: string; + }) => { + added.push(input); + return { id: `composio-${input.slug}`, url: `composio://${input.slug}` }; + }, + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + // Positions 16-25 are the stores after it; the broker is 26, `composio`. + ...(Array.from({ length: 10 }) as never[]), + listApps ? ({ broker: { listApps } } as never) : undefined, + ); + + return { added, app }; +} + +describe("the Composio directory", () => { + test("a deployment with no broker is told which setting to set", async () => { + const { app } = directoryApp(null); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + // 503 rather than `{ apps: [] }`. An empty directory and an absent one are different facts, + // and a page shown the empty one draws "no apps available" over a deployment that simply has + // no key. + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + }); + + test("a search term filters the directory here, not at the vendor", async () => { + /* + * `@composio/core` forwards only category, managed_by, sort_by, cursor and limit, and silently + * drops anything else — so a term handed to their client comes back as an unfiltered first + * page that reads as a result. The filter is ours, over slug, name and description. + */ + const { app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps?q=sla", + ); + + expect(response.status).toBe(200); + expect( + (await response.json()).apps.map((app: { slug: string }) => app.slug), + ).toEqual(["slack"]); + }); + + test("an app is enabled by the url of the row, not by the row's id", async () => { + // Which app a row is comes off its url and only off its url, because that is where the + // transport reads it from. An id read as an app name is a different question wearing the same + // answer's clothes. + const { app } = directoryApp(undefined, "admin", [ + { id: "an-id-nobody-should-read", url: "composio://slack" }, + ]); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + const apps = (await response.json()).apps as Array<{ + slug: string; + enabled: boolean; + }>; + expect(apps.find((entry) => entry.slug === "slack")?.enabled).toBe(true); + expect(apps.find((entry) => entry.slug === "gmail")?.enabled).toBe(false); + }); + + test("a slug the directory never answered with is refused", async () => { + /* + * THE VALIDATION IS THE WHOLE ROUTE. The slug becomes `composio://`, which is the url + * every future call for the app is resolved against, so a slug nobody listed is a row pointing + * at an app that does not exist — added, grantable, and dead at the first call. + */ + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "not-an-app" }), + }, + ); + + expect(response.status).toBe(400); + expect(added).toEqual([]); + }); + + test("an app the directory does list is added", async () => { + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(201); + // The title comes off the directory entry, never off the request: the caller chose an app, not + // a name for it. + expect(added).toEqual([{ slug: "slack", title: "Slack", by: ADMIN.email }]); + }); + + test("somebody who is not an administrator sees none of it", async () => { + // Enabling an app writes every one of its actions in front of a model, which is the same + // decision as adding an MCP server and stays an administrator's. + const { added, app } = directoryApp(undefined, "user"); + + expect( + (await app.request("http://openbot.test/api/plugins/composio/apps")) + .status, + ).toBe(403); + + const posted = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + expect(posted.status).toBe(403); + expect(added).toEqual([]); + }); +}); + +/** + * What one person's connected accounts are, when some of them are brokered. + * + * CRITERION. A brokered connection appears in `GET /connections` for the person who holds it, and + * for nobody else, in the same list as this deployment's own OAuth connections. + * + * REASON. The route answered out of `connectionsFor` alone, which reads the vault's join table, so + * an app connected through Composio was invisible to the browser however live it was. The settings + * page could then only lie about it or say nothing, and it said nothing. The two reads are separate + * because the tables are — one holds a refresh token, the other holds only the fact that Composio + * said yes — and this pins that the API does not make the reader care which. + * + * SCOPING IS THE OTHER HALF, and it is a per-person read with no `requireAdmin` in front of it: a + * union assembled from the wrong id would put somebody else's connected mailbox on this page. + */ +function connectionsApp( + person: { id: string; email: string }, + held: Array<{ serverId: string; scope: string; connectedAt: string }>, + brokered: Array<{ + userId: string; + row: { serverId: string; scope: string; connectedAt: string }; + }>, +) { + const store = { + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + connectionsFor: async (userId: string) => + userId === person.id ? held : [], + brokeredConnectionsFor: async (userId: string) => + brokered + .filter((connection) => connection.userId === userId) + .map((connection) => connection.row), + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { ...person, name: "Somebody", image: null }, + }), + }, + } as never, + { rolesForUser: async () => ["user"] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return () => app.request("http://openbot.test/api/plugins/connections"); +} + +const ASKER = { id: "user_asker", email: "asker@openbot.test" }; +const SOMEBODY_ELSE = { id: "user_other", email: "other@openbot.test" }; + +describe("a person's own connections", () => { + test("a brokered connection is in the list beside the OAuth ones", async () => { + const request = connectionsApp( + ASKER, + [ + { + serverId: "notion", + scope: "read", + connectedAt: "2026-01-01T00:00:00.000Z", + }, + ], + [ + { + userId: ASKER.id, + row: { + serverId: "composio-slack", + scope: "", + connectedAt: "2026-02-02T00:00:00.000Z", + }, + }, + ], + ); + + const response = await request(); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + connections: Array<{ serverId: string }>; + }; + // Sorted, so two requests answer in the same order: concatenating two lists that are each + // ordered within their own table does not produce an ordered list. + expect(body.connections.map((row) => row.serverId)).toEqual([ + "composio-slack", + "notion", + ]); + }); + + test("and is nobody else's", async () => { + // The must-not case. This route is behind `requireUser` and nothing else: a union read for the + // wrong person would show one person's connected account on another person's settings page. + const request = connectionsApp( + SOMEBODY_ELSE, + [], + [ + { + userId: ASKER.id, + row: { + serverId: "composio-slack", + scope: "", + connectedAt: "2026-02-02T00:00:00.000Z", + }, + }, + ], + ); + + const response = await request(); + + expect(response.status).toBe(200); + expect((await response.json()).connections).toEqual([]); + }); +}); From a79124ce518f4ab8c87823167069d36b064721aa Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 106/215] Stop sending this deployment's usage to the broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor's client turns its own telemetry on by default and installs interrupt handlers with it, and checks its release feed over the network from the constructor. The live test has switched both off since it was written, saying why: this is a self-hosted product whose operator never opted into a third party's analytics. The adapter never got the same treatment, and wiring it into startup is what turned that dormant asymmetry into shipped behaviour — so it is corrected in the same branch that caused it rather than after somebody notices the traffic. The version check is off for a second reason: a deployment's boot must not depend on the vendor's release feed being reachable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index b943520fa..bf6edc6b5 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -418,5 +418,16 @@ export function createComposioClient(apiKey: string): { actions: ComposioActions; broker: ComposioBroker; } { - return buildComposioClient(new Composio({ apiKey })); + return buildComposioClient( + new Composio({ + apiKey, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted + // product whose operator never opted into a third party's analytics. + allowTracking: false, + // Both default the other way, so both have to be said. The version check reaches npm for the + // SDK's latest release as the client is constructed, and a deployment's boot must not depend + // on the vendor's release feed. + disableVersionCheck: true, + }), + ); } From 9f8838a7ea07b9c7e5ff12b1fe3fa7bd23952ddb Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 107/215] Correct the comments that said no adapter existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven passages in the transport module described a product where nothing installed a Composio client and the only caller of its seam was the test suite. That was true until this branch wired one in at startup, and this repository treats a comment that has stopped being true as a defect rather than as prose that has aged. The load-bearing halves survive: why the seam is a setter rather than a constructor argument — the transport registry is a map built at import time, so there is nothing to hand a client to — and why an uninstalled client is a supported state rather than an outage, since a deployment with no key installs nothing and a test has to be able to take its stub back out. One refusal a person reads changed with them. It named the internal act rather than the thing an operator can do about it, and now names the setting. Comments and that one sentence only; no behaviour moved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 71 +++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 6f250a1ab..b2b736467 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -19,14 +19,15 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; * * It implements the same interface as the other three, as module-level exports, because that is the * shape {@link ./transport} resolves: a `TransportKind` maps to a MODULE. Which is also why the client - * would have to arrive through a setter rather than a constructor — the registry is built at import - * time, long before anything has read configuration. {@link useComposioClient} is that setter. - * - * NOTHING IN THE SHIPPED PRODUCT CALLS IT. There is no adapter under `server/src`: the only caller - * is the test suite. So on every real deployment `installed` is null, which is a state this module - * is written for rather than an outage — {@link listTools} throws a sentence saying so and - * {@link callTool} refuses with one. Read every mention of "the client" below as a description of - * the seam an adapter would plug into, not of wiring that exists. + * arrives through a setter rather than a constructor — the registry is built at import time, long + * before anything has read configuration. {@link useComposioClient} is that setter. + * + * `index.ts` CALLS IT AT STARTUP, from the one place that holds the key: it builds the client + * through `./composio-adapter` where `config.composioApiKey` is set and installs the actions seam + * with it. So on a deployment that has a key `installed` is a real client, and every mention of + * "the client" below describes wiring that runs. A deployment with no key installs nothing and + * leaves it null, which is a state this module is written for rather than an outage — + * {@link listTools} throws a sentence saying so and {@link callTool} refuses with one. */ /** @@ -81,12 +82,14 @@ export type ComposioAction = { * * WHY THE CLAIM WAS DROPPED RATHER THAN THE LOSS FIXED. The strip happens inside the vendor's own * parse, upstream of every byte this module receives, so there is nothing here to restore a key - * from — "stop losing them" is not an option this file has. The one place it could be avoided is - * the adapter that has yet to be written, by reading `client.tools.list` directly rather than - * `tools.getRawComposioTools` and never running `ToolSchema` over the answer; that is a decision - * about the vendor's types, and it belongs where the vendor's types belong. What this module can - * honestly promise is the narrower thing: it adds nothing to this schema and removes nothing from - * it, so what the SDK handed over is exactly what a model is shown. + * from — "stop losing them" is not an option this file has. The loss is live rather than + * theoretical: `./composio-adapter` fills this field from `tools.getRawComposioTools`, which is + * the call that runs the parse. The one place it could be avoided is that same file, by reading + * `client.tools.list` directly and never running `ToolSchema` over the answer — a decision about + * the vendor's types, belonging where the vendor's types belong, and one somebody can now make in + * a file that exists. What this module can honestly promise is the narrower thing: it adds + * nothing to this schema and removes nothing from it, so what the SDK handed over is exactly what + * a model is shown. * * Absent for the occasional action that publishes none — and equally for one that published `{}`, * which the SDK normalizes to absent before parsing (`src/models/Tools.ts:76-93`). @@ -104,8 +107,8 @@ export type ComposioAction = { * `ToolExecuteResponseSchema` in `@composio/core` 0.18.1 spells all three of these REQUIRED — `data` * a record, `error` a nullable string, `successful` a boolean — so the outcome of a call is a field * on a resolution and not only a thrown exception. Named here rather than imported so this module - * keeps no compile-time dependency on the vendor's package; the adapter that would install a real - * client is the one place their types belong, and it has not been written. + * keeps no compile-time dependency on the vendor's package; `./composio-adapter` is the one file + * under `server/src` that imports `@composio/core`, and that is where their types belong. * * `logId` and `sessionInfo` are the rest of the envelope, carried so the type stays a true statement * about what arrives. Nothing here reads them and nothing here shows them to a model. @@ -122,8 +125,9 @@ export type ComposioResult = { * What this module needs of Composio, and nothing more. * * A narrow projection rather than their client, so a test satisfies it with two functions and the - * SDK's shape is confined to one place: the adapter that would install a real client. No such - * adapter exists yet, so today the only implementations of this type are stubs. + * SDK's shape is confined to one place: `./composio-adapter`, which `index.ts` builds from + * `config.composioApiKey` and installs here at startup. That adapter is the only implementation + * that reaches Composio; every other one is a stub in the suite. * * `execute` RESOLVES AN OUTCOME, AND RESOLVING IS NOT SUCCEEDING. This comment used to say the * opposite — "resolves or throws, with no error field to check" — and {@link callTool} was written to @@ -190,12 +194,13 @@ export type ComposioActions = { let installed: ComposioActions | null = null; /** - * The seam an adapter would hand this module its client through, once, at startup. + * The seam `index.ts` hands this module its client through, once, at startup. * - * WOULD, BECAUSE NO SUCH ADAPTER EXISTS. Nothing under `server/src` calls this function — the only - * callers are tests — so `installed` is null on every deployment of the shipped product. The - * comment here used to describe `index.ts` doing the installing, and the code below was written - * around a state that was treated as an edge case when it is in fact the only state. + * A SETTER RATHER THAN A CONSTRUCTOR ARGUMENT, BECAUSE THERE IS NOTHING TO HAND A CLIENT TO. A + * transport is reached as a MODULE — `transportFor` maps a kind to one — and that registry is built + * at import time, long before there is configuration to read. So the client is installed globally + * instead, from the one place that holds the key: `index.ts` builds it from `config.composioApiKey` + * and calls this with `composio.actions`. * * `null` is a supported argument, and not only for symmetry: the suite is one process, so a test that * installs a stub has to be able to take it back out. It is also the unconfigured state — a @@ -390,13 +395,14 @@ export async function listTools(connection: { /* * A STATE, NOT A FAULT, and the sentence has to read as one. * - * Nothing under `server/src` installs a Composio client — see {@link useComposioClient} — so - * this is what every Composio refresh on every real deployment answers today, by design and - * not by accident. An operator who reads it as a crash goes looking for a broken vendor; what - * they need to know is that the connector is not wired up here and that nothing was lost. + * `index.ts` installs a Composio client only where `COMPOSIO_API_KEY` is set — see + * {@link useComposioClient} — so this is what every Composio refresh on a deployment without + * one answers, by design and not by accident. An operator who reads it as a crash goes looking + * for a broken vendor; what they need to know is that the connector is not configured here and + * that nothing was lost. */ throw new Error( - `Composio is not configured for this deployment, so nothing could be asked what ${toolkit} offers. That is the expected answer until a Composio client is installed at startup, and the actions already recorded for this app are kept rather than cleared.`, + `Composio is not configured for this deployment, so nothing could be asked what ${toolkit} offers. That is the expected answer until COMPOSIO_API_KEY is configured, and the actions already recorded for this app are kept rather than cleared.`, ); } @@ -423,10 +429,11 @@ export async function listTools(connection: { /* * NOTHING IS READ OFF THE ANSWER UNTIL IT IS A LIST, and the check is here rather than assumed - * from the type. `ComposioActions` is this module's own projection, the adapter that would - * satisfy it has not been written, and a return type is not a promise about what resolves at - * runtime — a client that answers null, or a bare envelope with the array one level down, is a - * mistake this file will meet before a type checker does. + * from the type. `ComposioActions` is this module's own projection, what satisfies it is + * `./composio-adapter` mapping an answer off the wire that no type checker here has seen, and a + * return type is not a promise about what resolves at runtime — a client that answers null, or a + * bare envelope with the array one level down, is a mistake this file will meet before a type + * checker does. * * Both reads below sit OUTSIDE the try that wraps the vendor's call, so before this guard * `actions.length` propagated `null is not an object (evaluating 'actions.length')` — which is From 612e28698186ad2f663e1be4b5c790c0f8499d32 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 108/215] Build the broker client once, at startup, where a key is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two install mechanisms, because the two halves are reached differently. The transport seam is a module-level setter, since the registry that resolves a transport is built at import time and there is no constructor to hand a client to. The broker is an ordinary argument, handed to the store and to the routes — the same object, constructed once, so the two can never end up talking to different clients. A deployment with no key installs neither, which is the state the transport was written for: it refuses with a sentence naming the setting rather than answering with an empty listing that would be committed as an app advertising nothing. The live check gains the directory listing, still skipped without a key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/index.ts | 36 ++++++++++++++++++++++++++++++ server/tests/composio-live.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/server/src/index.ts b/server/src/index.ts index 260091eb6..2465b56fd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -77,6 +77,8 @@ import { intelligenceChannelMappings } from "./db/schema"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; +import { useComposioClient } from "./plugins/composio"; +import { createComposioClient } from "./plugins/composio-adapter"; import { redirectUriFor } from "./plugins/oauth"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, grantedTools } from "./plugins/tools"; @@ -315,6 +317,28 @@ const computerGateway = computerProvider */ const sandboxedStore = createSandboxedStore(database, bootAuditStore); +/** + * Composio, built ONCE: the client the transport calls through and the broker behind the app + * directory are the same client, and the store below and the routes further down share it. + * + * ONE CLIENT, TWO SEAMS, INSTALLED TWO DIFFERENT WAYS, because the two are reached two different + * ways. The transport is reached as a MODULE — `transportFor` maps a kind to one, exactly as the + * builtin routines transport above is reached — so there is no constructor to hand a client to and + * the registry is built at IMPORT TIME, long before there is configuration to read. That is why the + * actions seam is installed globally, from here, the one place that has the key. The broker has no + * such problem: it is an ordinary argument, passed to the store and to `createApp`. + * + * A DEPLOYMENT WITH NO KEY INSTALLS NEITHER, which is the state the transport is written for rather + * than an edge of it. The seam stays null and every Composio listing and call refuses saying the + * connector is not configured here; the store gets no broker and the app directory says the same. + * Installing a client built from an absent key would turn all of that into a vendor error at first + * use, which sends an operator looking for a broken Composio instead of at their own configuration. + */ +const composio = config.composioApiKey + ? createComposioClient(config.composioApiKey) + : null; +if (composio) useComposioClient(composio.actions); + const pluginStore = createPluginStore({ database, auditStore: bootAuditStore, @@ -330,6 +354,14 @@ const pluginStore = createPluginStore({ * registering. */ redirectUri: config.publicUrl ? redirectUriFor(config.publicUrl) : undefined, + /* + * The same client the transport seam above was installed with, never a second one. Enabling an + * app writes the row here and creates the auth config at the vendor, and a store holding a + * different client from the one the call goes out through is two deployments' worth of state + * behind one screen. Undefined without a key, which leaves enabling an app refused rather than + * attempted. + */ + broker: composio?.broker, }); /** @@ -1157,6 +1189,10 @@ const app = createApp( // The same store every run reads through `loadInstructionsForActor`, so the screen a person edits // and the prompt their coworker is built from can never be two different pieces of text. userInstructionsStore, + // The app directory, behind the same client the plugin store and the transport already share. + // Absent without a key, which leaves the routes reporting no broker rather than listing apps + // nobody could connect. + composio ? { broker: composio.broker } : undefined, ); /** diff --git a/server/tests/composio-live.test.ts b/server/tests/composio-live.test.ts index 62e35198c..8d25fcc18 100644 --- a/server/tests/composio-live.test.ts +++ b/server/tests/composio-live.test.ts @@ -6,6 +6,7 @@ import { type ToolExecuteResponse, } from "@composio/core"; import { effectOf, vendorSentence } from "../src/plugins/composio"; +import { createComposioClient } from "../src/plugins/composio-adapter"; /** * One real call to Composio, so the shapes this transport is written against are the shapes it gets. @@ -235,4 +236,36 @@ describe.skipIf(!live)("Composio, for real", () => { true, ); }); + + /** + * The catalogue an administrator picks an app out of, asked for the way the product asks for it. + * + * THROUGH `createComposioClient`, not through a hand-built `Composio` like the tests above. This + * one is about the broker rather than about a vendor shape, and the broker's whole listing is one + * request: `listApps` asks for a single page AT THE CEILING sorted by usage, because the SDK has + * no cursor to follow and a page is therefore all there is. So the ceiling is what makes one + * request the entire catalogue, and the count below is what shows it held — several hundred apps + * rather than the vendor's default page of twenty, which would read exactly like a full list. + * + * A read of the public catalogue: no user id is involved and nobody's account is touched. + */ + test("the broker lists the whole catalogue in one request", async () => { + if (!key) { + throw new Error( + "Precondition not met: no COMPOSIO_API_KEY was set, so no client could be built. Nothing below was exercised.", + ); + } + const apps = await createComposioClient(key).broker.listApps(); + + // Several hundred, not a page of twenty. + expect(apps.length).toBeGreaterThan(50); + + // Not stated as a precondition: the ceiling is what makes this one request the whole listing, + // so an absent Gmail is a truncated answer rather than an assertion left unmade. The count is + // the field `listApps` reads out of `meta.toolsCount` and zeroes when the vendor publishes + // none, which is the one absence that would go unnoticed on the screen. + const gmail = apps.find((app) => app.slug === "gmail"); + expect(gmail?.slug).toBe("gmail"); + expect(gmail?.actionCount).toBeGreaterThan(0); + }); }); From c668431ffb2b7cd502a01be4c50497f704e23e1d Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 109/215] Let an administrator browse the broker's apps and enable one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search field over the whole directory and a row per app, each carrying the count of actions it publishes. The count is there because it is the size of the decision: Slack publishes a hundred and sixty-seven, which is worth meeting before pressing add rather than afterwards on a page of switches. There is no cap — enabling and granting are two decisions and the second is where reach is settled — so a large app stays possible and merely stops being accidental. An app this deployment already enabled reads as added rather than offering to add it a second time, and that answer is computed by the server from the app each row actually names, so the two halves cannot disagree. With no key the way in states the setting instead of vanishing. Hiding it would leave somebody who has heard of the broker with nowhere to find out what it wants. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routeTree.gen.ts | 22 +++ .../routes/_authed/admin/plugins/composio.tsx | 176 ++++++++++++++++++ .../routes/_authed/admin/plugins/index.tsx | 51 +++++ app/tests/composio-picker.test.tsx | 79 ++++++++ 4 files changed, 328 insertions(+) create mode 100644 app/src/routes/_authed/admin/plugins/composio.tsx create mode 100644 app/tests/composio-picker.test.tsx diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index ac2410814..4fc239880 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index' import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key' +import { Route as AuthedAdminPluginsComposioRouteImport } from './routes/_authed/admin/plugins/composio' import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index' import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index' @@ -179,6 +180,12 @@ const AuthedAdminPluginsKeyRoute = AuthedAdminPluginsKeyRouteImport.update({ path: '/plugins/$key', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminPluginsComposioRoute = + AuthedAdminPluginsComposioRouteImport.update({ + id: '/plugins/composio', + path: '/plugins/composio', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedSettingsComponentsGalleryIndexRoute = AuthedSettingsComponentsGalleryIndexRouteImport.update({ id: '/components-gallery/', @@ -233,6 +240,7 @@ export interface FileRoutesByFullPath { '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents/': typeof AuthedAppAgentsIndexRoute @@ -263,6 +271,7 @@ export interface FileRoutesByTo { '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents': typeof AuthedAppAgentsIndexRoute @@ -298,6 +307,7 @@ export interface FileRoutesById { '/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/_authed/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/_authed/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute @@ -332,6 +342,7 @@ export interface FileRouteTypes { | '/channel/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' | '/agents/' @@ -362,6 +373,7 @@ export interface FileRouteTypes { | '/channel/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' | '/agents' @@ -396,6 +408,7 @@ export interface FileRouteTypes { | '/_authed/_app/channel/new' | '/_authed/admin/components/$name' | '/_authed/admin/plugins/$key' + | '/_authed/admin/plugins/composio' | '/_authed/settings/components-gallery/$name' | '/_authed/settings/connected-accounts/$key' | '/_authed/_app/agents/' @@ -602,6 +615,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPluginsKeyRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/plugins/composio': { + id: '/_authed/admin/plugins/composio' + path: '/plugins/composio' + fullPath: '/admin/plugins/composio' + preLoaderRoute: typeof AuthedAdminPluginsComposioRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/settings/components-gallery/': { id: '/_authed/settings/components-gallery/' path: '/components-gallery' @@ -652,6 +672,7 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute + AuthedAdminPluginsComposioRoute: typeof AuthedAdminPluginsComposioRoute AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute AuthedAdminPluginsKeyToolsToolRoute: typeof AuthedAdminPluginsKeyToolsToolRoute @@ -669,6 +690,7 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute, + AuthedAdminPluginsComposioRoute: AuthedAdminPluginsComposioRoute, AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute, AuthedAdminPluginsKeyToolsToolRoute: AuthedAdminPluginsKeyToolsToolRoute, diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx new file mode 100644 index 000000000..8ce23aee6 --- /dev/null +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -0,0 +1,176 @@ +import { IconPlug } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import * as React from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { enableComposioAppMutationOptions } from "@/lib/plugins/mutations"; +import { + type ComposioApp, + composioAppsQueryOptions, +} from "@/lib/plugins/queries"; +import { queryClient } from "@/query-client"; + +/** + * Composio's directory, searched rather than listed. + * + * The catalogue on the previous screen is a reviewed handful, and every entry there is a decision + * somebody made about a vendor. This is a few hundred apps that nobody here has reviewed, which is + * why it is a search field and not a list: the only sensible entry point into a directory that size + * is the name of the app you already came looking for. + * + * Adding one is account-wide, and it still reads nothing. Every app here is reached as whoever is + * asking, so a person's own connection is what makes their own mail or messages readable — which is + * made on their settings page, not here. + */ +export const Route = createFileRoute("/_authed/admin/plugins/composio")({ + component: RouteComponent, +}); + +/** + * The apps to draw, in the order to draw them. + * + * A function rather than a `.sort()` inside the map, so the two things a row is decided from — the + * order, and the marker saying an app is already here — can be asserted without a DOM, a router and + * a query client. See `tests/composio-picker.test.tsx`. + * + * A copy, because the array belongs to TanStack Query's cache and is the same object on every + * render; sorting it in place would rewrite what the cache holds. By name rather than by the + * vendor's own order, which is popularity — useful for a landing page, useless for finding the one + * app somebody typed half the name of. + */ +export function matchingApps(apps: ComposioApp[]): ComposioApp[] { + return [...apps].sort((left, right) => left.name.localeCompare(right.name)); +} + +function RouteComponent() { + const [search, setSearch] = React.useState(""); + /* + * Debounced, as on People, and for a stronger reason: every distinct term is a cache key of its + * own and a request to the vendor, so typing a name un-debounced is a round trip per keystroke + * against somebody else's rate limit. + */ + const [query, setQuery] = React.useState(""); + React.useEffect(() => { + const timer = setTimeout(() => setQuery(search), 250); + return () => clearTimeout(timer); + }, [search]); + + const apps = useQuery(composioAppsQueryOptions(query)); + const enable = useMutation(enableComposioAppMutationOptions(queryClient)); + const listed = matchingApps(apps.data?.apps ?? []); + + return ( + + + {enable.error ? ( +

+ {enable.error.message} +

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

+ Composio's app directory could not be read. +

+ ) : listed.length === 0 ? ( + + {query + ? `Nothing in Composio's directory matches "${query}".` + : "Composio returned no apps."} + + ) : ( + + {listed.map((entry, index) => { + /* + * One mutation serves every row, so the row being added is the one whose slug the + * mutation is carrying. Without that, pressing Add on one app would put every other + * row into Adding… at the same time. + */ + const adding = + enable.isPending && enable.variables?.slug === entry.slug; + + return ( + + + {/* The vendor's own mark where Composio supplies one, and a plug where it does + not, so a list of third parties still has a fixed left edge. */} + + {entry.logo ? ( + + ) : ( + + )} + + + {entry.name} + {entry.description} + + + {/* The size of the decision, stated before it is made: an app is not one + tool, and "167 actions" is what says so. */} + + {entry.actionCount} actions + + {/* + * An app this deployment already has says so rather than offering Add again. + * A second Add would ask the server to record a connector it already holds, + * and there is nothing on the row to suggest that would be harmless. + */} + {entry.enabled ? ( + + Added + + ) : ( + + )} + + + {index !== listed.length - 1 && } + + ); + })} + + )} +
+
+ ); +} diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index 17d6db1dd..c008db613 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -231,6 +231,57 @@ function RouteComponent() { )} + + + + {/* + * The row states the setting rather than hiding the feature. A deployment with no + * Composio key still says that Composio is a thing this build can do and names the + * variable that turns it on — a section that simply vanished would leave an + * administrator with nothing to search for, and nothing to tell them the handful of + * connectors above is not the whole story. + */} + {plugins.data?.composioConfigured ? ( + } + size="sm" + > + + + + + Browse Composio + + A few hundred apps, reached as whoever is asking. Each + person connects their own account. + + + + + + + ) : ( + /* No chevron and no link: the row goes nowhere, because there is nowhere to go + until the key is set. */ + + + + + + Composio + + Add your Composio key to enable a catalogue of tools. Set + COMPOSIO_API_KEY on this deployment. + + + + )} + + )} diff --git a/app/tests/composio-picker.test.tsx b/app/tests/composio-picker.test.tsx new file mode 100644 index 000000000..d61747b05 --- /dev/null +++ b/app/tests/composio-picker.test.tsx @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import type { ComposioApp } from "@/lib/plugins/queries"; +import { matchingApps } from "@/routes/_authed/admin/plugins/composio"; + +/** + * What the Composio picker lists, decided without drawing anything. + * + * The screen reads a directory of a few hundred apps, some of which this deployment already has. + * Two facts about that list are worth pinning: the order it is read in, and that the two fields a + * decision actually rests on — whether the app is already here, and how much it brings with it — + * arrive at the row intact. + * + * A `.tsx` file because it imports a route module, which is JSX; the precedent is + * `agent-roster-error.test.tsx`. Nothing here renders, though — `matchingApps` is exported as a + * function rather than left inline in the map for exactly that reason, so the ordering and the + * already-added marker can be asserted without a DOM, a router or a query client. + */ + +/** A minimal but complete `ComposioApp`, overridable per case. */ +function app(overrides: Partial & { slug: string }): ComposioApp { + return { + name: "App", + description: "Does something.", + logo: null, + categories: [], + actionCount: 1, + enabled: false, + ...overrides, + }; +} + +const SLACK = app({ + slug: "slack", + name: "Slack", + description: "Messages, channels and files.", + actionCount: 167, + enabled: true, +}); + +const GMAIL = app({ + slug: "gmail", + name: "Gmail", + description: "Mail, threads and labels.", + actionCount: 42, +}); + +test("an app this deployment already has is offered as added, not as one more thing to add", () => { + const listed = matchingApps([SLACK, GMAIL]); + + // Once, and carrying the flag the row branches on. An app that came back enabled and lost it + // would draw a second Add button for something already here, and pressing it would ask the + // server to add a duplicate. + const slack = listed.filter((entry) => entry.slug === "slack"); + expect(slack).toHaveLength(1); + expect(slack[0]?.enabled).toBe(true); + + // The other side of the same claim: an app that is genuinely not here stays addable. + expect(listed.find((entry) => entry.slug === "gmail")?.enabled).toBe(false); +}); + +test("the size of the decision survives, however large the app", () => { + const listed = matchingApps([SLACK, GMAIL]); + + // 167 is the real count on Slack, and it is the whole reason the row states one: an app is not + // a small thing to switch on, and the number is what says so before anybody presses Add. + expect(listed.find((entry) => entry.slug === "slack")?.actionCount).toBe(167); + expect(listed.find((entry) => entry.slug === "gmail")?.actionCount).toBe(42); +}); + +test("the directory is listed by name, and the vendor's own order is left alone", () => { + const apps = [SLACK, GMAIL]; + const listed = matchingApps(apps); + + expect(listed.map((entry) => entry.name)).toEqual(["Gmail", "Slack"]); + // A copy: the query's cached array is the same object every render, and sorting it in place + // would rewrite what TanStack Query holds. + expect(apps.map((entry) => entry.name)).toEqual(["Slack", "Gmail"]); + expect(listed).not.toBe(apps); +}); From 24a4f1b6606e4ab608db67a9368531d79fc86256 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 110/215] Stop offering a shared token to the connector that cannot hold one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector page decided what to show from the catalogue and fell back to the shared-token form when there was no entry. That fallback was right for a server an administrator added by URL, which really is reached with one token for everybody, and wrong for the one row shape whose entire point is per-person accounts: a brokered app was asked for a credential used by everybody, and a token typed there would have been stored and never read. The row's own recorded provenance is asked first now, because it is a fact rather than an inference from the absence of something. A brokered app instead says how it is actually reached, and offers the person looking at it their own account. The same card lands on the personal settings page, which until now told somebody arriving from the list that the deployment had no connector by that name — a falsehood about a connector it demonstrably has, and a dead end besides. Both pages draw one treatment: the same connect, the same confirm on arrival, the same disconnect saying plainly that it ends the account at Composio rather than only here. Neither page invents a state. The vendor's answer wins once there is one, and until then the recorded row stands; somebody who abandoned consent reads as not connected rather than as an error. The two routes behind connect and disconnect land in the next steps, so these cards are honest but inert until then. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routes/_authed/admin/plugins/$key.tsx | 146 ++++++++++++++++-- .../settings/connected-accounts/$key.tsx | 137 +++++++++++++++- .../settings/connected-accounts/index.tsx | 72 ++++++++- app/tests/brokered-connection-card.test.tsx | 28 ++++ 4 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 app/tests/brokered-connection-card.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 2c37ba95c..dd6a63129 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -40,7 +40,9 @@ import { agentListQueryOptions } from "@/lib/agents/queries"; import { storeMcpToken } from "@/lib/credentials/mutations"; import { addCuratedServerMutationOptions, + confirmBrokeredConnectionMutationOptions, connectAccountMutationOptions, + disconnectBrokeredMutationOptions, grantPlugin, invalidatePlugins, refreshPluginServerMutationOptions, @@ -48,7 +50,9 @@ import { removePluginServerMutationOptions, } from "@/lib/plugins/mutations"; import { + type CatalogueItem, connectionsQueryOptions, + type PluginServer, pluginsPageQueryOptions, } from "@/lib/plugins/queries"; @@ -90,6 +94,30 @@ function grantSummary(held: number, total: number): string { return `${held} of ${total} Bots`; } +/** + * How this vendor is reached, from whichever record we have. + * + * The row's own provenance is asked first because it is a fact this deployment recorded when the + * app was enabled, not an inference: a brokered app is reached through Composio whatever the + * catalogue does or does not say about it. + * + * That order matters because the catalogue has nothing to say about a brokered app — it is not a + * curated entry — and what stood here was `entry?.auth ?? "deployment-bearer"`: a guess, and the + * one guess that is wrong for the one row shape that cannot hold a token. The page then offered + * "one credential, used for everybody" to the connector whose whole point is that each person + * connects their own account, and would have stored a token nothing ever reads. + * + * A server an administrator added by URL is still the fallback's case, and still correct there: + * nothing about it is reached as a person, so it gets the shared-token shape. + */ +export function connectionKindFor( + server: PluginServer | undefined, + auth: CatalogueItem["auth"] | undefined, +): CatalogueItem["auth"] | "brokered" { + if (server?.provenance === "composio") return "brokered"; + return auth ?? "deployment-bearer"; +} + function RouteComponent() { const { key } = useParams({ from: "/_authed/admin/plugins/$key" }); const queryClient = useQueryClient(); @@ -171,14 +199,50 @@ function RouteComponent() { name: nameFor(agent.id), })); - /** - * How this vendor is reached, from whichever record we have. + const auth = connectionKindFor(server, entry?.auth); + const title = entry?.title ?? server?.title ?? key; + + /* + * Ask the vendor whether this person's brokered account is actually live, on arrival. + * + * The return trip from consent is an ordinary redirect with nothing signed in it, so being back + * on this page proves nothing about what happened at the vendor. The row this page would + * otherwise read is written from that same unproven return, which is why the answer is asked for + * rather than assumed. * - * A server added by URL has no catalogue entry, and nothing about it is reached as a person, so it - * falls back to the shared-token shape. + * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has + * simply never connected — arrives here with nothing at the vendor to confirm, and that is an + * ordinary state of this page, not a failure of it. It reads as not connected, which is what it + * is; a red sentence across the top would be the page reporting its own question as the + * administrator's problem. */ - const auth = entry?.auth ?? "deployment-bearer"; - const title = entry?.title ?? server?.title ?? key; + const confirmBrokered = useMutation( + confirmBrokeredConnectionMutationOptions(queryClient), + ); + const confirmBrokeredAccount = confirmBrokered.mutate; + React.useEffect(() => { + if (auth !== "brokered") return; + confirmBrokeredAccount(key); + }, [auth, key, confirmBrokeredAccount]); + const disconnectBrokered = useMutation({ + ...disconnectBrokeredMutationOptions(queryClient), + ...report, + }); + + /* + * Whether this person's own account is live, for the row that says so. + * + * For a brokered app that is what the vendor last answered, and only our own record until it has + * answered anything: `youConnected` is this deployment's row, `confirmBrokered.data` is the + * vendor's answer about the account behind it. The answer wins once there is one, in both + * directions — an account ended at Composio by somebody else reads as not connected here too. A + * confirm still in flight, or one that could not be made at all, leaves the recorded row + * standing rather than inventing either answer. + */ + const accountConnected = + auth === "brokered" + ? (confirmBrokered.data?.connected ?? youConnected) + : youConnected; /** Adding is two writes when a token was typed: the credential, then the record pointing at it. */ const add = async () => { @@ -322,7 +386,10 @@ function RouteComponent() { ? "This vendor answers as whoever is asking. The deployment registers an OAuth client, and each person connects their own account, so a Bot only ever sees what that person can see." : auth === "builtin" ? "Built into this deployment. There is no vendor to reach and no credential to hold — a call runs as whoever asked." - : "What this deployment presents to the vendor. One credential, used for everybody." + : auth === "brokered" + ? /* The row below says how, at length. This says whose account it is, which is the part that decides what an administrator has to do here — which is nothing. */ + "Reached through Composio, which holds each person's own account." + : "What this deployment presents to the vendor. One credential, used for everybody." } title="Connection" > @@ -342,6 +409,12 @@ function RouteComponent() { * step rather than as the answer. The row states that plainly instead of leaving the * card empty — and being first, it also gives the docsUrl row below something other than * the card's own top border to sit its leading separator against. + * + * The third is the brokered row, for the same reason as the second and one more: an + * administrator looking at a Connection card with only somebody's personal account on it + * will reasonably look for the deployment's half of the arrangement. There is one, it is + * just not theirs to type, and saying so is the only way this card is not read as + * half-configured. */} {auth === "builtin" ? ( @@ -365,6 +438,31 @@ function RouteComponent() { ) : null} + {auth === "brokered" ? ( + /* + * Nothing to click. The deployment's half of a brokered app is one key held once, + * for Composio rather than for this vendor, and it is set where every brokered app + * reads it from — not here, per app, by hand. + */ + + + How this is reached + + This app is reached through Composio, which holds the + account. This deployment sends one key and the name of + whoever is asking, so each person connects their own and a + Bot sees only what that person can see. There is no token to + paste. + + + + + Through Composio + + + + ) : null} + {auth === "deployment-bearer" ? ( Your account - {youConnected - ? `Connected, so a Bot granted these tools uses your ${title} as you. Everybody else connects their own.` + {accountConnected + ? auth === "brokered" + ? /* Said beside the button rather than after it: disconnecting ends the account at Composio, so what it undoes is not the row here but the grant on this person's mailbox, and connecting again is a fresh consent. */ + `Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.` + : `Connected, so a Bot granted these tools uses your ${title} as you. Everybody else connects their own.` : "Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only."} - {youConnected ? ( + {accountConnected ? ( <> {/* Decorative: the word beside it already says which. */} Connected + {auth === "brokered" ? ( + + ) : null} ) : ( /* The arrow says this leaves OpenBot for the vendor's consent page. It does. */ diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 19f8b1c94..f78916c35 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -1,7 +1,7 @@ import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, useParams } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { PageEmpty, PageRows, @@ -23,7 +23,11 @@ import { ItemTitle, } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; -import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; +import { + confirmBrokeredConnectionMutationOptions, + connectAccountMutationOptions, + disconnectBrokeredMutationOptions, +} from "@/lib/plugins/mutations"; import { connectionsQueryOptions, pluginsPageQueryOptions, @@ -45,6 +49,7 @@ function RouteComponent() { const { key } = useParams({ from: "/_authed/settings/connected-accounts/$key", }); + const queryClient = useQueryClient(); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); const [notice, setNotice] = useState(null); @@ -62,10 +67,41 @@ function RouteComponent() { }); const entry = plugins.data?.catalogue.find((item) => item.key === key); - const enabled = (plugins.data?.servers ?? []).some((s) => s.id === key); + const server = (plugins.data?.servers ?? []).find((s) => s.id === key); + const enabled = server !== undefined; const connection = (connections.data?.connections ?? []).find( (row) => row.serverId === key, ); + /* + * Asked of the row rather than the catalogue, because a brokered app has no catalogue entry at + * all: the deployment recorded how it is reached when the app was enabled, and that record is the + * only thing here that knows. + */ + const brokered = server?.provenance === "composio"; + + /* + * Ask the vendor whether this brokered account is actually live, on arrival. + * + * The return trip from consent is an ordinary redirect with nothing signed in it, so being back + * on this page proves nothing about what happened at the vendor. So the vendor is asked, and its + * answer is what the row below is drawn from. + * + * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has + * simply never connected — has nothing at the vendor to confirm, and that is an ordinary state of + * this page rather than a failure of it. It reads as not connected, which is what it is. + */ + const confirmBrokered = useMutation( + confirmBrokeredConnectionMutationOptions(queryClient), + ); + const confirmBrokeredAccount = confirmBrokered.mutate; + useEffect(() => { + if (!brokered) return; + confirmBrokeredAccount(key); + }, [brokered, key, confirmBrokeredAccount]); + const disconnectBrokered = useMutation({ + ...disconnectBrokeredMutationOptions(queryClient), + onError: (thrown: Error) => setNotice(thrown.message), + }); if (plugins.isPending) { return {null}; @@ -76,6 +112,99 @@ function RouteComponent() { linkProps: { to: "/settings/connected-accounts" as const }, }; + /* + * A brokered app, before the catalogue is consulted at all. + * + * It has no catalogue entry, so the branch below would find no `entry`, decide the deployment has + * no connector by that name, and say so under the raw id — about the one connector the list on the + * way in demonstrably drew a row for. Falling through to the `user-oauth` branch instead is no + * better: its title and summary are the catalogue's, and there is none. + */ + if (brokered && server) { + /* + * What the vendor last answered, and only our own record until it has answered anything. The + * answer wins once there is one, in both directions — an account ended at Composio by somebody + * else reads as not connected here too. A confirm still in flight, or one that could not be + * made at all, leaves whatever we recorded standing rather than inventing either answer. + */ + const accountConnected = + confirmBrokered.data?.connected ?? connection !== undefined; + + return ( + + {notice ? ( +

+ {notice} +

+ ) : null} + + {/* One decision, so no heading: it would only repeat the row's own title. */} + + + + + Your account + + {accountConnected + ? /* Said beside the button rather than after it: disconnecting ends the account + at Composio, so what it undoes is not the row here but the grant on your own + mailbox, and connecting again is a fresh consent. */ + `A Bot granted its tools reads your ${server.title} as you. Disconnecting ends the account at Composio, not just here.` + : "No Bot can read this as you. Connecting takes you to Composio and then to the vendor to consent."} + + + + {accountConnected ? ( + <> + {/* Decorative: the word beside it already says which. */} + + + + +
+ ); + } + /* * A vendor that is not reached as a person has nothing here for anybody to decide, and one an * administrator has not enabled cannot be consented to — there is no OAuth client behind it. Both diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 04a7f96b2..b2c99766e 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -79,6 +79,17 @@ function RouteComponent() { (entry) => entry.auth === "user-oauth" && added.has(entry.key), ); + /* + * Brokered apps belong here for the same reason the OAuth ones do: they answer as you. + * + * The filter above names the catalogue's `user-oauth` kind, which a brokered row cannot have + * because it has no catalogue entry at all — so the one connector that is nothing but per-person + * accounts was the one this page never listed. + */ + const brokered = (plugins.data?.servers ?? []).filter( + (server) => server.provenance === "composio", + ); + return ( ) : ( - {yours.length === 0 ? ( + {yours.length === 0 && brokered.length === 0 ? ( /* * Says whose move it is. "Nothing here" on its own reads as though you failed to do * something, when what is missing is an administrator enabling a connector. @@ -160,7 +171,64 @@ function RouteComponent() {
- {index !== yours.length - 1 && } + {(index !== yours.length - 1 || brokered.length > 0) && ( + + )} + + ); + })} + {brokered.map((server, index) => { + const Mark = markFor(server.id); + return ( + + + } + size="sm" + > + + + + + {server.title} + {/* Written here rather than read off the row: a brokered app has no + catalogue entry, so the summary the server sends back is empty. */} + + Reached through Composio, which holds the account, so + a Bot sees only what you can see. + + + + {/* + * The same dot and the same two words as the rows above, read out of the + * same set. The connections endpoint now answers out of both tables, so a + * brokered app this person has connected is in `connected` under the id of + * its server row — which is the id this row is drawn from. The state was + * never a different kind of fact here, only an unanswerable one. + */} + + + {index !== brokered.length - 1 && } ); })} diff --git a/app/tests/brokered-connection-card.test.tsx b/app/tests/brokered-connection-card.test.tsx new file mode 100644 index 000000000..36f6e67f4 --- /dev/null +++ b/app/tests/brokered-connection-card.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { connectionKindFor } from "@/routes/_authed/admin/plugins/$key"; + +/** + * Which shape the connector page draws, decided from the row rather than guessed. + * + * The page used to fall back to `deployment-bearer` whenever the catalogue had nothing to say, and + * a brokered row is exactly the case with nothing to say: it is not a curated entry, so there is no + * `auth` to read. The guess then drew the one shape a brokered row cannot hold — a shared token + * pasted once for everybody — over the connector whose whole point is that each person connects + * their own account. + * + * `.tsx` because this imports a route module, which is JSX; see `agent-roster-error.test.tsx` for + * the same reason. Nothing here renders: the decision is a pure function precisely so that the one + * thing worth pinning can be pinned without a router, a query client or a document. + */ + +test("a Composio-provenance row is brokered, whatever the catalogue says", () => { + expect( + connectionKindFor({ provenance: "composio" } as never, undefined), + ).toBe("brokered"); +}); + +test("a server added by URL still falls back to the shared-token shape", () => { + expect(connectionKindFor({ provenance: "custom" } as never, undefined)).toBe( + "deployment-bearer", + ); +}); From 53dc3d254fc2a4fa56ba21a53c7037c1a61163f5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 04:52:52 +0200 Subject: [PATCH 111/215] Leave a read-only check somebody can run after a real consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything about this connector is provable against a stubbed broker except the one thing that matters: a real person consenting and a real answer coming back out of their own account. This is what makes that repeatable without repeating the click. It reads the directory, reports whether one named person holds a live connection, and calls exactly one action — behind a flag, behind that connection, and behind a check of the vendor's own read-only label on the action at call time, so "read-only" is something the broker asserted rather than something the script assumed from a name. It mints no consent link, creates no session, and prints no key: every line it writes passes through a redactor first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- package.json | 1 + scripts/composio-smoke.ts | 217 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 scripts/composio-smoke.ts diff --git a/package.json b/package.json index cbb29bf31..6022a63a6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts", "test:live-composio": "bun -e \"if (!process.env.COMPOSIO_API_KEY?.trim()) { console.error('COMPOSIO_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as COMPOSIO_API_KEY=... bun run test:live-composio.'); process.exit(1) }\" && OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", + "composio:smoke": "bun scripts/composio-smoke.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" }, "devDependencies": { diff --git a/scripts/composio-smoke.ts b/scripts/composio-smoke.ts new file mode 100644 index 000000000..952325b69 --- /dev/null +++ b/scripts/composio-smoke.ts @@ -0,0 +1,217 @@ +/** + * Does this deployment's Composio key actually open Composio? + * + * `server/tests/composio-live.test.ts` asks what the vendor does; this asks what THIS KEY can see. + * They are different questions and only the second one is about an operator's own account: a key + * with no project behind it, a project with no auth config for the app, or a person who never + * finished the consent page all produce a product that looks configured and answers nothing. So + * everything below is a read, and every read is one an operator would otherwise make by clicking + * around Composio's dashboard and guessing which of the three it was. + * + * COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] + * + * IT MINTS NO CONNECT LINK AND CREATES NO SESSION. `broker.authorize` answers a url that attaches + * an account to whoever opens it, so a diagnostic that printed one would be leaving somebody's + * mailbox in a terminal scrollback and in whatever captured it; this script never calls it, and a + * person connects in their own browser through the product instead. Sessions are the other half: + * `createComposioClient` builds a plain per-call client — see the boundary written down at the top + * of `server/src/plugins/composio-adapter.ts` — and nothing here reaches past it. + * + * THE KEY IS NEVER PRINTED. It is read once, handed to {@link createComposioClient}, and after that + * every line this script writes goes through {@link say}, which redacts it. That is belt and braces + * on purpose: the adapter promises not to quote the key, but a vendor exception is a foreign object + * and "this SDK does not put the key in an error" is not a promise this file is in a position to + * make on the SDK's behalf. + */ +import { + type ComposioResult, + effectOf, + LISTING_LIMIT, +} from "../server/src/plugins/composio"; +import { createComposioClient } from "../server/src/plugins/composio-adapter"; + +/** + * The app the numbers below are about. + * + * One app rather than all of them, because the question is whether a real connection works and a + * person only ever has one app connected at a time when they are debugging this. Gmail because it + * is the app this deployment's Composio work has been written against throughout, and because its + * action count is large enough that a truncated or "important"-filtered listing shows up as an + * obviously wrong number rather than as a plausible one. + */ +const APP = "gmail"; + +/** + * The one action `--call` runs, and why it is safe to run against somebody's real account. + * + * A profile read: it answers the address and the message counts, and it touches no message. The + * slug is named rather than discovered so that what a `--call` does is readable here instead of + * depending on whatever Composio happens to list first — but the name is not the guarantee. The + * guarantee is the {@link effectOf} check below, which reads the vendor's own behaviour label at + * call time and refuses anything that is not marked read-only. + */ +const READ_ACTION = "GMAIL_GET_PROFILE"; + +const args = process.argv.slice(2); +const userFlag = args.indexOf("--user"); +const given = userFlag === -1 ? undefined : args[userFlag + 1]; +/* + * A value that is itself a flag is a missing id rather than a strange one: `--user --call` reads + * as somebody who forgot the id, and taking `--call` as the user would ask Composio about a person + * who does not exist and report "no connection" as though that were a finding about them. + */ +const user = given?.startsWith("--") ? undefined : given; +const call = args.includes("--call"); + +if (!user) { + console.error( + "Usage: COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call]\n\n" + + "The user id is the one this deployment sends Composio as the person a call is for — the\n" + + "same id `composio_connections` records. --call additionally runs one read-only action as\n" + + "that person, which only works once they have connected the app in their own browser.", + ); + process.exit(2); +} + +const configured = process.env.COMPOSIO_API_KEY?.trim(); +if (!configured) { + console.error( + "COMPOSIO_API_KEY is empty or unset, so there is nothing to ask Composio with and every answer below would be an absence rather than a finding. Run it as COMPOSIO_API_KEY=... bun run composio:smoke -- --user .", + ); + process.exit(1); +} +/* + * Rebound so that {@link say}, which is a closure and therefore outside the narrowing above, holds + * a `string` by declaration rather than by a cast. A cast would be the wrong tool twice over: it + * asserts what the refusal above already proved, and this is the one variable in the file where + * silencing the type checker is least welcome. + */ +const key: string = configured; + +/** + * Every line this script writes, with the key taken back out of it. + * + * A plain `split`/`join` rather than a regular expression, because a key is an arbitrary string and + * building a pattern out of one is how a `+` or a `.` in a credential turns a redaction into a + * mismatch. Applied to the vendor's words as well as to this file's own: the only lines that carry + * text nobody here wrote are the failure lines, which are exactly the ones worth guarding. + */ +function say(line: string): void { + console.info(line.split(key).join("")); +} + +/** + * What a thrown failure is allowed to contribute to the output. + * + * The MESSAGE, never the object. A caught value from an SDK carries a request, a config and + * whatever else the vendor attached to it, and `console.error(error)` prints all of it — which is + * the path by which a key ends up in a terminal and in whatever captured it. So the shape is + * discarded here and the one human sentence is kept, and even that goes out through {@link say}. + */ +function sentence(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const { actions, broker } = createComposioClient(key); + +const apps = await broker.listApps(); +const app = apps.find((candidate) => candidate.slug === APP); +say(`Composio listed ${apps.length} apps for this key.`); +if (!app) { + /* + * Stated rather than shrugged at. A catalogue that does not contain Gmail is a key pointed at + * something other than what this script assumes, and reporting "0 actions" for it would read as + * an empty app rather than as a listing that never included it. + */ + say( + `${APP} was not among them, so the action count and the connection below are about an app this key cannot see.`, + ); + process.exit(1); +} +say(`${app.name} publishes ${app.actionCount} actions.`); + +const connected = await broker.isConnected({ userId: user, toolkit: APP }); +say( + connected + ? `${user} has a live ${APP} connection.` + : `${user} has no live ${APP} connection. They connect one in their own browser, from the app's page in the product; nothing here can do it for them.`, +); + +if (!call) { + say("Nothing was called. Pass --call to run one read-only action."); + process.exit(0); +} + +if (!connected) { + say( + `--call was passed, but there is no connection to call through, so nothing was sent. Connect ${APP} for ${user} first.`, + ); + process.exit(1); +} + +/* + * The action is looked up in a real listing rather than called from the constant alone, for the two + * things only the listing carries: the concrete version, which the SDK refuses to execute without, + * and the behaviour labels, which are what makes the claim "read-only" checkable instead of + * asserted in a comment. + */ +const listed = await actions.listActions(APP, { limit: LISTING_LIMIT }); +const action = listed.find((candidate) => candidate.slug === READ_ACTION); +if (!action) { + say( + `Composio does not list ${READ_ACTION} for ${APP}, so nothing was called. Pick another read-only action rather than calling one of the writes.`, + ); + process.exit(1); +} +const { effect, destructive } = effectOf(action.tags); +if (effect !== "read" || destructive) { + /* + * The vendor's label decides, and a disagreement stops the run. `effectOf` treats anything + * unlabelled as a write, so this also covers the case where Composio stops publishing labels + * altogether — which would otherwise turn a smoke test into an unreviewed write. + */ + say( + `Composio no longer marks ${READ_ACTION} as read-only, so nothing was called. This script only ever runs a read.`, + ); + process.exit(1); +} +if (!action.version) { + say( + `Composio listed ${READ_ACTION} with no version, so no versioned call could be made and nothing was sent.`, + ); + process.exit(1); +} + +let result: ComposioResult; +try { + result = await actions.execute( + { + toolkit: APP, + slug: action.slug, + userId: user, + version: action.version, + }, + /* + * No arguments. A profile read is about the connected account itself, and the one parameter it + * takes defaults to it — so an empty bag is both the smallest request and the one that cannot + * accidentally name somebody else's mailbox. + */ + {}, + ); +} catch (error) { + say(`${READ_ACTION} threw: ${sentence(error)}`); + process.exit(1); +} + +/* + * A resolution is not a success. Composio reports most failures by answering with `successful: + * false` rather than by throwing, and a smoke test that only watched for exceptions would report a + * working key on top of a call that failed. + */ +say( + result.successful + ? `${READ_ACTION} succeeded.` + : `${READ_ACTION} failed: ${result.error ?? "Composio said so without saying why."}`, +); +say(`Log id: ${result.logId ?? "none was returned."}`); +process.exit(result.successful ? 0 : 1); From c2adeb54f64bd8f85b81f23d7b90635ff59dd030 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:12:24 +0200 Subject: [PATCH 112/215] End every account at the broker when an app is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing an app used to clear this deployment's gate rows and tell the broker nothing, so every person's account stayed live where it actually lives — and adding the app back turned all of them on again with nobody asked. Consent that reattaches by itself is not consent. Now the people are read first, each account is ended at the broker, then the rows go, then one trail entry per person says what really happened, and the authorization config this deployment created is deleted last. Last because an orphaned config grants nobody anything while a live account does, so it is the only step whose failure leaves nothing dangerous behind. Nothing is caught around the revokes. A broker that will not answer has to stop the method rather than let it report an ending that did not happen, and stopping leaves rows that running the removal again will finish off — the recoverable half of the two. The ordering assertion this needs is not the obvious one. Moving the delete above the revoke leaves the recorded sequence of calls identical, so the test reads the table from inside the revoke instead and fails when it finds it already empty. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 77 +++++- server/tests/plugin-store.integration.test.ts | 227 +++++++++++++++++- 2 files changed, 285 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 581283f00..f64c6bd28 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2398,7 +2398,10 @@ export function createPluginStore(options: PluginStoreOptions) { * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing * between a person and their mailbox is a `composio_connections` row, and that table references * nothing that would cascade it. Removing the app therefore left every one of them behind, and - * adding the app back turned them live again without anybody being asked. That row goes too. + * adding the app back turned them live again without anybody being asked. That row goes too — + * and before it goes, the account it stands for is ended at Composio, because clearing the row + * alone shuts a gate and leaves the mailbox attached. The deployment's auth config for the app + * goes last, once nobody is connected to it any more. * * The revokes go first. These are writes on two tables and the store exposes no transaction that * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves @@ -2526,19 +2529,65 @@ export function createPluginStore(options: PluginStoreOptions) { * two writes leaves has to be the recoverable half. A connection cleared with the app still * present is fixed by removing it again; an app deleted with the connections standing is * reachable by no operation at all, because the toolkit was only ever readable off its url. + * + * AND THE ACCOUNT IS ENDED AT THE VENDOR, not merely forgotten here. Deleting the row closes + * the gate this deployment owns and does nothing whatever to the account: the person's + * mailbox stays attached at Composio, the grant stays live, and an administrator who pressed + * "remove" was told the connector was gone. So every connected person is revoked through the + * broker first, exactly as {@link disconnectBrokered} revokes for one — the same + * shape at the scale of an app. + * + * REVOKE BEFORE DELETE, ALWAYS, and the argument is the one that method makes. The row is the + * only thing here that names which app this person connected, so a delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on somebody's + * mailbox that no operation in this deployment can reach. The other order costs a repeat of + * an administrative act nobody minds repeating. Dead and reachable beats live and + * unreachable. + * + * WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes: a broker that will not + * answer ends this method with the rows still standing and the app still present, rather than + * letting it report an ending that did not happen. + * + * THE AUTH CONFIG GOES LAST, after every account is dead and every row is gone, for the same + * reasoning one step out. An orphaned auth config grants nobody anything — it is a shape this + * deployment holds at Composio, not an account — while a live account whose config has + * already been deleted is access that nothing left here can end. */ const toolkit = existing ? accessFor(existing, null).toolkit : null; if (toolkit) { + /* + * Read before anything is deleted, because the revokes below need the people and the rows + * are where the people are. Sorted, so two removals of the same app revoke in the same + * order and write their trail rows in the same order. + */ const connected = await database - .delete(composioConnections) + .select({ userId: composioConnections.userId }) + .from(composioConnections) .where(eq(composioConnections.toolkit, toolkit)) - .returning({ userId: composioConnections.userId }); + .orderBy(asc(composioConnections.userId)); + + /* + * What the broker actually did for each of them, kept so the trail below records the + * answer rather than the attempt. False where there is no broker at all: a deployment whose + * key has since been unset can still remove the app, and it could not have been calling it + * either way — but nothing was ended at Composio and the row must not claim otherwise. + */ + const vendorRevoked = new Map(); + for (const connection of connected) { + vendorRevoked.set( + connection.userId, + broker + ? await broker.revoke({ userId: connection.userId, toolkit }) + : false, + ); + } - // Sorted, so two removals of the same app write their rows in the same order. - for (const connection of connected.sort((left, right) => - left.userId.localeCompare(right.userId), - )) { + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)); + + for (const connection of connected) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -2570,14 +2619,20 @@ export function createPluginStore(options: PluginStoreOptions) { // administrator took the whole app away and the person did nothing. reason: "mcp_server_removed", /* - * False, and said out loud. This closed the gate this deployment owns; the account - * the person connected is still connected at Composio, and only they or an operator - * of that broker can end it. A row implying otherwise would be worse than no row. + * What happened, not what was attempted — {@link ComposioBroker.revoke}'s own + * answer, passed through. True where a grant was withdrawn, false where there was + * none to withdraw or where this deployment has no broker to have asked. The value + * of the field is exactly that a reader can tell a grant this deployment ended from + * one that outlives it somewhere else, so a constant here would be worse than none. */ - vendorRevoked: false, + vendorRevoked: vendorRevoked.get(connection.userId) ?? false, }, }); } + + // Last of all, for the reason above, and skipped entirely on a deployment with no + // broker to have made one. + if (broker) await broker.deleteAuthConfig(toolkit); } await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index affffe889..ebc76c3b5 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6056,8 +6056,20 @@ test("enabling an app with no broker says which setting is missing", async () => * the test's own functions, so a test that needs to know what a row looked like at the moment of * the call reads it inside its own handler — the same way the enablement test above establishes * that the auth config comes before the row. + * + * AND THE ENTRY NAMES WHOSE CALL IT WAS, where the call has an owner. A path that revokes for one + * person is fully described by `revoke`; a path that revokes for everybody connected to an app is + * not, because "two revokes happened" says nothing about who they were for and nothing about the + * order they went out in — and the order is what makes a removal repeatable. So the request's + * `userId` is appended where there is one, and calls that are about the deployment rather than a + * person (`deleteAuthConfig`) stay bare. */ function brokerSpy(answers: { + ensureAuthConfig?: (config: { + toolkit: string; + name: string; + }) => Promise; + deleteAuthConfig?: (toolkit: string) => Promise; isConnected?: (request: { userId: string; toolkit: string; @@ -6074,7 +6086,14 @@ function brokerSpy(answers: { what: string, ) => { return async (request: Request): Promise => { - order.push(name); + const owner = + typeof request === "object" && + request !== null && + "userId" in request && + typeof request.userId === "string" + ? `:${request.userId}` + : ""; + order.push(`${name}${owner}`); if (!handler) throw new Error(`the connection path asked the broker to ${what}`); return await handler(request); @@ -6085,8 +6104,16 @@ function brokerSpy(answers: { order, broker: { listApps: unasked("list the catalogue"), - ensureAuthConfig: unasked("create an auth config"), - deleteAuthConfig: unasked("delete an auth config"), + ensureAuthConfig: asked( + "ensureAuthConfig", + answers.ensureAuthConfig, + "create an auth config", + ), + deleteAuthConfig: asked( + "deleteAuthConfig", + answers.deleteAuthConfig, + "delete an auth config", + ), authorize: unasked("begin somebody's connection"), isConnected: asked( "isConnected", @@ -6135,7 +6162,7 @@ test("a brokered connection row is written only where the vendor says the accoun const connection = await store.brokeredConnection(pair); expect(connection?.connectedAt).toBeTruthy(); - expect(order).toEqual(["isConnected", "isConnected"]); + expect(order).toEqual(["isConnected:user_asker", "isConnected:user_asker"]); const connected = auditStore .recorded() .filter((event) => event.eventType === "mcp.account_connected"); @@ -6178,7 +6205,7 @@ test("confirming a brokered connection the vendor no longer has removes the row" }); expect(await store.brokeredConnection(pair)).toBeNull(); - expect(order).toEqual(["isConnected"]); + expect(order).toEqual(["isConnected:user_asker"]); expect(auditStore.recorded()).toHaveLength(0); }); @@ -6217,7 +6244,11 @@ test("confirming a brokered connection already recorded writes no second trail r connected: true, }); - expect(order).toEqual(["isConnected", "isConnected", "isConnected"]); + expect(order).toEqual([ + "isConnected:user_asker", + "isConnected:user_asker", + "isConnected:user_asker", + ]); // Unmoved, because the person connected when they connected: the confirms above are page loads. expect(await store.brokeredConnection(pair)).toEqual(first); expect( @@ -6266,7 +6297,7 @@ test("disconnecting a brokered connection revokes at the vendor before the row g expect(outcome).toEqual({ vendorRevoked: true }); expect(rowsWhenRevoked).toEqual(["user_asker"]); - expect(order).toEqual(["revoke"]); + expect(order).toEqual(["revoke:user_asker"]); expect(await store.brokeredConnection(pair)).toBeNull(); const disconnected = auditStore @@ -6362,7 +6393,7 @@ test("a brokered disconnect that withdrew no grant records that it withdrew none }), ).toEqual({ vendorRevoked: false }); - expect(order).toEqual(["revoke"]); + expect(order).toEqual(["revoke:user_asker"]); // Gone, because there was nothing at the vendor and the row was therefore the half that had // drifted. Keeping it would leave the gate on every brokered call passing for an account that // no longer exists anywhere. @@ -6431,3 +6462,183 @@ test("a brokered connection is not listed against a server row whose url names a expect(await store.brokeredConnectionsFor("user_asker")).toEqual([]); }); + +/** + * Removing an app ends every account at the vendor, and only then forgets where they were. + * + * CRITERION. `removeServer` on a brokered row revokes at the broker for every connected person + * while their rows are still standing, deletes the rows after that, and drops the deployment's + * auth config last of all — and the trail says of each person that the grant was really withdrawn. + * + * REASON. Clearing `composio_connections` closes the gate this deployment owns and nothing else: + * the account the person attached is still live at Composio, and an administrator who pressed + * "remove" was not told they had left it there. So the removal ends the accounts too, and the + * order is forced. A brokered row's toolkit is readable off nothing but the row, so delete-first + * and a revoke that then fails leaves a live grant on somebody's mailbox with no value left here + * to revoke it under; revoke-first and the same failure leaves the app present, every account + * dead, and removing again finishes the job. Dead-and-reachable beats live-and-unreachable. + * + * THE AUTH CONFIG GOES LAST for the same reasoning one step out. An orphaned auth config grants + * nobody anything — it is a shape this deployment holds at Composio, not an account — while a live + * account whose config has already gone is access nobody here can end. + * + * ASSERTED ON WHAT EACH CALL SAW, not on a count. Counting says nothing about order, and the order + * is the entire property: an implementation that deleted the rows first and revoked off what the + * delete returned would make exactly the same calls in exactly the same sequence. + * + * THE PEOPLE CONNECT IN REVERSE, `user-b` before `user-a`, so that the sorted revoke is doing work + * rather than agreeing with the insertion order by luck. + */ +test("removing an app revokes everybody, then clears rows, then drops the config", async () => { + /** Who was still connected to `linear` at the moment of each broker call, in call order. */ + const connectedWhenAsked: string[][] = []; + const stillConnected = async () => + ( + await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, "linear")) + .orderBy(asc(composioConnections.userId)) + ).map((row) => row.userId); + + const { broker, order } = brokerSpy({ + ensureAuthConfig: async () => {}, + isConnected: async () => true, + revoke: async () => { + connectedWhenAsked.push(await stillConnected()); + return true; + }, + deleteAuthConfig: async () => { + connectedWhenAsked.push(await stillConnected()); + }, + }); + const { store, database, auditStore } = await freshStore({ broker }); + useComposioClient({ + listActions: async () => [ + { + slug: "LINEAR_CREATE_ISSUE", + description: "Create an issue.", + inputParameters: { type: "object", properties: {} }, + tags: ["createHint"], + version: "20260903_00", + }, + ], + execute: async () => vendorAnswered(), + }); + + /* + * This fixture's own ids, checked here because the guard at the top of the file does not cover + * them: `composio-linear` and the pairs at `linear` are spelled the way production spells them, + * so a row already sitting at one belongs to somebody else and the cleanup below would take it. + */ + const [present] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + const strangers = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "linear"), + inArray(composioConnections.userId, ["user-a", "user-b"]), + ), + ); + if (present || strangers.length > 0) { + throw new Error( + "a 'composio-linear' server row or a 'linear' connection for user-a or user-b was already " + + "here, so it is not this test's to write over", + ); + } + + try { + await store.addBrokeredApp({ + slug: "linear", + title: "Linear", + by: "admin", + }); + for (const userId of ["user-b", "user-a"]) { + expect( + await store.confirmBrokeredConnection({ toolkit: "linear", userId }), + ).toEqual({ connected: true }); + } + + // The setup's own traffic, cleared so what follows is about the removal and nothing else. + order.length = 0; + connectedWhenAsked.length = 0; + + await store.removeServer("composio-linear", "admin"); + + expect(order).toEqual([ + "revoke:user-a", + "revoke:user-b", + "deleteAuthConfig", + ]); + /* + * Both revokes found both rows, and the auth config was dropped with none left. This is the + * ordering the call list above cannot see: revoking off the rows a delete had already returned + * would produce that same list while leaving nothing to revoke under if the broker refused. + */ + expect(connectedWhenAsked).toEqual([ + ["user-a", "user-b"], + ["user-a", "user-b"], + [], + ]); + + expect( + await store.brokeredConnection({ toolkit: "linear", userId: "user-a" }), + ).toBeNull(); + expect( + await store.brokeredConnection({ toolkit: "linear", userId: "user-b" }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + expect(disconnected.map((event) => event.targetId)).toEqual([ + // The app, not the server row's id, because that is what a brokered connection is keyed on + // and the id is gone by the time anybody reads back. + "linear", + "linear", + ]); + expect(disconnected.map((event) => event.payload)).toEqual([ + { + actor: "admin", + server: "linear", + owner: "user-a", + // Not "they disconnected" and not "they were removed": an administrator took the whole + // app away and the person did nothing. + reason: "mcp_server_removed", + /* + * True, and true because the broker said so rather than because the call returned. This + * is the whole change: the row used to say `false` here whatever happened, which was + * honest only while the removal left every account live at Composio. + */ + vendorRevoked: true, + }, + { + actor: "admin", + server: "linear", + owner: "user-b", + reason: "mcp_server_removed", + vendorRevoked: true, + }, + ]); + } finally { + await database + .delete(mcpTools) + .where(eq(mcpTools.serverId, "composio-linear")); + await database + .delete(mcpServers) + .where(eq(mcpServers.id, "composio-linear")); + await database + .delete(composioConnections) + .where( + and( + eq(composioConnections.toolkit, "linear"), + inArray(composioConnections.userId, ["user-a", "user-b"]), + ), + ); + } +}); From feecf7b53c2f7a1af48d1633566caf24592889b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:12:24 +0200 Subject: [PATCH 113/215] Answer a brokered app's Connect with a link minted for whoever asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect route refused a brokered row with "is not connected as an individual person", which is the opposite of true: it is the one connector that is nothing but per-person accounts. The refusal came from reaching checks that belong to a flow this row never enters — a public URL for a callback there is none of, and a catalogue entry it does not have — so the brokered branch is taken before both. THE PERSON COMES FROM THE SESSION AND FROM NOWHERE ELSE. A user id a caller could name would be a link that binds somebody else's account, which is the defect the prior art shipped three times; the test mutates the route to read one from the body and catches it. A second connection to the same app is refused rather than minted. The broker permits several and the call that runs an action names the person but not the account, so with two connected there is no answer to which mailbox a Bot just read — not here and not in the trail. The refusal names disconnect as the way to switch. The link is a bearer capability: whoever opens it binds an account to the id it was minted for. It goes back to the browser that asked and is stored nowhere, logged nowhere, and put in no error body. Two neighbouring suites built stub stores by hand and now needed the one method this branch reads. Widening the stubs, rather than teaching the route to work without a method its own type guarantees. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 73 +++++++++ server/tests/plugin-connect-route.test.ts | 10 +- server/tests/plugin-oauth-callback.test.ts | 3 + server/tests/plugin-routes.test.ts | 173 +++++++++++++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 08de91a35..b71aac4db 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -595,6 +595,79 @@ export function createPluginRoutes( */ routes.post("/servers/:id/connect", requireUser, async (context) => { const serverId = context.req.param("id"); + + /* + * A BROKERED APP IS ANSWERED HERE AND GOES NO FURTHER DOWN THIS HANDLER. + * + * Everything below this branch belongs to the consent flow THIS deployment runs: a public URL + * to build a redirect URI out of, a catalogue entry naming the vendor's authorization + * endpoint, an OAuth client an administrator registered, a sealed state the callback reads + * back. A brokered app has none of it. Composio holds the consent, so no authorization code + * ever comes back to us, no refresh token is stored here, and no redirect URI of ours is + * registered with anybody — there is nothing for those checks to be about. + * + * WHICH IS WHY THE ORDER IS THE WHOLE POINT AND NOT A TIDINESS. Falling through, a brokered + * row met `catalogueEntry`, which has never heard of `composio-linear`, and the person + * pressing Connect was told the app "is not connected as an individual person" — the exact + * opposite of true about the one kind of row that is ONLY ever connected as an individual + * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, + * refusing for want of a setting that has no bearing on a flow it does not enter. + * + * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the + * directory route says: the url is where the transport reads which app a call is against, and + * the id is a row name that happens to look similar. + */ + const row = (await store.listServers()).find( + (server) => server.id === serverId, + ); + const toolkit = row ? toolkitOf(row.url) : null; + if (toolkit) { + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + /* + * THE PERSON IS THE SESSION'S, HERE AND IN THE READ ABOVE IT. + * + * Nothing in this branch reads a user id out of the body or the query, and that is the + * property rather than an implementation detail: the link minted below attaches an account + * to whichever person it names, so a user id a caller could choose would let one POST hang + * somebody else's mailbox off this deployment. It is the defect the prior art this design + * follows shipped three separate times, and it is structural here — there is no line that + * could break it. + */ + const existing = await store.brokeredConnection({ + toolkit, + userId: context.var.actor.id, + }); + if (existing) { + // Named with the step to take rather than only refused: a second link would attach a + // second account behind a row that already says connected, and the way to a new one is + // through the connection they have. + return context.json( + { + error: `You already have an account connected to ${serverId}. Disconnect it first if you want to connect a different one.`, + }, + 409, + ); + } + + /* + * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to this person's + * connection, so it is answered to the browser that asked and to nothing else: not logged, + * not audited, not put in an error body. A redirect url in a log line is somebody else's + * mailbox for as long as it stays valid. + */ + const { redirectUrl } = await composio.broker.authorize({ + userId: context.var.actor.id, + toolkit, + }); + return context.json({ authorizationUrl: redirectUrl }); + } + if (!connect?.publicUrl) { return context.json( { diff --git a/server/tests/plugin-connect-route.test.ts b/server/tests/plugin-connect-route.test.ts index 3aa9617fd..bbaf50613 100644 --- a/server/tests/plugin-connect-route.test.ts +++ b/server/tests/plugin-connect-route.test.ts @@ -40,7 +40,15 @@ function app(store: { ) => Promise; }) { const routes = createPluginRoutes( - store as never, + { + /* + * The read that decides whether this is a brokered row, which the handler makes before + * anything about the consent flow. None of these vendors is one: a brokered app is reached + * through Composio and enters none of the flow these tests are about. + */ + listServers: async () => [], + ...store, + } as never, signedIn(), async () => true, { diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts index a3d3f5c81..a66b75c9b 100644 --- a/server/tests/plugin-oauth-callback.test.ts +++ b/server/tests/plugin-oauth-callback.test.ts @@ -56,6 +56,9 @@ function app(input: { recordConnection?: (connection: Recorded) => Promise; }) { const store = { + // The brokered read the connect handler makes before anything about the consent flow. This + // deployment has no brokered rows, so it answers nothing and the flow below is untouched. + listServers: async () => [], oauthClientFor: async () => ({ clientId: "dyn-1", clientSecret: "" }), ensureOAuthClient: async () => ({ clientId: "dyn-1", clientSecret: "" }), recordConnection: diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 76c08d550..d447b9a34 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -867,3 +867,176 @@ describe("a person's own connections", () => { expect((await response.json()).connections).toEqual([]); }); }); + +/** + * The url a person is sent to when they connect a brokered app to their own account. + * + * A constant rather than a literal at each assertion, because the thing being asserted about it is + * mostly where it does NOT appear: it is handed to the browser that asked and to nothing else. + */ +const AUTHORIZATION_URL = "https://backend.composio.dev/s/a-bearer-capability"; + +/** + * One brokered app, added, and the person connecting their own account to it. + * + * WHICH APP THIS IS COMES OFF THE ROW'S URL. `listServers` answers one row whose url is + * `composio://linear`, and the branch under test reads the app out of it with `toolkitOf` rather + * than off the id — the id is a row name (`composio-linear`) and reading one as the other works + * right up until somebody renames a row. + * + * `redirectUrl` NULL IS A DEPLOYMENT WITH NO COMPOSIO_API_KEY, the same way `directoryApp`'s null + * listing is: there is no broker at all, which is the shipped default and a state the surface has + * to answer honestly rather than by pretending nobody is connected. + */ +function brokeredApp( + /** What this deployment already holds for this person and this app. Null is nobody connected. */ + connection: { connectedAt: string } | null = null, + /** Null is a deployment with no COMPOSIO_API_KEY, which is the shipped default. */ + redirectUrl: string | null = AUTHORIZATION_URL, +) { + const authorized: Array<{ userId: string; toolkit: string }> = []; + const queried: Array<{ toolkit: string; userId: string }> = []; + const confirmed: Array<{ toolkit: string; userId: string }> = []; + const disconnected: Array<{ toolkit: string; userId: string }> = []; + + const store = { + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [ + { + id: "composio-linear", + url: "composio://linear", + provenance: "composio", + }, + ], + listSkills: async () => [], + listGrants: async () => [], + brokeredConnection: async (input: { toolkit: string; userId: string }) => { + queried.push(input); + return connection; + }, + confirmBrokeredConnection: async (input: { + toolkit: string; + userId: string; + }) => { + confirmed.push(input); + return { connected: connection !== null }; + }, + disconnectBrokered: async (input: { toolkit: string; userId: string }) => { + disconnected.push(input); + return { vendorRevoked: true }; + }, + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + // Connecting an account is not an administrator's act: an administrator adds the app once, and + // then everybody connects their own. + { rolesForUser: async () => ["user"] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + // Positions 16-25 are the stores after it; the broker is 26, `composio`. + ...(Array.from({ length: 10 }) as never[]), + redirectUrl + ? ({ + broker: { + authorize: async (request: { userId: string; toolkit: string }) => { + authorized.push(request); + return { redirectUrl }; + }, + }, + } as never) + : undefined, + ); + + return { + authorized, + queried, + confirmed, + disconnected, + connect: (body: unknown) => + app.request( + "http://openbot.test/api/plugins/servers/composio-linear/connect", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ), + }; +} + +describe("connecting a brokered app", () => { + test("the link is minted for the session's own person, whatever the body says", async () => { + /* + * THE USER ID COMES FROM THE SESSION AND FROM NOWHERE ELSE. + * + * A brokered call opens whichever account the user id names, so a route that would take one + * out of a request body is one POST away from attaching somebody else's Linear to this + * person's row — or, the same defect turned around, minting a link that connects this person's + * account under somebody else's name. It is the defect the prior art this design copies + * shipped and fixed three separate times, which is why the body here carries a user id at all: + * the assertion is that it changed nothing. + */ + const { authorized, queried, connect } = brokeredApp(); + + const response = await connect({ userId: "user_somebody_else" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorizationUrl: AUTHORIZATION_URL, + }); + expect(authorized).toEqual([{ userId: ADMIN.id, toolkit: "linear" }]); + // And the read that decided there was no connection yet asked about the same person. + expect(queried).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("a brokered row never reaches the checks that belong to the OAuth flow", async () => { + /* + * The ordering, stated as its own case. This deployment has no OPENBOT_PUBLIC_URL and + * `composio-linear` is in nobody's catalogue, so a brokered row that fell through to either of + * the two checks below the branch would be answered "no public URL" or "is not connected as an + * individual person" — the second of which is the opposite of true. Neither check applies: no + * authorization code comes back to us, no refresh token is stored, and no redirect URI of ours + * is registered anywhere. + */ + const { connect } = brokeredApp(); + + const body = await (await connect({})).json(); + + expect(body.authorizationUrl).toBe(AUTHORIZATION_URL); + }); + + test("a second connection is refused with the step to take", async () => { + const { authorized, connect } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await connect({}); + + expect(response.status).toBe(409); + // Naming the remedy rather than only refusing: the person has an account attached already, and + // the only way to a new link is through disconnecting the one they have. + expect(((await response.json()).error as string).toLowerCase()).toContain( + "disconnect", + ); + // And nothing was minted, which is the half that matters: a link handed out here would attach + // a second account behind a row that already says connected. + expect(authorized).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + // The same answer the directory gives, for the same reason: nobody was asked, and the remedy + // is one environment variable long. + const { connect } = brokeredApp(null, null); + + const response = await connect({}); + + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + }); +}); From 53980697662fd225e0ea51bd8ff9799d8d1424d1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:12:24 +0200 Subject: [PATCH 114/215] Give one Bot and one app a screen with a switch per action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching a grant meant opening one action and switching the Bots that may use it. That was right for twenty actions across three connectors and is wrong for an app publishing sixty-three: setting one Bot up became sixty-three page loads, which is how somebody grants the wrong thing out of fatigue. Turned around: one screen per Bot and app, searchable, split into what reads and what changes things, a switch each. The per-action screen stays, because granting one action to several Bots is still a thing somebody does, and both write through the grants that already exist. Every switch is off until a grant says otherwise. There is no local checked state and nothing arrives pre-selected, so a switch drawn on is always a decision somebody made. One bulk action, and it says what it is about to do before it does it — how many tools this Bot would then hold — because "turn on every read-only action" on a large app is a number worth seeing first. It covers reads and only reads. A destructive action is marked, and nothing at all is drawn beside a write that is not. An action declining to claim it destroys something is not claiming to be safe, so there is no reassuring mark to misread. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routeTree.gen.ts | 22 ++ app/src/routes/_authed/admin/plugins/$key.tsx | 63 +++ .../admin/plugins/$key_.bots.$agentId.tsx | 360 ++++++++++++++++++ app/tests/bot-app-grants.test.tsx | 73 ++++ 4 files changed, 518 insertions(+) create mode 100644 app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx create mode 100644 app/tests/bot-app-grants.test.tsx diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 4fc239880..bab75137e 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -41,6 +41,7 @@ import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './rout import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index' import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key' +import { Route as AuthedAdminPluginsKeyBotsAgentIdRouteImport } from './routes/_authed/admin/plugins/$key_.bots.$agentId' import { Route as AuthedAdminPluginsKeyToolsToolRouteImport } from './routes/_authed/admin/plugins/$key_.tools.$tool' const AuthedRoute = AuthedRouteImport.update({ @@ -210,6 +211,12 @@ const AuthedSettingsConnectedAccountsKeyRoute = path: '/connected-accounts/$key', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedAdminPluginsKeyBotsAgentIdRoute = + AuthedAdminPluginsKeyBotsAgentIdRouteImport.update({ + id: '/plugins/$key_/bots/$agentId', + path: '/plugins/$key/bots/$agentId', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminPluginsKeyToolsToolRoute = AuthedAdminPluginsKeyToolsToolRouteImport.update({ id: '/plugins/$key_/tools/$tool', @@ -248,6 +255,7 @@ export interface FileRoutesByFullPath { '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesByTo { @@ -279,6 +287,7 @@ export interface FileRoutesByTo { '/admin/plugins': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesById { @@ -315,6 +324,7 @@ export interface FileRoutesById { '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/_authed/admin/plugins/$key_/bots/$agentId': typeof AuthedAdminPluginsKeyBotsAgentIdRoute '/_authed/admin/plugins/$key_/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRouteTypes { @@ -350,6 +360,7 @@ export interface FileRouteTypes { | '/admin/plugins/' | '/settings/components-gallery/' | '/settings/connected-accounts/' + | '/admin/plugins/$key/bots/$agentId' | '/admin/plugins/$key/tools/$tool' fileRoutesByTo: FileRoutesByTo to: @@ -381,6 +392,7 @@ export interface FileRouteTypes { | '/admin/plugins' | '/settings/components-gallery' | '/settings/connected-accounts' + | '/admin/plugins/$key/bots/$agentId' | '/admin/plugins/$key/tools/$tool' id: | '__root__' @@ -416,6 +428,7 @@ export interface FileRouteTypes { | '/_authed/admin/plugins/' | '/_authed/settings/components-gallery/' | '/_authed/settings/connected-accounts/' + | '/_authed/admin/plugins/$key_/bots/$agentId' | '/_authed/admin/plugins/$key_/tools/$tool' fileRoutesById: FileRoutesById } @@ -650,6 +663,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsConnectedAccountsKeyRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/admin/plugins/$key_/bots/$agentId': { + id: '/_authed/admin/plugins/$key_/bots/$agentId' + path: '/plugins/$key/bots/$agentId' + fullPath: '/admin/plugins/$key/bots/$agentId' + preLoaderRoute: typeof AuthedAdminPluginsKeyBotsAgentIdRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/plugins/$key_/tools/$tool': { id: '/_authed/admin/plugins/$key_/tools/$tool' path: '/plugins/$key/tools/$tool' @@ -675,6 +695,7 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminPluginsComposioRoute: typeof AuthedAdminPluginsComposioRoute AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute + AuthedAdminPluginsKeyBotsAgentIdRoute: typeof AuthedAdminPluginsKeyBotsAgentIdRoute AuthedAdminPluginsKeyToolsToolRoute: typeof AuthedAdminPluginsKeyToolsToolRoute } @@ -693,6 +714,7 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminPluginsComposioRoute: AuthedAdminPluginsComposioRoute, AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute, + AuthedAdminPluginsKeyBotsAgentIdRoute: AuthedAdminPluginsKeyBotsAgentIdRoute, AuthedAdminPluginsKeyToolsToolRoute: AuthedAdminPluginsKeyToolsToolRoute, } diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index dd6a63129..941f09bce 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -94,6 +94,19 @@ function grantSummary(held: number, total: number): string { return `${held} of ${total} Bots`; } +/** + * How much of this vendor one Bot holds, in the same voice as {@link grantSummary}. + * + * The same decision counted the other way round, for the rows that open a Bot's own page against + * this app. Named ends rather than a fraction, for the reason recorded above: "0/167" reads as a + * score, and the two answers worth recognising without reading are none of it and all of it. + */ +function heldSummary(held: number, total: number): string { + if (held === 0) return "No tools"; + if (held === total) return total === 1 ? "1 tool" : "Every tool"; + return `${held} of ${total} tools`; +} + /** * How this vendor is reached, from whichever record we have. * @@ -805,6 +818,56 @@ function RouteComponent() { ))}
)} + + {/* + * The same grants, from the Bot's end. + * + * In this section rather than one of its own, because it is not a second subject: the + * list above is one action and every Bot, and these rows are one Bot and every action. + * Which way round somebody wants it depends on what they came here to do — write a rule + * about an action, or set a Bot up — and an app of a hundred and sixty-seven actions is + * only approachable from this end. + */} + {server.tools.length > 0 && bots.length > 0 ? ( + <> +

By Bot

+ + {bots.map((bot, index) => ( + + {/* A real link with no children: children passed to `render` replace the row's own. */} + + } + size="sm" + > + + {bot.name} + + Every action this app offers, switched one at a time. + + + + + {heldSummary( + server.tools.filter((tool) => + tool.grantedTo.includes(bot.id), + ).length, + server.tools.length, + )} + + + + + {index !== bots.length - 1 && } + + ))} + + + ) : null} ) : null} diff --git a/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx new file mode 100644 index 000000000..33569157f --- /dev/null +++ b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx @@ -0,0 +1,360 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, useParams } from "@tanstack/react-router"; +import * as React from "react"; +import { useState } from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { useBotNames } from "@/lib/agents/bot-names"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { + grantPlugin, + invalidatePlugins, + setPluginGrantMutationOptions, +} from "@/lib/plugins/mutations"; +import { + type PluginTool, + pluginsPageQueryOptions, +} from "@/lib/plugins/queries"; + +/** + * One Bot and one app: every action the app offers, and which of them this Bot holds. + * + * The sibling screen — `$key_.tools.$tool` — answers the same question from the other end, one + * action and every Bot, and that is the right shape when somebody is thinking about an action. It + * is the wrong shape for setting a Bot up against an app that offers a hundred and sixty-seven of + * them: that is one visit per action, and the decision being made ("what can this Bot do in Slack") + * is never on screen at once. + * + * So the list is searchable and split by what a boundary sees. A read and a write are not the same + * decision, and an app's actions arrive interleaved by name, which puts `delete_channel` two rows + * under `list_channels` with nothing between them but a word. + * + * `$key_` opts this route out of nesting under `$key.tsx`, so the connector page stays a page rather + * than becoming a layout with an outlet. + */ +export const Route = createFileRoute( + "/_authed/admin/plugins/$key_/bots/$agentId", +)({ component: RouteComponent }); + +/** + * The refs the bulk action would grant: every action that only reads. + * + * Exported as a function so the promise the button makes is assertable without a DOM, a router or + * a query client — see `tests/bot-app-grants.test.tsx`. The button says "read-only" and states a + * resulting count before it acts, and both of those are only true if this is exactly the reads. + * + * A destructive action can never appear here. `destructive` sits beside `effect` rather than inside + * it, and the effect a destructive action carries is `write` — anything not positively known to be + * a read is one — so filtering on the effect alone already excludes it. + * + * Every action, not the ones a search happens to be showing. The button names every read-only + * action and that is what it grants; narrowing it to the filtered rows would make the sentence + * beside it quietly wrong. + */ +export function readOnlyRefs(tools: PluginTool[]): string[] { + return tools.filter((tool) => tool.effect === "read").map((tool) => tool.ref); +} + +function RouteComponent() { + const { key, agentId } = useParams({ + from: "/_authed/admin/plugins/$key_/bots/$agentId", + }); + const queryClient = useQueryClient(); + const plugins = useQuery(pluginsPageQueryOptions()); + const agents = useQuery(agentListQueryOptions()); + const nameFor = useBotNames(); + const [error, setError] = useState(null); + /** What the list is narrowed to, over action names. Never over what is switched on. */ + const [search, setSearch] = useState(""); + /** + * How far through the bulk grant we are, or null when none is running. + * + * A count rather than a boolean, for the reason the connector page's own batch records: this is + * honestly N writes, each its own audit row, and a button that says only "Granting…" for the + * length of forty of them gives an administrator no way to tell a slow batch from a stuck one. + */ + const [granting, setGranting] = useState<{ + done: number; + total: number; + } | null>(null); + + const setGrant = useMutation({ + ...setPluginGrantMutationOptions(queryClient), + onError: (thrown: Error) => setError(thrown.message), + }); + + const server = plugins.data?.servers.find((row) => row.id === key); + const appTitle = + plugins.data?.catalogue.find((item) => item.key === key)?.title ?? + server?.title ?? + key; + const bot = agents.data?.find((one) => one.id === agentId); + + const back = { + label: appTitle, + linkProps: { + params: { key }, + to: "/admin/plugins/$key" as const, + }, + }; + + /* + * One write per grant, in list order, with one refetch at the end. + * + * Going through the single-grant mutation would invalidate every plugin query after each write + * and await it, so forty reads would be forty round trips interleaved with forty refetches of a + * list nobody can read while the button is still counting. A refusal stops the rest and says why; + * the ones before it landed, so the screen is refreshed either way. + */ + const grantEveryRead = async (refs: string[]) => { + setError(null); + setGranting({ done: 0, total: refs.length }); + let done = 0; + try { + for (const ref of refs) { + await grantPlugin({ agentId, kind: "mcp", ref }); + done += 1; + setGranting({ done, total: refs.length }); + } + } catch (thrown) { + setError((thrown as Error).message); + } finally { + await invalidatePlugins(queryClient); + setGranting(null); + } + }; + + /* Nothing rather than a placeholder, so no sentence asserts anything while a fetch is open. */ + if (plugins.isPending || agents.isPending) { + return {null}; + } + + if (!server) { + return ( + + There is nothing here to grant. + + ); + } + + /* + * Gated on the roster having ARRIVED, not on `bot` being missing. + * + * `isPending` goes false on a failed fetch exactly as it does on a successful one, so `!bot` + * alone cannot tell "this deployment has no such Bot" apart from "the roster could not be read" + * — and the first of those is a claim, made about a Bot that may be perfectly real, on the + * evidence of a request that never came back. See `tests/agent-roster-error.test.tsx`, which + * exists for the same mistake on the two screens that shipped it. With no roster the grants + * below are still the plugins query's own answer and still true; the name falls back to the id. + */ + if (agents.data && !bot) { + return ( + + + It may have been deleted since this page was opened. + + + ); + } + + const botName = nameFor(agentId); + const held = (tool: PluginTool) => tool.grantedTo.includes(agentId); + + /* + * What the search is over: the action's own name, which is what somebody arriving here already + * knows. An app of this size is not read top to bottom, and a field that searched descriptions + * too would answer a typed name with a screenful of rows that do not carry it. + */ + const needle = search.trim().toLowerCase(); + const matching = needle + ? server.tools.filter((tool) => tool.name.toLowerCase().includes(needle)) + : server.tools; + const reads = matching.filter((tool) => tool.effect === "read"); + const writes = matching.filter((tool) => tool.effect === "write"); + + /* + * What the Bot would hold afterwards, counted over every tool rather than the filtered ones: the + * button grants every read whatever the search is showing, and a count that moved while somebody + * typed would be describing a different action than the one the button takes. + */ + const everyRead = readOnlyRefs(server.tools); + const ungranted = everyRead.filter( + (ref) => !server.tools.some((tool) => tool.ref === ref && held(tool)), + ); + const wouldHold = server.tools.filter( + (tool) => held(tool) || tool.effect === "read", + ).length; + + /** One card of switchable rows. Both sections are the same row, so they are the same code. */ + const rows = (tools: PluginTool[]) => ( + + {tools.map((tool, index) => ( + + + + {tool.name} + + {tool.description || "This action came with no description."} + + + + {/* + * Drawn only for an action the vendor warns about, and nothing at all beside one it + * does not. `destructive` is false when the vendor made no claim, which is not a + * claim of safety — so an absence stays an absence here rather than becoming a green + * word that says more than anybody knows. + * + * The destructive colour rather than the amber this app's tool list gives writes: the + * heading over these rows already says they change things, and the one thing left to + * say about this row is that what it changes does not come back. + */} + {tool.destructive ? ( + + destroys things + + ) : null} + {/* + * Binary and immediate, which is what a Switch is for: it takes effect when switched + * and there is no save. It is on for a grant that exists and off otherwise — nothing + * here is switched on by default, and nothing is proposed pre-switched. Disabled only + * while its own write is in flight, so switching one action does not freeze the list. + */} + { + setError(null); + setGrant.mutate({ + agentId, + granted: next, + kind: "mcp", + ref: tool.ref, + }); + }} + /> + + + {index !== tools.length - 1 && } + + ))} + + ); + + return ( + + {error ? ( +

+ {error} +

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

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

+ +
+ ) : null} + + + {reads.length === 0 ? ( + + {needle + ? `Nothing that only reads matches "${search.trim()}".` + : `${appTitle} offers nothing that only reads.`} + + ) : ( + rows(reads) + )} + + + + {writes.length === 0 ? ( + + {needle + ? `Nothing that changes things matches "${search.trim()}".` + : `${appTitle} offers nothing that changes anything.`} + + ) : ( + rows(writes) + )} + + + )} +
+ ); +} diff --git a/app/tests/bot-app-grants.test.tsx b/app/tests/bot-app-grants.test.tsx new file mode 100644 index 000000000..3a9f3de77 --- /dev/null +++ b/app/tests/bot-app-grants.test.tsx @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import type { PluginTool } from "@/lib/plugins/queries"; +import { readOnlyRefs } from "@/routes/_authed/admin/plugins/$key_.bots.$agentId"; + +/** + * What one button on the per-Bot grant screen promises, checked without drawing anything. + * + * The screen lists every action an app offers with a switch each, and offers one bulk action: + * "Turn on every read-only action". That button is the only control on the page that grants more + * than one thing at a time, so the only thing worth pinning about it is the set it acts on — every + * read, and nothing that changes anything, however the vendor labels it. + * + * A `.tsx` file because it imports a route module, which is JSX; the precedent is + * `agent-roster-error.test.tsx`. Nothing here renders, though — `readOnlyRefs` is exported as a + * function rather than left inline for exactly that reason, following `composio-picker.test.tsx`: + * the promise the button makes can be asserted without a DOM, a router or a query client. + */ + +/** A minimal but complete `PluginTool`, overridable per case. */ +function tool(overrides: Partial & { name: string }): PluginTool { + return { + serverId: "slack", + description: "Does something.", + inputSchema: {}, + ref: `slack/${overrides.name}`, + effect: "read", + destructive: false, + grantedTo: [], + ...overrides, + }; +} + +const LIST_CHANNELS = tool({ name: "list_channels" }); +const SEARCH_MESSAGES = tool({ name: "search_messages" }); +const SEND_MESSAGE = tool({ name: "send_message", effect: "write" }); +const DELETE_CHANNEL = tool({ + name: "delete_channel", + effect: "write", + destructive: true, +}); + +test("the bulk action covers every read, and nothing that changes anything", () => { + const refs = readOnlyRefs([ + LIST_CHANNELS, + SEND_MESSAGE, + SEARCH_MESSAGES, + DELETE_CHANNEL, + ]); + + // Every read, so the button's own sentence about what the Bot would then hold is true. + expect(refs).toEqual(["slack/list_channels", "slack/search_messages"]); + // And nothing else: a bulk grant that quietly swept a write in would be the one mistake this + // button must never make, because nobody switched that write on. + expect(refs).not.toContain("slack/send_message"); +}); + +test("a destructive action never appears in the bulk action", () => { + const tools = [LIST_CHANNELS, SEND_MESSAGE, DELETE_CHANNEL]; + const refs = readOnlyRefs(tools); + + // Read back through the tools rather than naming the ref: this asserts the property — nothing + // the vendor warns about destroying anything comes out — rather than one hard-coded absence. + const granted = tools.filter((entry) => refs.includes(entry.ref)); + expect(granted.every((entry) => !entry.destructive)).toBe(true); + expect(granted.every((entry) => entry.effect === "read")).toBe(true); +}); + +test("an app that offers nothing to read grants nothing", () => { + // The button is hidden in this case, and would still be harmless if it were not: an empty + // promise is kept by doing nothing, not by falling back to the whole list. + expect(readOnlyRefs([SEND_MESSAGE, DELETE_CHANNEL])).toEqual([]); + expect(readOnlyRefs([])).toEqual([]); +}); From 9d053e52dac392becd7ef27f36593de4e62eeb3d Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:48:21 +0200 Subject: [PATCH 115/215] End a removed person's accounts where they actually live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connections table was given a life independent of the user record precisely so offboarding could still find what somebody had after they were gone. The revoking half was never built, because there was no client to revoke with — so until now removing somebody shut the gate this deployment owns and left every account connected at the broker, which is the first thing anybody asks about a per-person connector. Revoke before delete, as everywhere else here. A broker that will not answer stops the method with the rows standing, and the caller is already built for that: it retires after its own transaction commits, so a removal with no retirement beside it is recoverable by removing the person again. Deleting first would leave live accounts reachable by no operation at all. The doc comment that said vendor-side revocation "belongs with disconnect" was true of both halves and is now true of only one. The vault half keeps its statement — the grant at Google outlives our copy of the secret until somebody revokes it there — and the brokered half says what it now does. Two test corrections ride along. What the trail records as revoked at the vendor had no test in the false direction on either removal path, so a constant would have passed; both now have one. And the fixture pairs this suite claims to own are stated as the cross they are, so the guard that refuses to run against a database holding somebody else's rows covers what the new test writes — with a comment one file over corrected, since it explained an absent revoke as policy when the real reason is that its store has no broker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 73 +++-- server/tests/composio-connections.test.ts | 6 +- server/tests/plugin-store.integration.test.ts | 261 ++++++++++++++++-- 3 files changed, 297 insertions(+), 43 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index f64c6bd28..f95ff1f6d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3909,10 +3909,26 @@ export function createPluginStore(options: PluginStoreOptions) { * itself the permission — the only thing deciding whether a call may go out as this person. * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. * - * NOT vendor-side revocation. That needs the OAuth client and the vendor's revoke endpoint, and - * it belongs with disconnect. This is the half that stops us holding the secret; the grant at - * Google outlives it until somebody revokes it there. Said plainly rather than implied, because - * the difference matters to whoever has to answer for it. + * NOT VENDOR-SIDE REVOCATION FOR THE VAULT HALF. That needs the OAuth client and the vendor's + * revoke endpoint, and it belongs with disconnect. Those rows are the half that stops us + * holding the secret; the grant at Google outlives it until somebody revokes it there. Said + * plainly rather than implied, because the difference matters to whoever has to answer for it. + * + * THE BROKERED HALF DOES END IT AT THE VENDOR, because there is no secret of ours to stop + * holding: clearing the row alone would shut the gate this deployment owns and leave the + * mailbox attached at Composio, which is "we removed their access" being untrue of the only + * thing that matters, for the person it matters most about. So every app this person connected + * is revoked through the broker, exactly as {@link disconnectBrokered} revokes for one and + * {@link removeServer} for a whole app. + * + * REVOKE BEFORE DELETE, ALWAYS. The row is the only thing that names which apps this person + * had, and it outlives the `users` row precisely so offboarding can still find them — which + * was the table's whole justification and until now was theoretical. A delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on a departed + * person's mailbox that no operation in this deployment can reach. The other order costs a + * repeat of an act nobody minds repeating. Nothing is caught around the revokes either, so a + * broker that will not answer ends this method with the rows still standing rather than + * letting it report an ending that did not happen. */ async retireConnectionsFor( userId: string, @@ -3987,17 +4003,40 @@ export function createPluginStore(options: PluginStoreOptions) { * be what deletes it. * * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays - * quiet on its own: the rows are gone, so the second call deletes none. + * quiet on its own: the rows are gone, so the second call finds none. + * + * READ BEFORE ANYTHING IS DELETED, because the revokes below need the apps and the rows are + * where the apps are — the reason the docblock gives for revoking first. Sorted, so two + * retirements of the same person revoke in the same order and write their rows in the same + * order. */ const brokered = await database - .delete(composioConnections) + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) .where(eq(composioConnections.userId, userId)) - .returning({ toolkit: composioConnections.toolkit }); + .orderBy(asc(composioConnections.toolkit)); - // Sorted, so two retirements of the same person write their rows in the same order. - for (const connection of brokered.sort((left, right) => - left.toolkit.localeCompare(right.toolkit), - )) { + /* + * What the broker actually did for each app, kept so the trail below records the answer + * rather than the attempt. False where there is no broker at all: a deployment whose key has + * since been unset can still offboard somebody, and it could not have been calling Composio + * either way — but nothing was ended there and the row must not claim otherwise. + */ + const vendorRevoked = new Map(); + for (const connection of brokered) { + vendorRevoked.set( + connection.toolkit, + broker + ? await broker.revoke({ userId, toolkit: connection.toolkit }) + : false, + ); + } + + await database + .delete(composioConnections) + .where(eq(composioConnections.userId, userId)); + + for (const connection of brokered) { retired += 1; await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", @@ -4011,12 +4050,14 @@ export function createPluginStore(options: PluginStoreOptions) { owner: userId, reason: "person_removed", /* - * False here for a different reason than above. There, the grant at Google outlives our - * copy of the secret. Here there is no secret of ours at all: the account stays - * connected at Composio until somebody ends it there, and what this did was shut the - * only gate this deployment owns. + * What happened, not what was attempted — {@link ComposioBroker.revoke}'s own answer, + * passed through, and the one place this half differs from the vault loop above. + * There the grant at Google outlives our copy of the secret and the field can only + * say so; here there was no secret of ours and the account itself was ended, or was + * already gone, or there was no broker to ask. The value of the field is exactly that + * a reader can tell those apart, so a constant here would be worse than none. */ - vendorRevoked: false, + vendorRevoked: vendorRevoked.get(connection.toolkit) ?? false, }, }); } diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 60ea85124..084596d81 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -269,9 +269,9 @@ test("offboarding somebody retires the app they connected, and the next call is actor: admin, server: toolkit, owner: askerId, - // An administrator removing somebody, never somebody changing their own mind. And the account - // at the broker is still connected: this closed the gate, it did not revoke anything at - // Composio. + // An administrator removing somebody, never somebody changing their own mind. And false + // because this store was built with no broker, so there was nobody to revoke at: + // `retireConnectionsFor` ends the account at Composio on a deployment that has one. reason: "person_removed", vendorRevoked: false, }); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index ebc76c3b5..da00bdf99 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -256,19 +256,27 @@ let ownsFixtureIds = false; /** * Every `composio_connections` row this file may claim, as one clause used by all three sites. * - * CRITERION. The refuse-to-run guard, the per-test sweep and the teardown ask exactly the same - * question, and a fixture added at a fourth pair has to change one place rather than three. + * CRITERION. The pair set is an app crossed with a person: `gmail` or `linear`, held by + * `user_asker`, by `user_leaver` or by the anonymous actor. The refuse-to-run guard, the per-test + * sweep and the teardown ask exactly that question and nothing wider, and a fixture written at a + * pair outside the cross has to widen one clause rather than three. + * + * A CROSS RATHER THAN A LIST, so it is a little wider than what the fixtures actually write — no + * test here connects `user_asker` to `linear`. That is deliberate and it is safe in this one + * direction: the guard runs first and has already established that no row sits anywhere in the + * cross, so every pair the sweep and the teardown delete is a pair this run put there. * * REASON. They disagreed. The guard and the sweep asked by person across every app; one test's * cleanup asked by the anonymous actor across every app; the pair `("gmail", "")` was in none of * them. So the file refused to run on rows it does not create, deleted rows it did not create, and * left behind one that it did — three faces of one confusion about what makes a row this file's. - * The app is half the answer: every row here is at `gmail`, because a Composio app IS its toolkit - * slug and this file asserts things about the real one. + * The app is half the answer: every row here is at a real app, because a Composio app IS its + * toolkit slug and this file asserts things about the real ones — so naming the person alone + * claims that person's rows at every other app as well. */ function ownedConnections() { return and( - eq(composioConnections.toolkit, "gmail"), + inArray(composioConnections.toolkit, ["gmail", "linear"]), inArray(composioConnections.userId, ["user_asker", "user_leaver", ""]), ); } @@ -297,23 +305,23 @@ beforeAll(async () => { /* * Brokered connections, keyed on the PAIR rather than on the person. * - * CRITERION. This guard refuses on `("gmail", )` and on no other - * `composio_connections` row, because those three pairs are the only ones this file inserts - * and the only ones it deletes. + * CRITERION. This guard refuses on {@link ownedConnections} — two real apps crossed with + * three people — and on no other `composio_connections` row, because every pair this file + * inserts and every pair it deletes is inside that cross. * - * REASON. Every connection row this file writes is at `gmail` — the two people it invents - * and the anonymous actor alike — so the app is half of what makes a row this file's, and - * asking by person alone claims rows at every other app as well. Both spellings of that - * over-reach have already cost something. `user_id = ''` caught the anonymous row - * `composio-connections.test.ts` writes against its own run-suffixed app, so a run of that - * file killed before its cleanup refused every test here for good; `user_id IN (asker, - * leaver)` claims a `("slack", "user_asker")` row the same way, and `freshDatabase` would - * then DELETE it — a `composio_connections` row is the entire gate on a brokered call, and - * nothing else can find it again. + * REASON. Every connection row this file writes is at a real app, `gmail` or `linear` — the + * two people it invents and the anonymous actor alike — so the app is half of what makes a + * row this file's, and asking by person alone claims rows at every other app as well. Both + * spellings of that over-reach have already cost something. `user_id = ''` caught the + * anonymous row `composio-connections.test.ts` writes against its own run-suffixed app, so a + * run of that file killed before its cleanup refused every test here for good; `user_id IN + * (asker, leaver)` claims a `("slack", "user_asker")` row the same way, and `freshDatabase` + * would then DELETE it — a `composio_connections` row is the entire gate on a brokered + * call, and nothing else can find it again. * - * The anonymous actor is one of the three because `user_id` is notNull and notNull does not - * exclude the empty string, so `("gmail", "")` is a row a deployment can legally hold, which - * is the whole point of the test that inserts one. + * The anonymous actor is one of the three people because `user_id` is notNull and notNull + * does not exclude the empty string, so `("gmail", "")` is a row a deployment can legally + * hold, which is the whole point of the test that inserts one. */ database .select({ @@ -3986,10 +3994,10 @@ afterAll(async () => { * CRITERION. Nothing at `sweep_witness_${suite}` outlives this run. * * REASON. It is removed in its own test's `finally`, which a killed process does not run — and - * nothing else would reach it: `ownedConnections` is keyed on `gmail`, and the anonymous actor - * is precisely what `retireConnectionsFor` refuses to act on, so no operation in the product - * could clear it either. Named exactly rather than by prefix, because another run's witness is - * that run's to take back. + * nothing else would reach it: `ownedConnections` names `gmail` and `linear`, and the anonymous + * actor is precisely what `retireConnectionsFor` refuses to act on, so no operation in the + * product could clear it either. Named exactly rather than by prefix, because another run's + * witness is that run's to take back. */ await database .delete(composioConnections) @@ -6642,3 +6650,208 @@ test("removing an app revokes everybody, then clears rows, then drops the config ); } }); + +/** + * Offboarding somebody ends the accounts they connected, where they actually live. + * + * CRITERION. `retireConnectionsFor` on somebody holding two brokered connections revokes both at + * the broker while their rows are still standing, deletes the rows after that, counts both, and + * says in the trail of each that the grant was really withdrawn. + * + * REASON. A brokered connection holds no secret of ours, so deleting the row shuts the gate this + * deployment owns and leaves the mailbox attached at Composio. "We removed their access" was then + * untrue of the only thing that matters — the grant at the vendor — for the person it matters most + * about, one who has been removed and cannot be asked to disconnect anything themselves. + * + * REVOKE BEFORE DELETE, and the order is forced by what the row is. It is the only place naming + * which apps this person had, and it outlives the `users` row precisely so offboarding can still + * find them; that was the table's whole justification and until now nothing exercised it. Delete + * first and a broker that refuses leaves a live grant on a departed person's mailbox with nothing + * left here to revoke it under. Revoke first and the same refusal leaves the rows standing and + * offboarding repeatable. Dead-and-reachable beats live-and-unreachable. + * + * ASSERTED ON WHAT EACH CALL SAW, not on a count and not on the call list. Both revokes are for + * one person, so the recorded sequence is identical whichever order the code uses — an + * implementation revoking off what the delete returned would make the same two calls. Reading the + * table from inside the stub is the only thing here that can tell the two apart. + * + * THE APPS ARE CONNECTED IN REVERSE, `linear` before `gmail`, so the sorted revoke is doing work + * rather than agreeing with the insertion order by luck. + */ +test("removing a person revokes their brokered accounts at the broker", async () => { + /** Which apps this person was still connected to at the moment of each revoke, in call order. */ + const connectedWhenAsked: string[][] = []; + /** Which app each revoke was for, which the spy's own `revoke:user_leaver` entries cannot say. */ + const revoked: string[] = []; + // Through the file's own handle, which is the one `freshStore` hands back: the rows this test + // writes are swept by {@link freshDatabase} and the teardown, so nothing here needs a local name + // for the database. + const stillConnected = async () => + ( + await database + .select({ toolkit: composioConnections.toolkit }) + .from(composioConnections) + .where(eq(composioConnections.userId, "user_leaver")) + .orderBy(asc(composioConnections.toolkit)) + ).map((row) => row.toolkit); + + const { broker, order } = brokerSpy({ + isConnected: async () => true, + revoke: async ({ toolkit }) => { + connectedWhenAsked.push(await stillConnected()); + revoked.push(toolkit); + return true; + }, + }); + const { store, auditStore } = await freshStore({ broker }); + + for (const toolkit of ["linear", "gmail"]) { + expect( + await store.confirmBrokeredConnection({ toolkit, userId: "user_leaver" }), + ).toEqual({ connected: true }); + } + + // The setup's own traffic, cleared so what follows is about the offboarding and nothing else. + order.length = 0; + + expect(await store.retireConnectionsFor("user_leaver", "admin")).toEqual({ + // Both of them, because the number is what "we removed their access" claims. + retired: 2, + }); + + expect(order).toEqual(["revoke:user_leaver", "revoke:user_leaver"]); + expect(revoked).toEqual(["gmail", "linear"]); + /* + * Both revokes found both rows. This is the ordering neither list above can see: revoking off + * the rows a delete had already returned would produce both of them unchanged while leaving + * nothing to revoke under if the broker refused. + */ + expect(connectedWhenAsked).toEqual([ + ["gmail", "linear"], + ["gmail", "linear"], + ]); + + expect( + await store.brokeredConnection({ toolkit: "gmail", userId: "user_leaver" }), + ).toBeNull(); + expect( + await store.brokeredConnection({ + toolkit: "linear", + userId: "user_leaver", + }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // The app, which for a brokered connection is all the row records and all that is left once + // the person is gone. + expect(disconnected.map((event) => event.targetId)).toEqual([ + "gmail", + "linear", + ]); + expect(disconnected.map((event) => event.payload)).toEqual([ + { + actor: "admin", + server: "gmail", + owner: "user_leaver", + // An administrator removing somebody, never somebody changing their own mind. + reason: "person_removed", + /* + * True, and true because the broker said so rather than because the call returned. This + * is the whole change: the row used to say `false` here whatever happened, which was + * honest only while offboarding left every account live at Composio. + */ + vendorRevoked: true, + }, + { + actor: "admin", + server: "linear", + owner: "user_leaver", + reason: "person_removed", + vendorRevoked: true, + }, + ]); +}); + +/** + * And where there was no grant left to withdraw, offboarding says so. + * + * CRITERION. A broker answering `false` leaves `vendorRevoked: false` in the trail, and the row is + * deleted and counted just the same. + * + * REASON. The account was already ended in Composio's own dashboard, so the local row is the stale + * half of a pair that has drifted. What must not happen is the trail claiming this deployment + * withdrew something: whoever reads back for who ended somebody's access is then given the wrong + * answer in the same words as the right one. + * + * THE FALSE IS THE WHOLE TEST. A pass-through is indistinguishable from a hardcoded `true` until a + * revoke answers no, and nothing else on this path exercises one. + */ +test("offboarding a brokered account nobody held any more withdraws nothing, and says so", async () => { + const { broker, order } = brokerSpy({ revoke: async () => false }); + const { store, database, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_leaver" }; + await database.insert(composioConnections).values(pair); + + expect(await store.retireConnectionsFor("user_leaver", "admin")).toEqual({ + retired: 1, + }); + + expect(order).toEqual(["revoke:user_leaver"]); + // Gone, because there was nothing at the vendor and the row was therefore the half that had + // drifted. Keeping it would leave the gate passing for a person who no longer exists. + expect(await store.brokeredConnection(pair)).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "admin", + server: "gmail", + owner: "user_leaver", + reason: "person_removed", + vendorRevoked: false, + }); +}); + +/** + * The same false, on the other act that ends a brokered connection. + * + * CRITERION. `removeServer` on a brokered row whose broker answers `false` records + * `vendorRevoked: false`, and still clears the row and drops the config. + * + * REASON. The removal test above pins the `true`, which a literal `true` in the store would pass + * just as well — and one did, for the whole of this suite, until this test. A field whose only + * purpose is to tell a grant this deployment ended from one that outlives it somewhere else is + * worth nothing if it can only ever say one of the two. + */ +test("removing an app records the grant it did not withdraw as not withdrawn", async () => { + const { broker, order } = brokerSpy({ + revoke: async () => false, + deleteAuthConfig: async () => {}, + }); + const { store, database, auditStore } = await freshStore({ broker }); + await seedComposioGmail(database, store); + + await store.removeServer("gmail", "admin"); + + expect(order).toEqual(["revoke:user_asker", "deleteAuthConfig"]); + expect( + await store.brokeredConnection({ toolkit: "gmail", userId: "user_asker" }), + ).toBeNull(); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "admin", + server: "gmail", + owner: "user_asker", + reason: "mcp_server_removed", + vendorRevoked: false, + }); +}); From 81b73bbbc7926d88b9499a8f6b8b3e5b879d98c0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:48:21 +0200 Subject: [PATCH 116/215] Let a person confirm and end their own brokered account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two routes the account cards have been calling into nothing. Both act on the caller and on nobody else: the person's id comes off the session, and neither handler reads a body or a query at all. Confirming exists so the vendor is asked. Coming back from consent is an ordinary redirect with nothing signed in it, so it proves nothing on its own — and calling this again on any page load is safe, because it heals a row that drifted in either direction. Neither is admin-gated. This is somebody acting on their own account, and an administrator has no endpoint for connecting one on somebody's behalf. One helper resolves the row and produces the two refusals for both handlers, so the sentence a person reads cannot drift into two sentences. The connect route deliberately does not share it: there, a row that is not brokered has an OAuth flow to fall through to rather than a refusal to answer with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 145 ++++++++++++++++++++++++ server/tests/plugin-routes.test.ts | 170 ++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index b71aac4db..ab1a80d0e 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -761,6 +761,151 @@ export function createPluginRoutes( }); }); + /** + * Which app one of the two routes below is about, or the refusal that ends it. + * + * Both of them act on a brokered connection and on nothing else, so both ask the same two + * questions in the same order and answer them in the same words. It is one function because the + * sentence somebody reads when they aim either route at an ordinary OAuth row should not be able + * to drift into two sentences. + * + * THE APP COMES OFF THE ROW'S URL AND NEVER OFF ITS ID, for the reason the directory route and + * the connect branch above both give: the url is where the transport reads which app a call is + * against, and the id is a row name that happens to look similar. + * + * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this + * deployment really has — what is wrong is that its connection does not live at Composio, and + * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an + * id naming no row at all, which is the same answer from the caller's side. + * + * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is + * {@link BrokerUnconfiguredError}'s own message rather than a sentence written here. + * + * The connect route's brokered branch does not come through this function, deliberately: a row + * that is not brokered has an OAuth flow below it to fall through to, so refusing there would be + * wrong. + */ + const brokeredAppFor = async ( + serverId: string, + ): Promise< + | { toolkit: string; refusal?: undefined } + | { toolkit?: undefined; refusal: { error: string; status: 400 | 503 } } + > => { + const row = (await store.listServers()).find( + (server) => server.id === serverId, + ); + const toolkit = row ? toolkitOf(row.url) : null; + if (!toolkit) { + return { + refusal: { + error: "That app is not reached through a broker.", + status: 400, + }, + }; + } + if (!composio) { + return { + refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, + }; + } + return { toolkit }; + }; + + /** + * Ask Composio whether this person's account is really attached, and write the answer down. + * + * THE ROUTE EXISTS SO THAT THE VENDOR IS ASKED. The return trip from a consent screen is an + * ordinary redirect with nothing signed in it, so a browser landing back on the settings page + * proves nothing: not that the flow finished, and not that it finished with the account a row + * would go on to claim. Composio tells this deployment nothing by itself — there is no callback + * of ours in that flow — so unless something asks, all that stands behind the gate every later + * brokered call passes through is a guess about what a redirect meant. + * + * AND IT IS MEANT TO BE CALLED AGAIN, on any page load, which is the other half of why it is + * here. The row is only a cache of the vendor's last answer, so it drifts by construction — an + * account ended in Composio's own dashboard, a consent this deployment never saw finish — and + * calling this heals it in whichever direction it went: written where the vendor says yes, + * deleted where it says no. Repeating it files no trail rows and moves no timestamps; the store + * is where that is settled. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED. An administrator adds the app once; confirming one's + * own connection to it is not an administrative act. + */ + routes.post( + "/servers/:id/connection/confirm", + requireUser, + async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * THE PERSON IS THE SESSION'S, AND THERE IS NO SECOND SOURCE FOR THEM. Nothing here reads a + * user id out of the body or the query, and that is the property rather than an + * implementation detail: a confirm writes the row every later brokered call is gated on, so + * a caller who could name somebody else would be one POST away from recording a connection + * under a person who never made one — or, the same defect turned around, from deleting the + * row of a person the vendor answers no for. + * + * The store's answer is passed straight back rather than restated here. `connected` is what + * Composio said, and a shape invented at this layer would be a second opinion about a fact + * only the vendor holds. + */ + return context.json( + await store.confirmBrokeredConnection({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + }), + ); + }, + ); + + /** + * End this person's own brokered account, at the vendor first and here after. + * + * The order is the store's and the argument for it is made there: the row is the only thing that + * says which app this person connected, so a delete that ran before the revoke could leave a live + * grant on somebody's mailbox that nothing here can reach. What comes back is what happened — + * `vendorRevoked` false is a grant that was already gone — and it is passed through rather than + * rewritten, because telling those two apart is the whole value of the field. + * + * `reason` IS "self" BECAUSE OF WHO IS ASKING. The other word the store takes is + * `person_removed`, which belongs to an administrator offboarding somebody from the People + * screen. The trail tells the two acts apart by this word and by whether `by` and the owner + * differ, and on this route they are the same person by construction. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED, for the reason confirm gives: this is somebody + * ending their own account, not an administrator ending anybody's. + */ + routes.delete("/servers/:id/connection", requireUser, async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * WHOSE ACCOUNT THIS IS COMES FROM THE SESSION, here as on confirm and for a sharper reason: a + * user id a caller could name would be a DELETE that revokes somebody else's grant at the + * vendor. It is read once, from `context.var.actor`, and used for both the owner and the actor + * — nothing in the body or the query is looked at at all. + */ + return context.json( + await store.disconnectBrokered({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + by: context.var.actor.id, + reason: "self", + }), + ); + }); + /** * Where the vendor sends somebody back. * diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index d447b9a34..867e571f2 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -897,7 +897,12 @@ function brokeredApp( const authorized: Array<{ userId: string; toolkit: string }> = []; const queried: Array<{ toolkit: string; userId: string }> = []; const confirmed: Array<{ toolkit: string; userId: string }> = []; - const disconnected: Array<{ toolkit: string; userId: string }> = []; + const disconnected: Array<{ + toolkit: string; + userId: string; + by: string; + reason: string; + }> = []; const store = { // Every read the plugins surface makes on its way to the route under test. @@ -907,6 +912,18 @@ function brokeredApp( url: "composio://linear", provenance: "composio", }, + /* + * An ordinary OAuth row, so that "this app is not brokered" is a real row and not a missing + * one. The two routes below answer the same way for both, and this is the half that would + * otherwise go untested: an id naming nothing at all is easy to refuse, while a server this + * deployment really has whose connection simply does not live at Composio is where a + * confusing answer would come from. + */ + { + id: "notion", + url: "https://notion.test/mcp", + provenance: "curated", + }, ], listSkills: async () => [], listGrants: async () => [], @@ -921,7 +938,12 @@ function brokeredApp( confirmed.push(input); return { connected: connection !== null }; }, - disconnectBrokered: async (input: { toolkit: string; userId: string }) => { + disconnectBrokered: async (input: { + toolkit: string; + userId: string; + by: string; + reason: string; + }) => { disconnected.push(input); return { vendorRevoked: true }; }, @@ -967,9 +989,42 @@ function brokeredApp( body: JSON.stringify(body), }, ), + /* + * The two routes a person's own settings page drives, with every input a caller controls left + * open: which row, what is in the body, and what is in the query. The tests below hand all + * three a user id that is not the session's, because the assertion is that none of them + * changed who was acted on. + */ + confirm: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection/confirm${options.query ?? ""}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), + disconnect: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection${options.query ?? ""}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), }; } +/** Everything a caller of those two routes gets to choose. */ +type Caller = { + /** Which server row the route is aimed at. Defaults to the brokered one. */ + serverId?: string; + body?: unknown; + /** A query string, leading `?` included. */ + query?: string; +}; + describe("connecting a brokered app", () => { test("the link is minted for the session's own person, whatever the body says", async () => { /* @@ -1040,3 +1095,114 @@ describe("connecting a brokered app", () => { expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); }); }); + +/** + * Confirming a brokered connection, and ending one. + * + * CRITERION. Both routes act on the connection of the person whose session made the request, and on + * nobody else's, whatever a body or a query says; both refuse a row that is not brokered in so many + * words; and both tell a deployment with no broker which setting to set. + * + * REASON. The store's behaviour is pinned where it is decided — what a confirm writes, what a + * disconnect revokes — so what is left here is the routing, and the routing is where the damage + * would be. A user id these handlers could take from a caller turns one DELETE into a revoke of + * somebody else's grant at the vendor and one POST into a connection recorded under a person who + * never made one. It is the defect the prior art this design follows shipped three separate times, + * and the first test of each pair hands the route a body AND a query naming somebody else so that + * reading either is a failure rather than a silent pass. + * + * Neither route is admin-gated, deliberately: an administrator adds the app once and everybody then + * manages their own account, so `requireUser` is the whole of the gate and the identity is the whole + * of the scoping. + */ +describe("confirming and ending a brokered connection", () => { + test("confirm asks about the session's own person, whatever the caller says", async () => { + const { confirmed, confirm } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await confirm({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + // The store's answer, passed through rather than restated: `connected` is what the vendor said. + expect(await response.json()).toEqual({ connected: true }); + expect(confirmed).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("disconnect ends the session's own account, whatever the caller says", async () => { + const { disconnected, disconnect } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await disconnect({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ vendorRevoked: true }); + /* + * The owner and the actor are the same person, and `reason` is the word that says why. The + * trail tells this apart from an administrator offboarding somebody by those three fields and + * by nothing else, so a route that filed `person_removed` here, or named a different owner, + * would leave a record of an act that did not happen. + */ + expect(disconnected).toEqual([ + { + toolkit: "linear", + userId: ADMIN.id, + by: ADMIN.id, + reason: "self", + }, + ]); + }); + + test("an app that is not brokered is refused for what is actually wrong", async () => { + // `notion` is a row this deployment really has. What is wrong is not that it is missing but + // that its connection does not live at Composio, and the sentence says so rather than talking + // about a broker setting or an app nobody has heard of. + const { confirmed, disconnected, confirm, disconnect } = brokeredApp(); + + const confirmResponse = await confirm({ serverId: "notion" }); + const disconnectResponse = await disconnect({ serverId: "notion" }); + + expect(confirmResponse.status).toBe(400); + expect(disconnectResponse.status).toBe(400); + expect((await confirmResponse.json()).error).toBe( + "That app is not reached through a broker.", + ); + expect((await disconnectResponse.json()).error).toBe( + "That app is not reached through a broker.", + ); + // And the store was left alone: a confirm here would write a row for an app whose connection + // is not Composio's to answer about, and a disconnect would ask the broker to revoke a grant + // it never issued. + expect(confirmed).toEqual([]); + expect(disconnected).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + // The same answer the directory and connect give, for the same reason: nobody was asked, and + // the remedy is one environment variable long. Answering "not connected" instead would draw a + // settled state over a deployment that has no broker to have connected anybody at. + const { confirmed, disconnected, confirm, disconnect } = brokeredApp( + null, + null, + ); + + const confirmResponse = await confirm(); + const disconnectResponse = await disconnect(); + + expect(confirmResponse.status).toBe(503); + expect(disconnectResponse.status).toBe(503); + expect((await confirmResponse.json()).error).toContain("COMPOSIO_API_KEY"); + expect((await disconnectResponse.json()).error).toContain( + "COMPOSIO_API_KEY", + ); + expect(confirmed).toEqual([]); + expect(disconnected).toEqual([]); + }); +}); From 2afeacbd1f2a6e63a3c3026be25419724c81c450 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:48:21 +0200 Subject: [PATCH 117/215] Stop a failed read becoming a claim about what this deployment has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new grants screen said "this deployment has not enabled an app by that name" whenever the app was absent from the list — and a list that never arrived is absent in exactly the same way. A query stops pending when it fails, not only when it answers, so a fetch that fell over told an administrator their app did not exist. The screen already got this right for the Bot ten lines below. Now both branches read as one decision: claim nothing until the answer is here, and where it never came say so instead. A failed read draws no switches, no sections and no bulk button either, because the app's title, its actions and this Bot's grants all come out of the one response that did not arrive. The guard has a test, since a guard nothing exercises is the thing that rots next: it covers the failure, the absence of everything the page would otherwise draw, that a genuine answer missing the app still says so, and that a failed refresh keeps the list it already had. The sibling per-action screen has the same defect and is left alone here. It predates this work and wants the same treatment in a change that owns that file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../admin/plugins/$key_.bots.$agentId.tsx | 33 +- app/tests/bot-app-grants-error.test.tsx | 304 ++++++++++++++++++ 2 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 app/tests/bot-app-grants-error.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx index 33569157f..713723a77 100644 --- a/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx +++ b/app/src/routes/_authed/admin/plugins/$key_.bots.$agentId.tsx @@ -144,7 +144,15 @@ function RouteComponent() { return {null}; } - if (!server) { + /* + * Gated on the plugin list having ARRIVED, not on `server` being missing — the same guard, for + * the same reason, as the roster check below, and the two are meant to be read together. + * `isPending` goes false on a failed fetch exactly as it does on a successful one, so `!server` + * alone cannot tell "this deployment has not enabled that app" apart from "the plugin list could + * not be read". Only the first is a fact about this deployment, and a request that never came + * back is no evidence for it. + */ + if (plugins.data && !server) { return ( +

+ Plugins could not be loaded. +

+
+ ); + } + + /* + * Gated on the roster having ARRIVED, not on `bot` being missing — the same guard as the app + * check above, over the other query. * * `isPending` goes false on a failed fetch exactly as it does on a successful one, so `!bot` * alone cannot tell "this deployment has no such Bot" apart from "the roster could not be read" diff --git a/app/tests/bot-app-grants-error.test.tsx b/app/tests/bot-app-grants-error.test.tsx new file mode 100644 index 000000000..b57f8cbbe --- /dev/null +++ b/app/tests/bot-app-grants-error.test.tsx @@ -0,0 +1,304 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { + type PluginServer, + type PluginsPage, + type PluginTool, + pluginKeys, +} from "@/lib/plugins/queries"; +import { Route as BotAppRoute } from "@/routes/_authed/admin/plugins/$key_.bots.$agentId"; + +/** + * What the per-Bot grant screen says when the plugin list does not come back. + * + * Same mistake, same shape, as `agent-roster-error.test.tsx`: the screen branched on the app being + * absent from `plugins.data`, and `isPending` goes false on a failed fetch exactly as it does on a + * successful one — so a request that never came back was rendered as "this deployment has not + * enabled an app by that name", about an app that may be enabled and granted right now. The Bot + * half of the same function always got this right (`agents.data && !bot`), which is what made the + * app half visible. + * + * `bot-app-grants.test.tsx` is the other half of this screen's coverage and deliberately renders + * nothing — it asserts the bulk button's set through the exported `readOnlyRefs`. This file has to + * draw, because what is under test is which of three pages the component returns, so it is its own + * file rather than a DOM smuggled into that one. + * + * THE HARNESS IS THIS REPOSITORY'S, copied from `agent-roster-error.test.tsx` for the reasons + * recorded there: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in `afterEach`, queries + * off `render()`'s own return, and a `QueryClient` with `retry: false` so a failing query settles + * in one attempt. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; + +beforeEach(() => { + // Every read in this app goes through `client()` in `lib/client.ts`, which throws once the + // response is not `ok`. A 500 with no body is the shape a broken server actually sends. + global.fetch = (async () => + new Response(null, { status: 500 })) as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +const APP_KEY = "slack"; +const BOT_ID = "bot-1"; + +/** A client the failing queries settle on in one attempt, so no test waits on a retry. */ +function failingQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +/** + * A client already holding a good plugins page under the exact key `pluginsPageQueryOptions()` + * reads, built on `failingQueryClient()`. + * + * With this file's always-failing `fetch`, mounting against it reproduces a failed BACKGROUND + * refetch: `refetchOnMount` fires because `staleTime` is 0, that fetch 500s, and `isError` goes + * true while `data` stays exactly this seeded page. That is the state the screen must keep + * rendering from — see `agent-roster-error.test.tsx`, which documents the query-core behaviour. + */ +function stalePluginsClient(page: PluginsPage) { + const queryClient = failingQueryClient(); + queryClient.setQueryData(pluginKeys.page(), page); + return queryClient; +} + +/** A minimal but complete `PluginTool`, overridable per case. */ +function tool(overrides: Partial & { name: string }): PluginTool { + return { + serverId: APP_KEY, + description: "Does something.", + inputSchema: {}, + ref: `${APP_KEY}/${overrides.name}`, + effect: "read", + destructive: false, + grantedTo: [], + ...overrides, + }; +} + +/** A minimal but complete `PluginServer`, overridable per case. */ +function server(overrides: Partial & { id: string }) { + return { + title: "Slack", + vendor: "Slack", + url: "https://example.invalid/mcp", + summary: "Chat.", + docsUrl: "https://example.invalid/docs", + provenance: "first-party", + hasCredential: true, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + tools: [], + withdrawn: [], + ...overrides, + } satisfies PluginServer; +} + +/** A minimal but complete `PluginsPage`, overridable per case. */ +function pluginsPage(overrides: Partial = {}): PluginsPage { + return { + catalogue: [], + servers: [], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: false, + ...overrides, + }; +} + +/** Waits for the seeded query to have actually failed its background refetch, rather than trusting + * that the seeded data alone (which would render identically before any fetch ran) proves it. */ +async function waitForFailedRefetch(queryClient: QueryClient) { + await waitFor(() => { + expect(queryClient.getQueryState(pluginKeys.page())?.status).toBe("error"); + }); +} + +/* + * This screen's component calls `useParams({ from: "/_authed/admin/plugins/$key_/bots/$agentId" })`, + * which resolves that id against the router's matched routes, so the tree has to produce exactly + * that id — decoy pathless/static parents are enough, as `agent-roster-error.test.tsx` establishes, + * and the real ancestors (a session check, the admin shell) are not what this file is about. + * + * `routeTree.gen.ts` fixes both halves: id `/plugins/$key_/bots/$agentId` under `/_authed/admin`, + * path `/plugins/$key/bots/$agentId` — the trailing underscore opts the route out of nesting and + * never appears in a URL. + * + * Capture and restore of the exported `Route` singleton is that file's scheme verbatim, and for its + * reason: `.update()` merges into the live object and `createRouter()` derives `_id`/`parentRoute` + * off it, so a render here would otherwise leave the real router (`router.test.ts` builds one in + * this same bun process) pointed at a decoy parent. + */ +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} + +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} + +/** Captured once, at module scope, before any `test()` body in this file has run — the state this + * file must hand back, whatever it happens to be. See `agent-roster-error.test.tsx`. */ +const pristineBotAppRouteState = captureRouteState(BotAppRoute); + +let botAppRouteSnapshot: Record; + +beforeEach(() => { + botAppRouteSnapshot = captureRouteState(pristineBotAppRouteState); +}); + +afterEach(() => { + restoreRouteState(BotAppRoute, botAppRouteSnapshot); +}); + +function renderScreen(queryClient: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + // The two Back links this screen can draw. Registered so `Link` has a real route to build an + // href from; neither is navigated to here. + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const appRoute = createRoute({ + path: "/plugins/$key", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + BotAppRoute as unknown as { + update: (options: unknown) => typeof BotAppRoute; + } + ).update({ + id: "/plugins/$key_/bots/$agentId", + path: "/plugins/$key/bots/$agentId", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([ + adminRoute.addChildren([pluginsRoute, appRoute, wired]), + ]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/admin/plugins/${APP_KEY}/bots/${BOT_ID}`], + }), + }); + return render( + + + , + ); +} + +test("a failed plugin read reports the failure instead of claiming the app is not enabled", async () => { + const view = renderScreen(failingQueryClient()); + + expect(await view.findByText("Plugins could not be loaded.")).toBeTruthy(); + + // The whole point: an app that may be enabled, with grants on it right now, must never be + // reported as absent on the evidence of a request that never came back. + expect( + view.queryByText("This deployment has not enabled an app by that name."), + ).toBeNull(); + expect(view.queryByText("There is nothing here to grant.")).toBeNull(); +}); + +test("a failed plugin read draws no switches, so nothing reads as a Bot holding nothing", async () => { + const view = renderScreen(failingQueryClient()); + + await view.findByText("Plugins could not be loaded."); + + expect(view.container.querySelectorAll('[role="switch"]').length).toBe(0); + expect(view.queryByText("Turn on every read-only action")).toBeNull(); + // The two section headings the grant list is drawn under: neither belongs on a page built from + // an answer that never arrived. + expect(view.queryByText("Reads")).toBeNull(); + expect(view.queryByText("Changes things")).toBeNull(); +}); + +test("an answer that genuinely lacks the app still says the app is not enabled", async () => { + // Seeded, so `plugins.data` is a real answer; the refetch on mount still fails, which is what + // separates "the list came back without it" from "the list never came back". + const queryClient = stalePluginsClient(pluginsPage()); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + + expect( + await view.findByText( + "This deployment has not enabled an app by that name.", + ), + ).toBeTruthy(); + expect(view.queryByText("Plugins could not be loaded.")).toBeNull(); +}); + +test("a failed REFETCH keeps the grant list it already had, not the error", async () => { + const queryClient = stalePluginsClient( + pluginsPage({ + servers: [ + server({ + id: APP_KEY, + tools: [tool({ name: "list_channels" })], + }), + ], + }), + ); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + + expect(await view.findByText("list_channels")).toBeTruthy(); + expect(view.queryByText("Plugins could not be loaded.")).toBeNull(); + expect( + view.queryByText("This deployment has not enabled an app by that name."), + ).toBeNull(); +}); From fd8526f9b8cadd993a29ced1241d23650854b495 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 05:52:36 +0200 Subject: [PATCH 118/215] Let a screen read what an action destroys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column has been recorded since the transport landed and reached nothing: the store built its tool objects without the field, so the key was simply absent from the answer and every screen drew a delete exactly like any other write. The grants screen already draws a danger mark from it. Until now that mark could never appear, because the value behind it was structurally false rather than false in fact. This is the line that makes it mean something. Beside the effect rather than folded into it. The rule engine judges reads and writes and gains nothing from a third value, while a person deciding whether to switch an action on is asking a different question — and an action that does not claim to destroy something is not claiming to be safe, so false is an absence of a claim. An app that has not been refreshed since the vendor's labels started being recorded still reports all-false until its next refresh. Nothing is backfilled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 10 +++++ server/tests/plugin-store.integration.test.ts | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index f95ff1f6d..ef804ab4d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -95,6 +95,15 @@ export type ToolRecord = { /** `/`. What a grant names and what the model's tool name is derived from. */ ref: string; effect: "read" | "write"; + /** + * Whether the vendor warns that this action destroys something. + * + * Beside {@link ToolRecord.effect} rather than folded into it: the rule engine judges reads and + * writes and gains nothing from a third value, while a person deciding whether to switch an action + * on is asking a different question. Recorded from the vendor's own labels, so false is an absence + * of a claim rather than a claim of safety. + */ + destructive: boolean; grantedTo: string[]; }; @@ -3076,6 +3085,7 @@ export function createPluginStore(options: PluginStoreOptions) { inputSchema: tool.inputSchema as Record, ref, effect: classifyTool(entry, tool.name, true, tool.effect), + destructive: tool.destructive, grantedTo: grants.get(ref) ?? [], }; }), diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index da00bdf99..320db479e 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6855,3 +6855,43 @@ test("removing an app records the grant it did not withdraw as not withdrawn", a vendorRevoked: false, }); }); + +/** + * The vendor's destructive label, carried out of the database to the screens that draw it. + * + * CRITERION. A `mcp_tools` row Composio labelled destructive reaches `listServers` as + * `destructive: true`, and every tool in the list carries the field as a boolean. + * + * REASON. The column has been recorded since the brokered transport landed and reached nothing: the + * store selected the row and then built a tool object without the field, so a delete read out + * identically to any other write on every screen. The per-Bot grants screen already draws its danger + * mark from `PluginTool.destructive`, which could only ever be false while the mapping was missing — + * a mark that cannot turn on is worse than no mark, because it reads as an assurance. The second + * assertion is the half a `true` alone would not hold: the field must be present on the ordinary read + * beside it, not only on the row that happens to be dangerous. + */ +test("a destructive action says so in the list the screens read", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + await database.insert(mcpTools).values({ + serverId: "gmail", + name: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + effect: "write", + destructive: true, + }); + + const gmail = (await store.listServers()).find( + (server) => server.id === "gmail", + ); + + const deletes = gmail?.tools.find( + (tool) => tool.name === "GMAIL_DELETE_MESSAGE", + ); + expect(deletes?.destructive).toBe(true); + // Not only on the dangerous row: the reader asks every tool the same question, and an undefined + // on the read beside it is a screen with nothing to draw rather than a screen drawing "safe". + expect(gmail?.tools.map((tool) => typeof tool.destructive)).toEqual( + gmail?.tools.map(() => "boolean"), + ); +}); From 00033a5aad598f3792d7cc03efc87784d4005221 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:11:24 +0200 Subject: [PATCH 119/215] Bring the person back to this deployment after they consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link minted for consent carried no return address, so somebody who connected an account landed on the broker's own page and had to find their way back. The documentation said otherwise and the design's confirm-on-return step assumed a round trip; only the confirm-on-load half was holding it together. THE CALL THAT MINTS THE LINK CHANGED, and it had to. The one the plan named takes no callback at any position, and is a wrapper that resolves an authorization config we already create at enable time and then hardcodes permission for several accounts per person — the opposite of the rule this connector states. Worse, the call underneath it is retired for a Composio-managed config on a redirectable scheme, with both cutover dates already past, so it would have thrown the first time anybody pressed Connect. The replacement is the vendor's own named successor: it carries the callback, and it keeps the one-account rule as a second line behind this deployment's own refusal. THE ADDRESS IS BUILT HERE AND A CALLER HAS NO SAY IN IT. Whoever names it names where a person lands holding a just-completed consent, so reading one off a body, a query or a header would be an open redirect with a consent screen in front of it — which is why the OAuth flow beside this one narrows its destination to two names rather than accepting a url. The test hands the route an address in all three places at once, so a route that read any of them fails on the two it ignored. A deployment with no app url configured refuses rather than minting a link that strands somebody mid-consent, the same way the OAuth path refuses without a public url. Two smaller things ride along. The refusal for a second connection names the app rather than the row's internal id. And a failure reported by the broker now reaches a person as the vendor's own extracted sentence — a missing key as the setting to set, anything else as a bad gateway — where before every one of them was a bare five hundred with the vendor's whole response, headers and trace ids included, on the console. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- docs/configuration.md | 2 + docs/plugins/composio.md | 42 ++- server/src/plugins/broker.ts | 16 ++ server/src/plugins/composio-adapter.ts | 82 +++++- server/src/plugins/routes.ts | 235 ++++++++++++++--- server/tests/composio-adapter.test.ts | 100 ++++++- server/tests/plugin-routes.test.ts | 343 ++++++++++++++++++++++++- 7 files changed, 762 insertions(+), 58 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index eae427254..90fd4a871 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -226,6 +226,8 @@ where `` is `google`, `microsoft` or `okta`. `OPENBOT_APP_URL` is where the callback sends the person afterwards. It is a separate setting because the app and the API are separate addresses: locally the app is Vite on `3010` and the API is `3001`, so a relative redirect would land on the API, which serves no pages. A deployment serving both from one origin can leave it unset. +A [Composio](plugins/composio.md) app needs `OPENBOT_APP_URL` and nothing else of the two: the consent lives at the broker, so no redirect URI of ours is registered anywhere, but the address Composio returns somebody to has to be absolute and this is where it comes from. Connecting a brokered account refuses where it resolves to nothing, rather than sending somebody to a consent screen with no way back. + ## One Bot handing work to another | Variable | Meaning | diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 9ed29ab6e..ea1a78010 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -57,11 +57,16 @@ accidental. from a caller and was written into a row's url would become the app every future call runs in. **The authorization config is created here, not on somebody's first click, and it is created -first.** Left to itself, the link-minting call would create one on demand, at Composio's managed -defaults, the first time any person pressed Connect. Creating it at enable time, named for this -deployment, makes it an object an operator can see in their Composio dashboard from the moment the -app exists — and tighten there, without a code change. It comes before anything is written here, so -a failure leaves no row behind and pressing the button again is the whole recovery. +first.** The SDK's own one-call shortcut would have made one on demand, at Composio's managed +defaults and under a name of its choosing, the first time any person pressed Connect. Creating it at +enable time, named for this deployment, makes it an object an operator can see in their Composio +dashboard from the moment the app exists — and tighten there, without a code change. It comes before +anything is written here, so a failure leaves no row behind and pressing the button again is the +whole recovery. + +Connecting an account is then a link minted **against that config**, which is why nothing mints a +config later: an app whose config was deleted at the dashboard refuses at Connect, naming the +administrator's step, rather than quietly acquiring a second one that nobody here named or can find. Then one ordinary `mcp_servers` row — id `composio-`, url `composio://`, provenance `composio`, vendor Composio, title from the directory, and no credential of any kind — and then the @@ -89,7 +94,18 @@ never as green. At `/settings/connected-accounts`, a brokered app appears beside the OAuth connectors once an administrator has enabled it. Open it and press **Connect**. That leaves OpenBot for Composio's -consent screen and returns to the same page. +consent screen and returns to the same page — or, for an administrator who started from the app's +own page under `/admin/plugins`, back to that page, because leaving a page mid-task and being +returned to a different one is the round trip this exists to remove. + +**The address they come back to is built here, and a caller has no say in it.** It is this +deployment's `OPENBOT_APP_URL` plus one of two known pages, so what a request can choose is which +page and never which site: an address taken from a body or a query would be an open redirect with a +consent screen in front of it, which is the same reason this deployment's own OAuth flow narrows its +`returnTo` to a name. A deployment with no app URL configured has no absolute address to hand over +— the consent screen is on Composio's origin, so a relative one resolves against theirs — and +**Connect** refuses there, naming the setting, rather than minting a link that would strand somebody +on Composio's page having just granted access to their mailbox. **The link is yours alone.** It is minted for the session's own id and can be asked for on nobody else's behalf. It is a bearer capability — whoever opens it binds *their* account to the id it was @@ -101,13 +117,21 @@ signed in it, so the row that lets calls through is written only after Composio is live. The page asks on return, and asks again on load, so a row that drifted heals. Composio is the source of truth and the row here is a cache of it. +**A failure at Composio arrives as Composio's own sentence.** A wrong key, a revoked one, an app +whose authorization config was deleted at the dashboard: each of those comes back as the one +sentence the vendor wrote — *Invalid API key provided.* — with everything that travelled beside it +left where it was. The key never appears anywhere, nor the connect link, nor the vendor's thrown +object, which is an entire HTTP response including headers and trace ids. Where the vendor reported +a failure and said nothing about it, the sentence is this deployment's own and names the step to +take rather than echoing a placeholder. + **One account per person per app, and the second is refused.** Composio would happily hold several accounts for one person and one app, but the call that runs an action names the person and not the account — so with two Gmail accounts connected, which mailbox a Bot reads would be Composio's choice, and neither the table here nor the audit row could say which one it was. Connect therefore -refuses while a live connection for that app already exists, and names the way to switch: *You have -already connected Slack. To use a different account, disconnect this one first.* It is per app and -nothing more — Gmail and Linear and Notion connected alongside each other are untouched. +refuses while a live connection for that app already exists, and names the way to switch: *You +already have an account connected to Slack. Disconnect it first if you want to connect a different +one.* The app's own title, never the row's id. It is per app and nothing more — Gmail and Linear and Notion connected alongside each other are untouched. ### Disconnecting diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 1fe25296f..e0da3fce9 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -75,6 +75,22 @@ export type ComposioBroker = { authorize(request: { userId: string; toolkit: string; + /** + * Where the vendor sends this person once the consent screen is done with them. + * + * REQUIRED, BECAUSE A CONSENT WITH NO RETURN LEG STRANDS SOMEBODY. Without it the flow ends on + * Composio's own hosted page: the person has consented, nothing here knows it, and the only + * way back is for them to find this deployment again by hand. An optional field would have + * made that the default for whichever call site forgot to pass one, which is exactly the state + * this parameter exists to end. + * + * IT IS AN ADDRESS THIS DEPLOYMENT BUILT AND NEVER ONE A CALLER CHOSE. Whoever names it names + * where a person lands holding a just-completed consent, so a value taken from a request body, + * a query or a header would be an open redirect with a consent screen in front of it. The one + * caller builds it from the deployment's configured app URL and narrows the page within it to + * a known name, the same way this repository's own OAuth `returnTo` is narrowed. + */ + returnUrl: string; }): Promise<{ redirectUrl: string }>; /** Whether this person currently has an account attached to this app at the vendor. */ isConnected(request: { userId: string; toolkit: string }): Promise; diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index bf6edc6b5..3d4208b6c 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -112,10 +112,6 @@ export type ComposioVendor = { }; toolkits: { get(query: { limit: number; sortBy: "usage" }): Promise; - authorize( - userId: string, - toolkit: string, - ): Promise<{ redirectUrl?: string | null }>; }; authConfigs: { list(query: { @@ -142,6 +138,34 @@ export type ComposioVendor = { statuses: "ACTIVE"[]; limit: number; }): Promise<{ items: { id: string }[] }>; + /** + * Mint one person's connect link against one auth config, with the page to come back to. + * + * `link` RATHER THAN `initiate`, AND RATHER THAN `toolkits.authorize`. All three end at a + * redirect url, and only the choice between them decides whether this deployment keeps working. + * `toolkits.authorize` takes a user id, a toolkit and an optional auth config id and has no + * parameter for a callback at all (`@composio/core` 0.18.1, `src/models/Toolkits.ts:333-338`), + * which is why consent used to end on Composio's hosted page — the address below has nowhere + * to travel on that call. `initiate` does carry one (`:249`), but the endpoint under it is + * retired for Composio-managed OAuth on redirectable schemes — cutover 2026-05-08 for new + * organizations and 2026-07-03 for the rest, after which it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`src/models/ConnectedAccounts.ts:146-160`) + * — and `use_composio_managed_auth` is exactly what {@link ComposioBroker.ensureAuthConfig} + * creates. `link` is the vendor's own named replacement for that combination, carries the + * callback, and answers in the same shape. + * + * TAKING THE AUTH CONFIG ID IS NOT A COST HERE. It is the one thing `toolkits.authorize` was + * doing for us, and it did it by listing the configs and creating one at Composio's managed + * defaults where it found none — which this deployment already does for itself, at enable + * time, named so an operator can find it in their dashboard. The listing below is that same + * read; the creation is not repeated, because an app with no config is a state to report + * rather than one to paper over. + */ + link( + userId: string, + authConfigId: string, + options: { callbackUrl: string }, + ): Promise<{ redirectUrl?: string | null }>; delete(id: string): Promise; }; }; @@ -361,8 +385,54 @@ export function buildComposioClient(vendor: ComposioVendor): { await vendor.authConfigs.delete(config.id); }, - async authorize({ userId, toolkit }): Promise<{ redirectUrl: string }> { - const request = await vendor.toolkits.authorize(userId, toolkit); + async authorize({ + userId, + toolkit, + returnUrl, + }): Promise<{ redirectUrl: string }> { + /* + * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. + * + * `ensureAuthConfig` creates it when an administrator enables the app, which is what makes + * this a read. Creating one here instead would mint it at the moment somebody presses + * Connect, unnamed for this deployment and invisible in the dashboard until the first person + * happened to try — and where a config already existed for an app enabled twice, a second + * one would split one app's connections across two configs, so removing "the" config later + * would drop half of them. + * + * NONE IS A STATE WITH A REMEDY, NOT A NULL TO WORK AROUND. It is the app enabled before + * this deployment created configs at all, or a config deleted by hand in Composio's + * dashboard. Neither is something a person pressing Connect can fix, so the sentence names + * the app and the administrator's step rather than leaving them at a link that would attach + * their account to a configuration nobody here chose. + */ + const [config] = await authConfigsFor(toolkit); + if (!config) { + throw new Error( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + /* + * THE RETURN ADDRESS IS THE WHOLE POINT OF THIS CALL, and it is the caller's rather than + * this file's: the adapter knows Composio, and where a person belongs afterwards is a fact + * about this deployment's own pages. It is never logged and never quoted in the refusal + * below, for the reason the url itself is not. + * + * NO `allowMultiple`, WHICH LEAVES THE VENDOR ENFORCING THE RULE THIS DEPLOYMENT ALREADY + * STATES. One person holds one account per app here, because the call that runs an action + * names the person and not the account — so with two accounts attached, which mailbox a Bot + * reads would be Composio's choice and nothing here could say which one it had been. The + * route refuses a second connection before it ever reaches this method, and that refusal is + * the sentence a person reads; this is the same rule one layer further down, where the + * vendor is the only party that can still see an account this deployment's rows have lost + * track of. `toolkits.authorize` passed `allowMultiple: true` unconditionally — the SDK + * calls it a "magic function" for exactly that — which is the opposite of what this + * deployment wants. + */ + const request = await vendor.connectedAccounts.link(userId, config.id, { + callbackUrl: returnUrl, + }); const redirectUrl = request.redirectUrl; if (!redirectUrl) { /* diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index ab1a80d0e..0bcbfce97 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3,11 +3,17 @@ import { Hono } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; -import { BrokerUnconfiguredError, type ComposioBroker } from "./broker"; +import { + type BrokerApp, + BrokerUnconfiguredError, + brokerSentence, + type ComposioBroker, +} from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; -import { toolkitOf } from "./composio"; +import { toolkitOf, vendorSentence } from "./composio"; import { authorizationUrlFor, + type ConnectOrigin, challengeFor, connectedAccountsUrlFor, createVerifier, @@ -40,6 +46,52 @@ import { */ export type ConnectingPersonCheck = (userId: string) => Promise; +/** + * What somebody is told when the broker itself failed, and what is deliberately kept out of it. + * + * EVERY BROKERED ROUTE NEEDS THIS AND NONE OF THEM USED TO HAVE IT. A listing, a connect, a confirm + * and a disconnect all end in a call to another company's API, and an unhandled rejection out of any + * of them is a bare 500 with the vendor's thrown object on this deployment's console. That object is + * the whole HTTP response — headers, trace ids, rate-limit counters — and the person reading the 500 + * gets none of it and no sentence either. A wrong `COMPOSIO_API_KEY` is the first failure a new + * operator meets, and it is the one this used to answer worst. + * + * `vendorSentence` IS THE SAME ONE THE TRANSPORT USES, for the same reason it exists there: the + * useful sentence — "Invalid API key", "No connected account found for user …" — is nested two + * levels inside `cause` beside everything that must not be shown, so this reaches in for that one + * string and takes nothing else. Null from it is a failure this deployment cannot explain, and the + * caller's own generic sentence is what a reader gets instead of the vendor's placeholder. + * + * NOTHING FROM THE REQUEST OR THE ANSWER TRAVELS WITH IT. Not the API key, which never leaves the + * adapter; not a connect link, which is a bearer capability handed to one browser; and not the + * thrown object, whether by spreading it, stringifying it or logging it. + * + * 502 RATHER THAN 500, because nothing here broke: this deployment asked a third party and the third + * party did not answer usefully, which is the same reading the dynamic-registration failure below + * already gives. {@link BrokerUnconfiguredError} is the one exception and keeps the 503 every other + * no-broker answer on these routes uses — nobody was asked at all, and the remedy is one environment + * variable long. + */ +function brokerRefusal( + error: unknown, + generic: string, +): { error: string; status: 502 | 503 } { + const unconfigured = brokerSentence(error); + if (unconfigured) return { error: unconfigured, status: 503 }; + return { error: vendorSentence(error) ?? generic, status: 502 }; +} + +/** + * What a reader is told when Composio would not answer with its directory. + * + * One constant because two routes make that call — browsing the catalogue and enabling an app out of + * it — and a reader meeting the same failure through two doors should not meet two sentences. It + * names the setting rather than quoting it: a key that was pasted with a space in it, or one for + * another project, is the likeliest reason Composio will not talk to this deployment at all. + */ +const DIRECTORY_UNAVAILABLE = + "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; + /** * The Plugins surface: what this deployment has added, and which Bots may use it. * @@ -452,7 +504,15 @@ export function createPluginRoutes( ); } - const directory = await composio.broker.listApps(); + let directory: BrokerApp[]; + try { + directory = await composio.broker.listApps(); + } catch (error) { + // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis + // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. + const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + return context.json({ error: refusal.error }, refusal.status); + } const term = (context.req.query("q") ?? "").trim().toLowerCase(); const matched = term ? directory.filter((app) => @@ -500,11 +560,22 @@ export function createPluginRoutes( slug?: string; } | null; const slug = body?.slug?.trim(); - const app = slug - ? (await composio.broker.listApps()).find( - (candidate) => candidate.slug === slug, - ) - : 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( { @@ -621,7 +692,7 @@ export function createPluginRoutes( (server) => server.id === serverId, ); const toolkit = row ? toolkitOf(row.url) : null; - if (toolkit) { + if (row && toolkit) { if (!composio) { return context.json( { error: new BrokerUnconfiguredError().message }, @@ -629,6 +700,31 @@ export function createPluginRoutes( ); } + /* + * NO APP URL IS A REFUSAL, NOT A LINK WITH NO WAY BACK. + * + * The address below is where Composio sends this person once they have consented, and it has + * to be absolute: the consent screen is on another company's origin, so a relative path + * resolves against theirs. A deployment that cannot say where its own pages are cannot + * produce one — and minting the link anyway would leave somebody stranded on Composio's + * hosted page having just granted access, with no route back to the deployment that asked + * for it and nothing here knowing it happened. + * + * The OAuth flow below refuses for its missing `OPENBOT_PUBLIC_URL` in these same terms and + * for this same reason. `OPENBOT_APP_URL` is the setting here because the two addresses are + * genuinely different: the API is one origin and the browser app is another, and it is a + * page this person is coming back to rather than an endpoint. + */ + if (!connect?.appUrl) { + return context.json( + { + error: + "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", + }, + 503, + ); + } + /* * THE PERSON IS THE SESSION'S, HERE AND IN THE READ ABOVE IT. * @@ -644,27 +740,66 @@ export function createPluginRoutes( userId: context.var.actor.id, }); if (existing) { - // Named with the step to take rather than only refused: a second link would attach a - // second account behind a row that already says connected, and the way to a new one is - // through the connection they have. + /* + * Named with the step to take rather than only refused: a second link would attach a + * second account behind a row that already says connected, and the way to a new one is + * through the connection they have. + * + * THE APP'S TITLE, NOT THE ROW'S ID. `composio-linear` is this deployment's name for a + * table row; "Linear" is the name of the thing the person connected and the only one of + * the two they have ever seen on a screen. An internal key in a sentence addressed to a + * person is both unhelpful and a small leak of how the rows are keyed. + */ return context.json( { - error: `You already have an account connected to ${serverId}. Disconnect it first if you want to connect a different one.`, + error: `You already have an account connected to ${row.title}. Disconnect it first if you want to connect a different one.`, }, 409, ); } + /* + * WHERE THE CONSENT COMES BACK TO, BUILT HERE AND NEVER READ OFF THE REQUEST. + * + * Composio sends the person to this address when they are done, so whoever chooses it + * chooses where somebody lands holding a just-completed consent. A url taken from the body, + * the query or a header would therefore be an open redirect with a consent screen in front + * of it — the exact thing {@link ConnectOrigin} exists to stop on the OAuth flow below, and + * it is narrowed here in the same way: the caller may name one of two PAGES, and the origin + * underneath them is this deployment's configured app URL in both cases. + * + * Both pages confirm on load, which is what makes either of them a correct destination: the + * return trip carries nothing signed, so arriving proves nothing, and the page asks Composio + * whether the account is really attached before anything here says it is. + */ + const returnTo: ConnectOrigin = + context.req.query("returnTo") === "admin" ? "admin" : "settings"; + /* * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to this person's * connection, so it is answered to the browser that asked and to nothing else: not logged, * not audited, not put in an error body. A redirect url in a log line is somebody else's - * mailbox for as long as it stays valid. + * mailbox for as long as it stays valid — which is why the failure below answers with the + * vendor's sentence and never with what was being minted when it failed. */ - const { redirectUrl } = await composio.broker.authorize({ - userId: context.var.actor.id, - toolkit, - }); + let redirectUrl: string; + try { + ({ redirectUrl } = await composio.broker.authorize({ + userId: context.var.actor.id, + toolkit, + returnUrl: connectedAccountsUrlFor( + connect.appUrl, + { serverId }, + returnTo, + ), + })); + } catch (error) { + const refusal = brokerRefusal( + error, + `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } return context.json({ authorizationUrl: redirectUrl }); } @@ -855,12 +990,30 @@ export function createPluginRoutes( * Composio said, and a shape invented at this layer would be a second opinion about a fact * only the vendor holds. */ - return context.json( - await store.confirmBrokeredConnection({ - toolkit: resolved.toolkit, - userId: context.var.actor.id, - }), - ); + try { + return context.json( + await store.confirmBrokeredConnection({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + }), + ); + } catch (error) { + /* + * A BROKER THAT WOULD NOT ANSWER IS NOT A CONNECTION THAT IS ABSENT. + * + * This route is called on every page load, so the tempting answer to a failure is + * `{ connected: false }` — and that would be this deployment inventing a fact only Composio + * holds, drawing "Not connected" over a live account and, one step on, deleting the row + * that says otherwise. The store deletes on a NO from the vendor, and a failure is not a + * no. So the page is told the ask failed, and what it goes on showing is the last answer + * Composio gave rather than a guess about this one. + */ + const refusal = brokerRefusal( + error, + "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } }, ); @@ -896,14 +1049,32 @@ export function createPluginRoutes( * vendor. It is read once, from `context.var.actor`, and used for both the owner and the actor * — nothing in the body or the query is looked at at all. */ - return context.json( - await store.disconnectBrokered({ - toolkit: resolved.toolkit, - userId: context.var.actor.id, - by: context.var.actor.id, - reason: "self", - }), - ); + try { + return context.json( + await store.disconnectBrokered({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + by: context.var.actor.id, + reason: "self", + }), + ); + } catch (error) { + /* + * REPEATING IT IS THE RECOVERY, AND THE SENTENCE SAYS SO RATHER THAN GUESSING HOW FAR IT GOT. + * + * The revoke runs before anything here is deleted, which is what makes a second press safe: + * whatever this failed at, the state it leaves is access dead or access untouched, never + * access live with nothing here able to reach it. Claiming "nothing was changed" would be a + * guess — a delete that succeeded and an audit write that did not is the same throw — and the + * one thing a person needs is the button to press, not this deployment's theory of where it + * stopped. + */ + const refusal = brokerRefusal( + error, + "Composio would not end this account, and gave no reason. Press Disconnect again: the revoke at Composio runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } }); /** diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index e516b8718..9b6a6e6dc 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -48,7 +48,6 @@ function fakeVendor(parts: { }, toolkits: { get: refuse("toolkits.get"), - authorize: refuse("toolkits.authorize"), ...parts.toolkits, }, authConfigs: { @@ -59,6 +58,7 @@ function fakeVendor(parts: { }, connectedAccounts: { list: refuse("connectedAccounts.list"), + link: refuse("connectedAccounts.link"), delete: refuse("connectedAccounts.delete"), ...parts.connectedAccounts, }, @@ -217,3 +217,101 @@ describe("executing an action", () => { expect(executed).toEqual([]); }); }); + +/** + * Minting one person's connect link, which is the call that decides whether consent comes back. + * + * `connectedAccounts.link` RATHER THAN `toolkits.authorize`, and the difference is the whole + * subject of these two tests. `toolkits.authorize` takes a user id, a toolkit and an optional auth + * config id and has nowhere to put a callback, so every consent it started ended on Composio's own + * hosted page: the person had granted access and the only way back to this deployment was to find + * it again by hand. `link` carries the callback, and it is also the vendor's own named replacement + * for `initiate` on Composio-managed OAuth, which is exactly what `ensureAuthConfig` creates here. + * + * The auth config is READ rather than created, because this deployment already made it when an + * administrator enabled the app — named for this deployment, visible in an operator's dashboard. + * `toolkits.authorize` would have created one on demand at Composio's defaults, which is the + * behaviour enabling-time creation exists to replace. + */ +describe("beginning one person's connection", () => { + test("the link carries the page this deployment sends them back to", async () => { + const linked: unknown[] = []; + const listed: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => { + listed.push(query); + return { + items: [{ id: "ac_this_deployments", name: "Linear (OpenBot)" }], + }; + }, + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const begun = await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }); + + // The config this deployment already holds for the app, found by the listing the key itself + // scopes — and no `authConfigs.create`, which would refuse in `fakeVendor` if it were reached. + expect(listed).toEqual([{ toolkit: "linear", limit: LISTING_LIMIT }]); + expect(linked).toEqual([ + [ + "user_1", + "ac_this_deployments", + { + callbackUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }, + ], + ]); + expect(begun).toEqual({ + redirectUrl: "https://backend.composio.dev/s/a-link", + }); + }); + + test("an app with no auth config is a refusal naming an administrator's step", async () => { + /* + * The state is real rather than defensive: an app enabled before this deployment created + * configs at all, or a config deleted by hand in Composio's dashboard. Creating one here + * instead would mint it unnamed, at the vendor's managed defaults, at the moment somebody + * pressed Connect — and nothing would be minted for the person to visit either way, so the + * honest answer names the app and the step that fixes it. + */ + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [] }) }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: + "https://openbot.test/settings/connected-accounts/composio-linear", + }); + + await expect(refused).rejects.toThrow(/linear/); + // And nothing was begun at the vendor: a link against a config chosen by nobody would attach + // this person's account to a configuration this deployment cannot see or tighten. + expect(linked).toEqual([]); + }); +}); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 867e571f2..80198c699 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -746,6 +746,60 @@ describe("the Composio directory", () => { expect(posted.status).toBe(403); expect(added).toEqual([]); }); + + test("a vendor failure is Composio's own sentence on both doors, not a 500", async () => { + /* + * THE WRONG KEY IS WHERE THIS FAILS FIRST, and nothing on either route caught anything: the + * administrator who had just pasted a key was shown a bare 500 and the vendor's whole response + * object went to the console, headers and trace id included. + * + * Both routes make the same call, so both answer in the same words. The POST is the one that + * mattered most: it is pressed by somebody who has just set the key and is waiting to hear + * whether it works. + */ + const failing = async (): Promise => { + throw WRONG_KEY; + }; + + const listed = await directoryApp(failing).app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + const added = await directoryApp(failing).app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + const listedBody = await listed.text(); + const addedBody = await added.text(); + + expect(listed.status).toBe(502); + expect(added.status).toBe(502); + expect(JSON.parse(listedBody).error).toBe("Invalid API key provided."); + expect(JSON.parse(addedBody).error).toBe("Invalid API key provided."); + expect(listedBody).not.toContain("req_a_trace_id_nobody_should_read"); + expect(addedBody).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a failure the vendor did not explain names the setting to check", async () => { + // Null from `vendorSentence` leaves the route to say something itself, and what it says is the + // one thing an operator meeting this can act on: the key. Never the thrown object's own text, + // which on this path is as likely to be a stack frame as a sentence. + const { app } = directoryApp(async () => { + throw new Error("fetch failed"); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + expect(response.status).toBe(502); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("COMPOSIO_API_KEY"); + expect(refusal).not.toContain("fetch failed"); + }); }); /** @@ -876,6 +930,41 @@ describe("a person's own connections", () => { */ const AUTHORIZATION_URL = "https://backend.composio.dev/s/a-bearer-capability"; +/** + * Where this deployment tells Composio to send somebody back to, written out rather than composed. + * + * BUILT FROM THE DEPLOYMENT AND FROM NOTHING IN THE REQUEST, which is the property the tests below + * are about. The origin is `testEnvironment`'s own — no `OPENBOT_APP_URL` is set, so it falls back + * through to `BETTER_AUTH_URL` — and the path is the account's page, which is where the person + * pressed Connect and the page that asks Composio whether it worked. + * + * Two of them because a caller may name one of two PAGES and nothing else: an administrator who + * started this from the app's own admin screen comes back to that screen. A literal at each + * assertion rather than a call to `connectedAccountsUrlFor`, because an assertion that builds the + * expected value the way the code does cannot fail when the code's answer changes. + */ +const RETURN_URL = + "http://localhost:3001/settings/connected-accounts/composio-linear"; +const ADMIN_RETURN_URL = "http://localhost:3001/admin/plugins/composio-linear"; + +/** + * A failure in the shape `vendorSentence` reaches into, with the vendor's sentence at the bottom. + * + * The sentence the vendor actually sends for the failure a new operator meets first, nested exactly + * as `@composio/core` nests it: two levels inside `cause`, beside the whole HTTP response. The + * `headers` and `requestId` beside it are the point of this fixture — they are what must not come + * back out. + */ +const WRONG_KEY = Object.assign(new Error("Request failed"), { + cause: { + error: { + error: { message: "Invalid API key provided." }, + headers: { "x-request-id": "req_a_trace_id_nobody_should_read" }, + }, + status: 401, + }, +}); + /** * One brokered app, added, and the person connecting their own account to it. * @@ -893,8 +982,25 @@ function brokeredApp( connection: { connectedAt: string } | null = null, /** Null is a deployment with no COMPOSIO_API_KEY, which is the shipped default. */ redirectUrl: string | null = AUTHORIZATION_URL, + /** + * The deployment as it is when something about it is what is under test. + * + * Every field is absent for the flows that work, which is most of this file. A failure needs a + * deployment that fails in one named way: a broker that throws where the vendor would have, a + * store whose brokered calls throw for the same reason one layer down, or an environment with no + * app URL to send anybody back to. + */ + deployment: { + authorizeThrows?: unknown; + storeThrows?: unknown; + environment?: Record; + } = {}, ) { - const authorized: Array<{ userId: string; toolkit: string }> = []; + const authorized: Array<{ + userId: string; + toolkit: string; + returnUrl: string; + }> = []; const queried: Array<{ toolkit: string; userId: string }> = []; const confirmed: Array<{ toolkit: string; userId: string }> = []; const disconnected: Array<{ @@ -909,6 +1015,9 @@ function brokeredApp( listServers: async () => [ { id: "composio-linear", + // The app's name, which is the one of the two a person has ever seen. The refusal below + // reads it off the row, and the id beside it is what that refusal used to quote instead. + title: "Linear", url: "composio://linear", provenance: "composio", }, @@ -921,6 +1030,7 @@ function brokeredApp( */ { id: "notion", + title: "Notion", url: "https://notion.test/mcp", provenance: "curated", }, @@ -936,6 +1046,9 @@ function brokeredApp( userId: string; }) => { confirmed.push(input); + // Thrown from where the store asks the broker, because that is where it throws in the + // product: `confirmBrokeredConnection` calls `isConnected` and catches nothing. + if (deployment.storeThrows) throw deployment.storeThrows; return { connected: connection !== null }; }, disconnectBrokered: async (input: { @@ -945,12 +1058,15 @@ function brokeredApp( reason: string; }) => { disconnected.push(input); + // The revoke comes before the delete, so a throw here is the product's own ordering: the + // account is still live at the vendor and the row is still here. + if (deployment.storeThrows) throw deployment.storeThrows; return { vendorRevoked: true }; }, }; const app = createApp( - loadConfig(testEnvironment()), + loadConfig(testEnvironment(deployment.environment)), { handler: () => new Response(null, { status: 204 }), api: { getSession: async () => ({ user: ADMIN }) }, @@ -966,8 +1082,15 @@ function brokeredApp( redirectUrl ? ({ broker: { - authorize: async (request: { userId: string; toolkit: string }) => { + authorize: async (request: { + userId: string; + toolkit: string; + returnUrl: string; + }) => { + // Recorded BEFORE the throw, so a test about a failing broker can still say the + // address it was asked with was this deployment's own. authorized.push(request); + if (deployment.authorizeThrows) throw deployment.authorizeThrows; return { redirectUrl }; }, }, @@ -980,12 +1103,23 @@ function brokeredApp( queried, confirmed, disconnected, - connect: (body: unknown) => + connect: ( + body: unknown, + query = "", + /** + * Headers a caller controls, for the cases that assert none of them is read. + * + * A browser sends `Referer` and `Origin` on an ordinary same-origin POST without being asked, + * so a route reading either would be taking a return address from the request while looking + * like it took none. Defaulted empty, because every other case here is about the body. + */ + headers: Record = {}, + ) => app.request( - "http://openbot.test/api/plugins/servers/composio-linear/connect", + `http://openbot.test/api/plugins/servers/composio-linear/connect${query}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), }, ), @@ -1045,7 +1179,9 @@ describe("connecting a brokered app", () => { expect(await response.json()).toEqual({ authorizationUrl: AUTHORIZATION_URL, }); - expect(authorized).toEqual([{ userId: ADMIN.id, toolkit: "linear" }]); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); // And the read that decided there was no connection yet asked about the same person. expect(queried).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); }); @@ -1074,11 +1210,18 @@ describe("connecting a brokered app", () => { const response = await connect({}); expect(response.status).toBe(409); + const refusal = (await response.json()).error as string; // Naming the remedy rather than only refusing: the person has an account attached already, and // the only way to a new link is through disconnecting the one they have. - expect(((await response.json()).error as string).toLowerCase()).toContain( - "disconnect", - ); + expect(refusal.toLowerCase()).toContain("disconnect"); + /* + * THE APP'S TITLE, AND NOT THE ROW'S ID. "Linear" is the name of the thing this person + * connected; `composio-linear` is how this deployment keys a table, which they have never seen + * and cannot act on. The second assertion is not the first one twice: a sentence that named the + * app and then quoted the row id beside it would satisfy one and fail the other. + */ + expect(refusal).toContain("Linear"); + expect(refusal).not.toContain("composio-linear"); // And nothing was minted, which is the half that matters: a link handed out here would attach // a second account behind a row that already says connected. expect(authorized).toEqual([]); @@ -1094,6 +1237,134 @@ describe("connecting a brokered app", () => { expect(response.status).toBe(503); expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); }); + + test("the address Composio sends somebody back to is built here, never taken from the request", async () => { + /* + * THE RETURN ADDRESS IS THIS DEPLOYMENT'S, AND A CALLER HAS NO SAY IN IT. + * + * Whoever names it names where a person lands holding a just-completed consent, so a url read + * off the body, the query or a header would be an open redirect with a consent screen in front + * of it — the same defect this repository's OAuth `returnTo` is narrowed to two names to + * avoid. The request below names an address in all three places at once, so a route that read + * any one of them fails here rather than passing on the two it ignored. + */ + const { authorized, connect } = brokeredApp(); + + const response = await connect( + { returnUrl: "https://evil.test/harvest", returnTo: "https://evil.test" }, + "?returnUrl=https%3A%2F%2Fevil.test%2Fharvest&callbackUrl=https%3A%2F%2Fevil.test", + { + returnUrl: "https://evil.test/harvest", + referer: "https://evil.test", + origin: "https://evil.test", + "x-forwarded-host": "evil.test", + }, + ); + + expect(response.status).toBe(200); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + }); + + test("an administrator who started on the app's own page is sent back to it", async () => { + /* + * The one thing a caller does get to choose, and it is a NAME rather than an address: `admin` + * or anything else, resolved against this deployment's own origin either way. An administrator + * connecting their account from the app's setup page left a page mid-task, and sending them to + * their personal settings afterwards is the round trip this exists to remove. + */ + const { authorized, connect } = brokeredApp(); + + await connect({}, "?returnTo=admin"); + + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: ADMIN_RETURN_URL }, + ]); + }); + + test("a returnTo naming somewhere else is the default, not a destination", async () => { + // Narrowed to one of two names on the way in, so an unrecognised value never reaches the url + // that gets built. A full address in that parameter is the attack this shape refuses. + const { authorized, connect } = brokeredApp(); + + await connect({}, "?returnTo=https%3A%2F%2Fevil.test"); + + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + }); + + test("a deployment with no app URL is refused rather than handed a link with no way back", async () => { + /* + * A CONSENT WITH NOWHERE TO RETURN TO STRANDS SOMEBODY, so no link is minted at all. + * + * The consent screen is on Composio's origin, so the address has to be absolute, and a + * deployment that cannot say where its own pages are has none to give. Minting the link anyway + * would leave a person on Composio's hosted page having just granted access to their mailbox, + * with no route back and nothing here knowing it happened. + * + * Single-user with no sign-in is the one deployment shape that genuinely has no app URL: + * everywhere else `OPENBOT_APP_URL`, `TRUSTED_ORIGINS` or the sign-in address supplies one. + */ + const { authorized, connect } = brokeredApp(null, AUTHORIZATION_URL, { + environment: { + OPENBOT_SINGLE_USER: "true", + BETTER_AUTH_URL: undefined, + BETTER_AUTH_SECRET: undefined, + GOOGLE_OAUTH_CLIENT_ID: undefined, + GOOGLE_OAUTH_CLIENT_SECRET: undefined, + INITIAL_ADMIN_EMAILS: undefined, + }, + }); + + const response = await connect({}); + + expect(response.status).toBe(503); + // The setting, because it is the whole remedy and nothing else on the screen names it. + expect((await response.json()).error).toContain("OPENBOT_APP_URL"); + expect(authorized).toEqual([]); + }); + + test("a broker that throws answers with Composio's own sentence, not a 500", async () => { + /* + * A WRONG KEY IS THE FIRST FAILURE A NEW OPERATOR MEETS, and it used to be the least legible: + * nothing on this path caught anything, so the person pressing Connect got a bare 500 and the + * vendor's whole thrown object went to the console. + * + * What comes back is the one sentence the vendor wrote and nothing else that travelled with + * it: not the request id, not the response headers, and not the link that was being minted + * when it failed — which is a bearer capability and belongs in one browser or nowhere. + */ + const { connect } = brokeredApp(null, AUTHORIZATION_URL, { + authorizeThrows: WRONG_KEY, + }); + + const response = await connect({}); + const body = await response.text(); + + // 502 rather than 500: nothing here broke, and a third party did not answer usefully. + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + expect(body).not.toContain(AUTHORIZATION_URL); + }); + + test("a failure the vendor did not explain still says what to do", async () => { + // Null from `vendorSentence` is a failure this deployment cannot explain — a socket that hung + // up, an answer in a shape nobody recognises — and the fallback names the app and the step + // rather than echoing whatever the thrown object happened to stringify as. + const { connect } = brokeredApp(null, AUTHORIZATION_URL, { + authorizeThrows: new Error("socket hang up"), + }); + + const response = await connect({}); + + expect(response.status).toBe(502); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("Linear"); + expect(refusal).not.toContain("socket hang up"); + }); }); /** @@ -1205,4 +1476,56 @@ describe("confirming and ending a brokered connection", () => { expect(confirmed).toEqual([]); expect(disconnected).toEqual([]); }); + + test("a broker that throws reaches both routes as Composio's own sentence", async () => { + /* + * NEITHER OF THESE HAD ANY ERROR HANDLING, and there is no framework-level handler behind + * them, so a wrong key answered both with a bare 500 and put the vendor's whole response + * object on the console. + * + * Confirm is the sharper of the two. It runs on every page load, so the tempting answer to a + * failure is `{ connected: false }` — which would be this deployment inventing a fact only + * Composio holds, drawing "Not connected" over a live account and then deleting the row that + * said otherwise. A failure is not a no. + */ + const { confirm, disconnect } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: WRONG_KEY }, + ); + + const confirmResponse = await confirm(); + const disconnectResponse = await disconnect(); + const confirmBody = await confirmResponse.text(); + const disconnectBody = await disconnectResponse.text(); + + expect(confirmResponse.status).toBe(502); + expect(disconnectResponse.status).toBe(502); + expect(JSON.parse(confirmBody).error).toBe("Invalid API key provided."); + expect(JSON.parse(disconnectBody).error).toBe("Invalid API key provided."); + // And nothing else that travelled with the failure: the response headers it was carrying are + // a request id in an error body, which is somebody's trace and nobody's remedy. + expect(confirmBody).not.toContain("req_a_trace_id_nobody_should_read"); + expect(disconnectBody).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a failure the vendor did not explain still says what to do on both routes", async () => { + // The fallback says what a person can act on rather than guessing how far the call got: what + // the page shows is the vendor's last answer and not this one, and a disconnect is safe to + // press again because the revoke runs before anything here is deleted. + const { confirm, disconnect } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: new Error("socket hang up") }, + ); + + const confirmRefusal = (await (await confirm()).json()).error as string; + const disconnectRefusal = (await (await disconnect()).json()) + .error as string; + + expect(confirmRefusal).toContain("last answer"); + expect(disconnectRefusal).toContain("Disconnect again"); + expect(confirmRefusal).not.toContain("socket hang up"); + expect(disconnectRefusal).not.toContain("socket hang up"); + }); }); From 3da24bf5e9ceea472aaa3b4e2ac38124d88be744 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:11:24 +0200 Subject: [PATCH 120/215] Draw one account card, and let it forget what it was told MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card appeared on two screens as two copies, comment for comment, and both copies held the same two defects — which is what the duplication cost. It is one hook and one row now, and the screens pass what genuinely differs: where consent returns to, which banner reports a failure, and what each audience is told, since an administrator setting a connector up and a person connecting their own account are not reading for the same thing. A DISCONNECT THAT WORKED USED TO READ AS ONE THAT HAD NOT. The card asked the broker on mount and kept that answer; disconnecting refreshed the queries, which is not where a mutation's answer lives, and the question was never asked again. So the row kept its dot and its button, the person read failure and pressed a second time, and that press reached a method which revoked nothing and filed a trail row saying an account had been disconnected. The answer is now discarded whenever an act changes the account — in both directions, so a connect cannot leave a stale "not connected" deciding the row either. AND WHERE THERE IS NO KEY THE CARD SAYS SO. The documentation already claimed a page did. Nothing did: the confirm call refused, the screens swallowed the refusal, and the state fell back to the last row we had — so every person read "Connected" on a deployment that could not reach the broker at all, until a Bot call refused. It names the setting, offers neither button, and says plainly that the app stays enabled and its grants stand. Neither screen had a single test between them, which is how both defects survived a wave review and a final one. There is one now, driving a small deployment rather than a canned answer, because the first defect is a sequence and not a state. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 244 ++++++++++ app/src/routes/_authed/admin/plugins/$key.tsx | 102 ++--- .../settings/connected-accounts/$key.tsx | 116 +---- app/tests/brokered-account-row.test.tsx | 425 ++++++++++++++++++ 4 files changed, 732 insertions(+), 155 deletions(-) create mode 100644 app/src/components/plugins/brokered-account-row.tsx create mode 100644 app/tests/brokered-account-row.test.tsx diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx new file mode 100644 index 000000000..855ccd892 --- /dev/null +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -0,0 +1,244 @@ +import { IconArrowUpRight } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { + confirmBrokeredConnectionMutationOptions, + connectAccountMutationOptions, + disconnectBrokeredMutationOptions, +} from "@/lib/plugins/mutations"; + +/** + * One person's own brokered account, on whichever screen is asking. + * + * Two screens draw this — the connector's admin page, where an administrator checks the setup they + * have just finished, and that person's own connected-accounts page — and they drew it twice, forty + * lines apiece, comment for comment. The cost was not the duplication: it was that both copies held + * the same two defects, a disconnect that went on reading "Connected" and a deployment with no + * Composio key that said nothing about it, and both had to be found twice to be fixed once. + * + * What genuinely differs between the two stays an argument. An administrator is told their setup is + * complete without this row; a person is told which Bots can read them as them. The consent flow + * returns to whichever screen started it. Everything else — what the row says while there is no + * key, what the dot means, what disconnecting ends — is one answer and lives here. + */ + +/** The row's state and the two things it can do, from {@link useBrokeredAccount}. */ +export type BrokeredAccount = { + /** Whether this person's account is live, as the vendor last answered. */ + connected: boolean; + /** + * Whether this deployment has a Composio key at all. + * + * Carried through the hook rather than passed to the row separately, so a caller wires the row up + * once: with no key there is no broker to ask, nothing to confirm on arrival, and neither action + * below can do anything but fail. + */ + configured: boolean; + /** Leave for the vendor's consent screen. */ + connect: () => void; + /** End the account at Composio, not only here. */ + disconnect: () => void; + connecting: boolean; + disconnecting: boolean; +}; + +export function useBrokeredAccount(input: { + serverId: string; + /** Whether this row is about a brokered app at all. Asked of the recorded row by the caller. */ + brokered: boolean; + /** See {@link BrokeredAccount.configured}. */ + configured: boolean; + /** What this deployment recorded, which is what stands until the vendor has answered anything. */ + recorded: boolean; + /** Which screen the vendor's callback puts somebody down on. */ + returnTo: "settings" | "admin"; + /** + * Where this row's failures go: the screen's own banner. + * + * Called with null as an action starts, so the reason the last attempt failed is not left sitting + * over the one now in flight. + */ + report: (message: string | null) => void; +}): BrokeredAccount { + const queryClient = useQueryClient(); + const { brokered, configured, recorded, report, returnTo, serverId } = input; + + /* + * Ask the vendor whether this person's brokered account is actually live, on arrival. + * + * The return trip from consent is an ordinary redirect with nothing signed in it, so being back + * on the page proves nothing about what happened at the vendor. The row a screen would otherwise + * read is written from that same unproven return, which is why the answer is asked for rather + * than assumed. + * + * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has + * simply never connected — arrives with nothing at the vendor to confirm, and that is an ordinary + * state of the page, not a failure of it. It reads as not connected, which is what it is; a red + * sentence across the top would be the page reporting its own question as somebody's problem. + * + * Not asked at all where there is no key. The endpoint answers 503, the screen swallows it, and + * the row falls back to whatever we recorded — so the question can only ever be a wasted request + * that ends in the one state this row has a sentence for anyway. + */ + const confirmation = useMutation( + confirmBrokeredConnectionMutationOptions(queryClient), + ); + const confirmAccount = confirmation.mutate; + /* + * The confirmed answer is thrown away whenever an action changes the account underneath it. + * + * A mutation's `data` is not query state: invalidating the queries refetches the recorded row and + * leaves this answer exactly where it was, and the effect above does not run again because none + * of its dependencies changed. So a successful disconnect kept its dot, its word and its + * Disconnect button, the person read that as a failure and pressed again, and the second DELETE + * wrote a second `mcp.account_disconnected` entry about an account that was already gone. + * + * Cleared on connect for the same reason in the other direction. That path ends in a full page + * navigation, so the held answer is usually thrown away with the document — but a navigation the + * browser declines to make, or one somebody comes back from, would otherwise leave a "not + * connected" answer from before the consent deciding a row about the account it granted. + */ + const forgetConfirmation = confirmation.reset; + useEffect(() => { + if (!(brokered && configured)) return; + confirmAccount(serverId); + }, [brokered, configured, serverId, confirmAccount]); + + const connect = useMutation({ + ...connectAccountMutationOptions(returnTo), + onError: (thrown: Error) => report(thrown.message), + /* + * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be + * shown to this person in their own browser; there is deliberately nothing here that could + * complete it for them, and nothing about being an administrator changes that. + */ + onSuccess: (authorizationUrl) => { + forgetConfirmation(); + window.location.href = authorizationUrl; + }, + }); + + /* + * The mutation's own `onSuccess` is called rather than replaced: it is what invalidates every + * plugin query, and spreading these options and then declaring a second `onSuccess` would quietly + * drop it, leaving the recorded row on screen as stale as the confirmed answer. + */ + const disconnectOptions = disconnectBrokeredMutationOptions(queryClient); + const disconnect = useMutation({ + ...disconnectOptions, + onError: (thrown: Error) => report(thrown.message), + onSuccess: (...args) => { + forgetConfirmation(); + return disconnectOptions.onSuccess?.(...args); + }, + }); + + return { + /* + * What the vendor last answered, and only our own record until it has answered anything. The + * answer wins once there is one, in both directions — an account ended at Composio by somebody + * else reads as not connected here too. A confirm still in flight, or one that could not be + * made at all, leaves the recorded row standing rather than inventing either answer. + */ + connected: confirmation.data?.connected ?? recorded, + configured, + connect: () => { + report(null); + connect.mutate(serverId); + }, + connecting: connect.isPending, + disconnect: () => { + report(null); + disconnect.mutate(serverId); + }, + disconnecting: disconnect.isPending, + }; +} + +/** + * The row itself, for a `PageRows` card on either screen. + * + * Just the `Item`: where it sits in the card, and whether a `Separator` precedes it, is the screen's + * business and differs between the two. + */ +export function BrokeredAccountRow({ + account, + connectedDescription, + disconnectedDescription, +}: { + account: BrokeredAccount; + /** What being connected means on this screen, said beside the button rather than after it. */ + connectedDescription: string; + /** What connecting would do, in the voice of whoever is reading. */ + disconnectedDescription: string; +}) { + return ( + + + {/* Not "Connect your account": the row is also the connected state, and a title has to read + for both. */} + Your account + {/* Unclamped where the key is missing: that sentence is the only place the setting is named, + so it is the point rather than a hint. */} + + {account.configured + ? account.connected + ? connectedDescription + : disconnectedDescription + : "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."} + + + + {!account.configured ? ( + /* + * A value and nothing to press, which is the layout's read-only row: the deployment has + * no key, so Connect could only fail at the broker and Disconnect could only fail at it + * twice. A button that cannot work is worse than no button — it invites the second press + * that files a record of an act that did not happen. + */ + Key missing + ) : account.connected ? ( + <> + {/* Decorative: the word beside it already says which. */} + + + ); +} diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 941f09bce..017e2bd9d 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -13,6 +13,10 @@ import { PageSection, PageShell, } from "@/components/layout/page-shell"; +import { + BrokeredAccountRow, + useBrokeredAccount, +} from "@/components/plugins/brokered-account-row"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -40,9 +44,7 @@ import { agentListQueryOptions } from "@/lib/agents/queries"; import { storeMcpToken } from "@/lib/credentials/mutations"; import { addCuratedServerMutationOptions, - confirmBrokeredConnectionMutationOptions, connectAccountMutationOptions, - disconnectBrokeredMutationOptions, grantPlugin, invalidatePlugins, refreshPluginServerMutationOptions, @@ -216,47 +218,22 @@ function RouteComponent() { const title = entry?.title ?? server?.title ?? key; /* - * Ask the vendor whether this person's brokered account is actually live, on arrival. - * - * The return trip from consent is an ordinary redirect with nothing signed in it, so being back - * on this page proves nothing about what happened at the vendor. The row this page would - * otherwise read is written from that same unproven return, which is why the answer is asked for - * rather than assumed. + * Everything the brokered row below reads and does, shared with the personal connected-accounts + * screen that draws the same row. See `brokered-account-row.tsx`. * - * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has - * simply never connected — arrives here with nothing at the vendor to confirm, and that is an - * ordinary state of this page, not a failure of it. It reads as not connected, which is what it - * is; a red sentence across the top would be the page reporting its own question as the - * administrator's problem. + * `connectSelf` above stays: it is the `user-oauth` row's Connect, which is a different row with + * no Disconnect beside it and nothing brokered to confirm. */ - const confirmBrokered = useMutation( - confirmBrokeredConnectionMutationOptions(queryClient), - ); - const confirmBrokeredAccount = confirmBrokered.mutate; - React.useEffect(() => { - if (auth !== "brokered") return; - confirmBrokeredAccount(key); - }, [auth, key, confirmBrokeredAccount]); - const disconnectBrokered = useMutation({ - ...disconnectBrokeredMutationOptions(queryClient), - ...report, + const brokeredAccount = useBrokeredAccount({ + brokered: auth === "brokered", + configured: plugins.data?.composioConfigured ?? false, + recorded: youConnected, + report: setError, + // Back to this page afterwards, not to the personal settings screen. + returnTo: "admin", + serverId: key, }); - /* - * Whether this person's own account is live, for the row that says so. - * - * For a brokered app that is what the vendor last answered, and only our own record until it has - * answered anything: `youConnected` is this deployment's row, `confirmBrokered.data` is the - * vendor's answer about the account behind it. The answer wins once there is one, in both - * directions — an account ended at Composio by somebody else reads as not connected here too. A - * confirm still in flight, or one that could not be made at all, leaves the recorded row - * standing rather than inventing either answer. - */ - const accountConnected = - auth === "brokered" - ? (confirmBrokered.data?.connected ?? youConnected) - : youConnected; - /** Adding is two writes when a token was typed: the credential, then the record pointing at it. */ const add = async () => { setError(null); @@ -565,26 +542,41 @@ function RouteComponent() { * could only fail. It is the one row on this card that is not optional in practice — * a brokered app reaches nobody until somebody connects — but it is still personal, * and still this person's account and nobody else's. + * + * The two are drawn by different things, which is the honest arrangement rather than + * one row branching on `auth` in five places. A brokered account is ended from here as + * well as begun, and says so; an OAuth one is withdrawn at the vendor and this page + * offers no button pretending otherwise. */} - {auth === "brokered" || - (auth === "user-oauth" && - (server?.hasCredential || server?.dynamicClient)) ? ( + {auth === "brokered" ? ( + <> + + + + ) : null} + + {auth === "user-oauth" && + (server?.hasCredential || server?.dynamicClient) ? ( <> Your account - {accountConnected - ? auth === "brokered" - ? /* Said beside the button rather than after it: disconnecting ends the account at Composio, so what it undoes is not the row here but the grant on this person's mailbox, and connecting again is a fresh consent. */ - `Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.` - : `Connected, so a Bot granted these tools uses your ${title} as you. Everybody else connects their own.` + {youConnected + ? `Connected, so a Bot granted these tools uses your ${title} as you. Everybody else connects their own.` : "Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only."} - {accountConnected ? ( + {youConnected ? ( <> {/* Decorative: the word beside it already says which. */} Connected - {auth === "brokered" ? ( - - ) : null} ) : ( /* The arrow says this leaves OpenBot for the vendor's consent page. It does. */ diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index f78916c35..51f9e0d44 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -1,13 +1,17 @@ import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, useParams } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { PageEmpty, PageRows, PageSection, PageShell, } from "@/components/layout/page-shell"; +import { + BrokeredAccountRow, + useBrokeredAccount, +} from "@/components/plugins/brokered-account-row"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -23,11 +27,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; -import { - confirmBrokeredConnectionMutationOptions, - connectAccountMutationOptions, - disconnectBrokeredMutationOptions, -} from "@/lib/plugins/mutations"; +import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { connectionsQueryOptions, pluginsPageQueryOptions, @@ -49,7 +49,6 @@ function RouteComponent() { const { key } = useParams({ from: "/_authed/settings/connected-accounts/$key", }); - const queryClient = useQueryClient(); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); const [notice, setNotice] = useState(null); @@ -79,28 +78,14 @@ function RouteComponent() { */ const brokered = server?.provenance === "composio"; - /* - * Ask the vendor whether this brokered account is actually live, on arrival. - * - * The return trip from consent is an ordinary redirect with nothing signed in it, so being back - * on this page proves nothing about what happened at the vendor. So the vendor is asked, and its - * answer is what the row below is drawn from. - * - * Deliberately not wired into the banner. Somebody who abandoned the consent screen — or who has - * simply never connected — has nothing at the vendor to confirm, and that is an ordinary state of - * this page rather than a failure of it. It reads as not connected, which is what it is. - */ - const confirmBrokered = useMutation( - confirmBrokeredConnectionMutationOptions(queryClient), - ); - const confirmBrokeredAccount = confirmBrokered.mutate; - useEffect(() => { - if (!brokered) return; - confirmBrokeredAccount(key); - }, [brokered, key, confirmBrokeredAccount]); - const disconnectBrokered = useMutation({ - ...disconnectBrokeredMutationOptions(queryClient), - onError: (thrown: Error) => setNotice(thrown.message), + /* Everything the brokered row below reads and does. See `brokered-account-row.tsx`. */ + const brokeredAccount = useBrokeredAccount({ + brokered, + configured: plugins.data?.composioConfigured ?? false, + recorded: connection !== undefined, + report: setNotice, + returnTo: "settings", + serverId: key, }); if (plugins.isPending) { @@ -121,15 +106,6 @@ function RouteComponent() { * better: its title and summary are the catalogue's, and there is none. */ if (brokered && server) { - /* - * What the vendor last answered, and only our own record until it has answered anything. The - * answer wins once there is one, in both directions — an account ended at Composio by somebody - * else reads as not connected here too. A confirm still in flight, or one that could not be - * made at all, leaves whatever we recorded standing rather than inventing either answer. - */ - const accountConnected = - confirmBrokered.data?.connected ?? connection !== undefined; - return ( - - - Your account - - {accountConnected - ? /* Said beside the button rather than after it: disconnecting ends the account - at Composio, so what it undoes is not the row here but the grant on your own - mailbox, and connecting again is a fresh consent. */ - `A Bot granted its tools reads your ${server.title} as you. Disconnecting ends the account at Composio, not just here.` - : "No Bot can read this as you. Connecting takes you to Composio and then to the vendor to consent."} - - - - {accountConnected ? ( - <> - {/* Decorative: the word beside it already says which. */} - - + diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx new file mode 100644 index 000000000..b76c65060 --- /dev/null +++ b/app/tests/brokered-account-row.test.tsx @@ -0,0 +1,425 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as AdminAppRoute } from "@/routes/_authed/admin/plugins/$key"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; + +/** + * The brokered account row, on both screens that draw it. + * + * Neither screen had a test, which is how two defects survived a wave review and a final one. + * + * THE FIRST is that a successful disconnect went on reading "Connected". The row's state is + * `confirmBrokered.data?.connected ?? `, and a mutation's `data` is not query + * state: disconnecting invalidated the queries, the recorded row went away, and the vendor's old + * answer — set by the confirm-on-mount, whose dependencies had not changed — kept winning. The + * person read that as a failure and pressed Disconnect again, and the second DELETE reached a store + * method with no row-existence check, which revoked nothing and filed a second + * `mcp.account_disconnected` entry about an account that was already gone. + * + * THE SECOND is a deployment with no Composio key. The confirm answers 503, both screens swallow + * it by design, and the state fell back to the stale local row — so every reader was told + * "Connected" on a deployment that cannot reach the broker at all, with no sign anything was wrong + * until a Bot call refused. `docs/plugins/composio.md` has always claimed the page says the key is + * missing; nothing implemented it. + * + * THE HARNESS IS THIS REPOSITORY'S, from `agent-roster-error.test.tsx` and + * `bot-app-grants-screen.test.tsx`: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in + * `afterEach`, queries off `render()`'s own return, a `QueryClient` with `retry: false`, and the + * capture-and-restore of each exported `Route` singleton those files document at length — bun walks + * every file into one process, and `.update()` merges into the live object rather than replacing it. + * + * What is NOT copied from them is the always-failing `fetch`. What is under test here is a sequence + * — confirmed live, then disconnected, then read again — so the stub below is a small in-memory + * deployment that answers each endpoint from state a test can set and a DELETE can change, rather + * than one canned response. It also counts the DELETEs, which is the only way to assert the second + * press cannot happen rather than merely that the word changed. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const APP_KEY = "gmail"; + +/** A minimal but complete brokered `PluginServer` — the row shape only Composio produces. */ +function brokeredServer(): PluginServer { + return { + id: APP_KEY, + title: "Gmail", + vendor: "Google", + url: "https://example.invalid/composio", + summary: "Mail.", + docsUrl: "", + provenance: "composio", + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + tools: [], + withdrawn: [], + }; +} + +function pluginsPage(composioConfigured: boolean): PluginsPage { + return { + catalogue: [], + servers: [brokeredServer()], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured, + }; +} + +/** + * The deployment these tests render against, as a handful of facts a test sets up front. + * + * `recorded` is this deployment's own row — written from the unproven return trip from consent — + * and `confirms` is what the broker answers when asked about the account behind it. They are + * separate on purpose: every case worth testing here is one where the two disagree. + */ +type Deployment = { + composioConfigured: boolean; + recorded: boolean; + confirms: boolean; +}; + +type Server = { + /** How many DELETEs reached the connection endpoint. */ + deletes: number; +}; + +/** + * A stub `fetch` answering the four endpoints these two screens read and write, from state the + * DELETE actually changes. + * + * A canned response per endpoint would not do: the defect under test is a screen that keeps + * rendering an answer the vendor gave BEFORE an act that invalidated it, so the disconnect has to + * really take effect somewhere for a later read to be able to disagree with it. + */ +function installDeployment(deployment: Deployment): Server { + const state = { ...deployment }; + const server: Server = { deletes: 0 }; + + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === "string" ? input : String(input); + const method = init?.method ?? "GET"; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + + if (path.startsWith("/api/plugins/connections")) { + return json({ + connections: state.recorded + ? [ + { + serverId: APP_KEY, + scope: "", + connectedAt: "2026-09-10T00:00:00.000Z", + }, + ] + : [], + redirectUri: null, + }); + } + if (path.endsWith("/connection/confirm") && method === "POST") { + // What the route answers with no key at all: nothing to ask, and no answer invented. + if (!state.composioConfigured) { + return new Response( + JSON.stringify({ error: "Composio is not set up" }), + { + headers: { "content-type": "application/json" }, + status: 503, + }, + ); + } + return json({ connected: state.confirms }); + } + if (path.endsWith("/connection") && method === "DELETE") { + server.deletes += 1; + state.recorded = false; + state.confirms = false; + return json({ ok: true }); + } + if (path.startsWith("/api/agents")) return json({ agents: [] }); + if (path.startsWith("/api/plugins")) { + return json(pluginsPage(state.composioConfigured)); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + return server; +} + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +/** A client that settles in one attempt, so no test waits on a retry. */ +function queryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +/* + * Capture and restore of each exported `Route` singleton, verbatim from + * `agent-roster-error.test.tsx` and for the reason recorded there: `.update()` merges into the live + * object, `createRouter()` derives `_id`/`parentRoute` off it, and nothing re-runs `init()` on a + * replay — so a render here would otherwise leave the real router (`router.test.ts` builds one in + * this same bun process) pointed at a decoy parent. + */ +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} + +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} + +/** Captured once, at module scope, before any `test()` body here has run — the state this file must + * hand back, whatever it happens to be. See `agent-roster-error.test.tsx`. */ +const pristineAccountRouteState = captureRouteState(ConnectedAccountRoute); +const pristineAdminRouteState = captureRouteState(AdminAppRoute); + +let accountRouteSnapshot: Record; +let adminRouteSnapshot: Record; + +beforeEach(() => { + accountRouteSnapshot = captureRouteState(pristineAccountRouteState); + adminRouteSnapshot = captureRouteState(pristineAdminRouteState); +}); + +afterEach(() => { + restoreRouteState(ConnectedAccountRoute, accountRouteSnapshot); + restoreRouteState(AdminAppRoute, adminRouteSnapshot); +}); + +/** + * The personal screen, at its real id. + * + * `routeTree.gen.ts` fixes both halves: id `/connected-accounts/$key` under `/_authed/settings`, + * path `/connected-accounts/$key`. Decoy pathless/static parents are enough to make the join land + * on the id `useParams({ from: … })` resolves against; the real ancestors check a session and mount + * the settings shell, neither of which this file is about. The index route is registered only so + * the Back link has something to build an href from. + */ +function renderAccountScreen(client: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const wired = ( + ConnectedAccountRoute as unknown as { + update: (options: unknown) => typeof ConnectedAccountRoute; + } + ).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([settingsRoute.addChildren([indexRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/settings/connected-accounts/${APP_KEY}`], + }), + }); + return render( + + + , + ); +} + +/** The administrator's connector page, the same way: id `/plugins/$key` under `/_authed/admin`. */ +function renderAdminScreen(client: QueryClient) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + AdminAppRoute as unknown as { + update: (options: unknown) => typeof AdminAppRoute; + } + ).update({ + id: "/plugins/$key", + path: "/plugins/$key", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([pluginsRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: [`/admin/plugins/${APP_KEY}`], + }), + }); + return render( + + + , + ); +} + +/** The sentence the row carries where the deployment has no Composio key, matched on the part that + * names the setting — the whole point of the row in that state. */ +const NAMES_THE_SETTING = /Set COMPOSIO_API_KEY on this deployment/; + +test("a disconnect that lands stops reading Connected and offers Connect again", async () => { + const server = installDeployment({ + composioConfigured: true, + recorded: true, + confirms: true, + }); + + const view = renderAccountScreen(queryClient()); + + // Connected first, on the vendor's own answer — otherwise the assertion below proves nothing. + const disconnect = await view.findByRole("button", { name: "Disconnect" }); + expect(view.queryByText("Connected")).toBeTruthy(); + + await userEvent.click(disconnect); + + await waitFor(() => + expect(view.queryByRole("button", { name: "Connect" })).toBeTruthy(), + ); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + // The whole reason this matters: with Disconnect still on screen the person presses it again, + // and the second DELETE files a row claiming an account was disconnected when there was none. + expect(server.deletes).toBe(1); +}); + +test("a deployment with no Composio key names the setting and offers neither action", async () => { + installDeployment({ + composioConfigured: false, + // A row left over from when a key WAS set: the app keeps its row and its grants, which is the + // exact state that used to read "Connected" on a deployment that cannot reach the broker. + recorded: true, + confirms: true, + }); + + const view = renderAccountScreen(queryClient()); + + expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); + expect(view.queryByText("Key missing")).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + expect(view.queryByRole("button", { name: "Connect" })).toBeNull(); +}); + +test("somebody who abandoned consent reads as not connected, not as an error", async () => { + installDeployment({ + composioConfigured: true, + // The callback wrote our row on an ordinary redirect with nothing signed in it. The vendor is + // the only thing that knows the consent was never finished, and it says so. + recorded: true, + confirms: false, + }); + + const view = renderAccountScreen(queryClient()); + + expect(await view.findByRole("button", { name: "Connect" })).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + // Not a failure of this page: it asked a question and got an answer. A red sentence across the + // top would report the page's own question as this person's problem. + expect(view.queryByRole("alert")).toBeNull(); + expect(view.queryByText(NAMES_THE_SETTING)).toBeNull(); +}); + +test("the connector's admin page draws the same row, and its disconnect clears too", async () => { + const server = installDeployment({ + composioConfigured: true, + recorded: true, + confirms: true, + }); + + const view = renderAdminScreen(queryClient()); + + const disconnect = await view.findByRole("button", { name: "Disconnect" }); + expect(view.queryByText("Connected")).toBeTruthy(); + + await userEvent.click(disconnect); + + await waitFor(() => + expect(view.queryByRole("button", { name: "Connect" })).toBeTruthy(), + ); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + expect(server.deletes).toBe(1); +}); + +test("the admin page says the key is missing too, rather than offering an action", async () => { + installDeployment({ + composioConfigured: false, + recorded: true, + confirms: true, + }); + + const view = renderAdminScreen(queryClient()); + + expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy(); + expect(view.queryByText("Key missing")).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); + expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull(); + // The app is still enabled and still says how it is reached — the row is honest about what is + // still true, rather than reading as a connector that has gone away. + expect(view.queryByText("How this is reached")).toBeTruthy(); +}); From e8f4989f3bdc355acd8b985ffab0008500f1945c Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:11:24 +0200 Subject: [PATCH 121/215] File a disconnect only where something was disconnected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method revoked at the broker, deleted by person and app whether or not a row was there, and filed its trail entry regardless. So a disconnect for somebody who held no connection revoked nothing, deleted nothing, and wrote a row saying an account had been ended. It still asks the broker, and that is deliberate rather than an oversight. Our row is a cache of the broker's answer — the confirm path deletes it on any no — so a missing row is not evidence that a grant is gone, and somebody whose row was cleared that way can still hold a live account with nothing here pointing at it. This is the only operation in the deployment that can end one, and skipping the ask for want of a row would make the half that matters unreachable for exactly the person who needs it. Which settles what the trail records: whether anything ended, not whether a row existed. A call that found no row and still withdrew a live grant performed the weightier of the two acts available here, and staying silent about it would be the same failure as the false row, pointed the other way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 65 ++++++++++++----- server/tests/plugin-store.integration.test.ts | 69 +++++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index ef804ab4d..3dc3d5fb3 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3843,6 +3843,32 @@ export function createPluginStore(options: PluginStoreOptions) { * own answer, passed through. True where a grant was withdrawn, false where there was none to * withdraw, and the value of the field is exactly that a reader can tell a grant this * deployment ended from one that outlives it somewhere else. + * + * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer + * and never the account itself (see {@link brokeredConnection}), so its absence is not + * evidence that the grant is gone: the confirm above deletes it on any `false` from the + * vendor, and a person whose row was cleared that way can still be holding a live account at + * Composio with nothing left here pointing at it. Asking anyway is the only operation in this + * deployment that can end such a grant, and it costs nothing where there is genuinely nothing + * to withdraw — the broker answers `false` and says so. Skipping the revoke for want of a + * local row would make the safe half of disconnect unreachable for exactly the person who + * needs it, on the strength of a cache we already know drifts. + * + * BUT THE TRAIL RECORDS ONLY A DISCONNECT THAT HAPPENED. The event is filed where something + * actually ended — a row deleted here, or a grant withdrawn at the vendor — and not otherwise. + * A call that found no row and withdrew no grant disconnected nothing, and an + * `mcp.account_disconnected` row for it tells whoever reads the trail that somebody's account + * ended at a moment when nobody's did. It is the criterion + * {@link confirmBrokeredConnection} files its own event under, one act the other way round: + * the trail records acts, and a call that changed nothing performed none. + * + * WHICH IS NOT THE SAME QUESTION AS `vendorRevoked`. A row here with no grant at the vendor is + * a disconnect — the gate this deployment decides every brokered call on was open, and this + * call closed it — so the event is filed, saying `vendorRevoked: false`. A grant at the vendor + * with no row here is a disconnect too, and the weightier of the two, because somebody's live + * account was ended; the event is filed for that as well. Only where both are absent is there + * no act to record, and the two cases stay legible in the trail because the field still says + * which of them happened. */ async disconnectBrokered(input: { toolkit: string; @@ -3865,30 +3891,35 @@ export function createPluginStore(options: PluginStoreOptions) { toolkit: input.toolkit, }); - await database + // `returning` because whether a row was here is half of what decides if anybody was + // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. + const [deleted] = await database .delete(composioConnections) .where( and( eq(composioConnections.toolkit, input.toolkit), eq(composioConnections.userId, input.userId), ), - ); + ) + .returning({ toolkit: composioConnections.toolkit }); - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.by, - server: input.toolkit, - // Whose account this was, which is not always who ended it: an administrator offboarding - // somebody and a person disconnecting themselves write the same shape of row, and only - // these two fields tell them apart. - owner: input.userId, - reason: input.reason, - vendorRevoked, - }, - }); + if (deleted || vendorRevoked) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + // Whose account this was, which is not always who ended it: an administrator + // offboarding somebody and a person disconnecting themselves write the same shape of + // row, and only these two fields tell them apart. + owner: input.userId, + reason: input.reason, + vendorRevoked, + }, + }); + } return { vendorRevoked }; }, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 320db479e..853ecdc62 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6420,6 +6420,75 @@ test("a brokered disconnect that withdrew no grant records that it withdrew none }); }); +/** + * A disconnect that found nothing to disconnect says nothing in the trail. + * + * CRITERION. With no `composio_connections` row for the pair, the call still asks the broker to + * revoke, and files an `mcp.account_disconnected` event only where that ask withdrew a grant. + * Nothing here and nothing at the vendor is nothing disconnected, and the trail stays empty. + * + * REASON. This is the second press of Disconnect. The screen that made it easy — a row still + * reading "Connected" after a successful disconnect — has been fixed, but any caller can make the + * same call twice, and an event filed for it would tell whoever reads the trail back that + * somebody's account ended at a moment when nobody's did. A trail padded with acts nobody + * performed cannot answer the one question it is kept for, which is the reasoning + * `confirmBrokeredConnection` already files its connected event under. + * + * THE VENDOR IS ASKED ALL THE SAME, which is asserted and not assumed. The row is a cache of + * Composio's answer and it drifts by construction — the confirm deletes it on any `false` from the + * vendor — so an absence here is no evidence that the grant is gone, and this call is the only + * operation in this deployment that can end one. What the missing row stops is the writing-down of + * an act, not the ask. + * + * BOTH ANSWERS IN ONE TEST, because the second is what makes the first mean something: a guard + * that filed nothing whenever the row was missing would pass the empty-trail assertion on its own, + * and would lose the case that matters most — a live account ended for somebody whose local row + * had already gone. + */ +test("a brokered disconnect with nothing to disconnect files nothing in the trail", async () => { + let granted = false; + const { broker, order } = brokerSpy({ revoke: async () => granted }); + const { store, auditStore } = await freshStore({ broker }); + const pair = { toolkit: "gmail", userId: "user_asker" }; + + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevoked: false }); + + expect(order).toEqual(["revoke:user_asker"]); + expect(await store.brokeredConnection(pair)).toBeNull(); + // Empty, which is the whole of the first half: no row went and no grant was withdrawn, so + // nobody was disconnected and the trail has nothing to say about it. + expect(auditStore.recorded()).toHaveLength(0); + + // The same absence locally, but this time the ask found a live account and ended it. That is an + // act — the weightier of the two this method performs — and it is recorded. + granted = true; + expect( + await store.disconnectBrokered({ + ...pair, + by: "user_asker", + reason: "self", + }), + ).toEqual({ vendorRevoked: true }); + + const disconnected = auditStore + .recorded() + .filter((event) => event.eventType === "mcp.account_disconnected"); + expect(disconnected).toHaveLength(1); + expect(disconnected[0]?.payload).toMatchObject({ + actor: "user_asker", + server: "gmail", + owner: "user_asker", + reason: "self", + vendorRevoked: true, + }); +}); + /** * A brokered connection is visible to the person who made it, under the server row's own id. * From 6b89968dde3c840ad360202552421e8cba98e82a Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:11:24 +0200 Subject: [PATCH 122/215] Test the mark that says an action destroys things, and say which setting is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design lists seven mutations that must each turn something red. Six did. The seventh — draw a destructive action as an ordinary write — did not: the mark was rendered by a screen no test rendered, so deleting it left the suite green. It is covered now in both directions, since drawing it on everything is as wrong as drawing it on nothing, and an action that makes no claim about destroying things must not be dressed as one that claims to be safe. The app picker was throwing away the one sentence worth reading. Its own error line is generic, while the refusal underneath names the setting that is missing — which is exactly what somebody who reached that page on a deployment with no key, or with a mistyped one, needs to see. The test file is renamed, because it stopped being only about failed reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../routes/_authed/admin/plugins/composio.tsx | 9 ++- ...est.tsx => bot-app-grants-screen.test.tsx} | 65 +++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) rename app/tests/{bot-app-grants-error.test.tsx => bot-app-grants-screen.test.tsx} (79%) diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index 8ce23aee6..11f711d19 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -102,7 +102,14 @@ function RouteComponent() { {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */} {apps.isPending ? null : apps.error ? (

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

) : listed.length === 0 ? ( diff --git a/app/tests/bot-app-grants-error.test.tsx b/app/tests/bot-app-grants-screen.test.tsx similarity index 79% rename from app/tests/bot-app-grants-error.test.tsx rename to app/tests/bot-app-grants-screen.test.tsx index b57f8cbbe..ca8e9b522 100644 --- a/app/tests/bot-app-grants-error.test.tsx +++ b/app/tests/bot-app-grants-screen.test.tsx @@ -26,19 +26,26 @@ import { import { Route as BotAppRoute } from "@/routes/_authed/admin/plugins/$key_.bots.$agentId"; /** - * What the per-Bot grant screen says when the plugin list does not come back. + * What the per-Bot grant screen draws: which of its three pages a failed read returns, and the one + * mark a row carries beyond the action's own name. * - * Same mistake, same shape, as `agent-roster-error.test.tsx`: the screen branched on the app being + * Most of it is about the failure. Same mistake, same shape, as `agent-roster-error.test.tsx`: the screen branched on the app being * absent from `plugins.data`, and `isPending` goes false on a failed fetch exactly as it does on a * successful one — so a request that never came back was rendered as "this deployment has not * enabled an app by that name", about an app that may be enabled and granted right now. The Bot * half of the same function always got this right (`agents.data && !bot`), which is what made the * app half visible. * + * The last case is no failure at all: the danger mark on a destructive action. It is here because + * this is where the harness that draws this screen lives, and it needs both halves — the marked row + * and the unmarked one — since `destructive: false` is the vendor making no claim rather than a + * claim of safety, and a row that read as reassuring on that evidence would say more than anybody + * knows. It draws off the same seeded page as the failed-refetch case above. + * * `bot-app-grants.test.tsx` is the other half of this screen's coverage and deliberately renders * nothing — it asserts the bulk button's set through the exported `readOnlyRefs`. This file has to - * draw, because what is under test is which of three pages the component returns, so it is its own - * file rather than a DOM smuggled into that one. + * draw, because what is under test is what reaches the page, so it is its own file rather than a + * DOM smuggled into that one. * * THE HARNESS IS THIS REPOSITORY'S, copied from `agent-roster-error.test.tsx` for the reasons * recorded there: `GlobalRegistrator` in `beforeAll`/`afterAll`, `cleanup` in `afterEach`, queries @@ -302,3 +309,53 @@ test("a failed REFETCH keeps the grant list it already had, not the error", asyn view.queryByText("This deployment has not enabled an app by that name."), ).toBeNull(); }); + +/** + * The actions end of one action's row: its switch, and whatever is drawn beside it. + * + * Reached through the switch's own label because a row carries no test id, and the label is the + * only thing on it that names the action uniquely. The roster never comes back in this file, so the + * Bot's name in that label falls back to its id, which is what the screen itself does. + */ +function rowActions(view: ReturnType, name: string) { + const actions = view + .getByLabelText(`Let ${BOT_ID} call ${name}`) + .closest('[data-slot="item-actions"]'); + if (!actions) throw new Error(`No row drawn for ${name}.`); + return actions; +} + +test("an action the vendor calls destructive is marked, and a write it says nothing about is not", async () => { + const queryClient = stalePluginsClient( + pluginsPage({ + servers: [ + server({ + id: APP_KEY, + tools: [ + tool({ effect: "write", name: "post_message" }), + tool({ + destructive: true, + effect: "write", + name: "delete_channel", + }), + ], + }), + ], + }), + ); + + const view = renderScreen(queryClient); + await waitForFailedRefetch(queryClient); + await view.findByText("delete_channel"); + + // The mark itself, on the row that earned it: the heading already says these rows change things, + // and this is the one saying what this row changes does not come back. + expect(rowActions(view, "delete_channel").textContent).toContain( + "destroys things", + ); + + // The other half, which matters as much. `destructive: false` is the vendor having made no claim, + // not a claim that nothing is lost — so nothing at all goes beside this switch. A word here, of + // any colour, would be the page vouching for an action on evidence it does not have. + expect(rowActions(view, "post_message").textContent).toBe(""); +}); From 71af5ba859e6327a4ff3921e55a175df8aab757c Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:19:15 +0200 Subject: [PATCH 123/215] Hold the broker's catalogue for ten minutes instead of asking per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's search goes to this deployment, which asked the broker for its entire directory — several hundred apps — and every distinct term was its own round trip. Typing an app's name pulled the whole catalogue over and over, and enabling one pulled it again to check the slug. What is held is the REQUEST rather than the rows, and written into the slot before it is awaited, so several people opening the picker at once share one fetch instead of each starting their own. Holding resolved rows would have missed exactly the case the cache exists for: nothing is cached yet when the burst arrives. A REFUSAL IS NOT HELD. The likely first failure here is an unset or mistyped key, and the operator who fixes it presses the button again seconds later — a remembered refusal would keep refusing for ten minutes with nothing left to fix. Callers already sharing the one request do share its failure, which is the truth about when they asked. The enable route reads the same held answer, and that is a decision rather than a concession: the slug it validates came out of this same catalogue moments earlier, and the check exists to refuse one the broker never published. What it gives up is an app withdrawn inside the window, which costs a row for an app that answers nothing, removable on the page that added it. The design also asked for an explicit reload. That is not built, and the comment says so rather than implying otherwise: nothing in this deployment reloads a catalogue, and an invalidation nobody calls is an untested path that reads like a guarantee. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 176 +++++++++++++++++++------ server/tests/composio-adapter.test.ts | 155 ++++++++++++++++++++++ 2 files changed, 293 insertions(+), 38 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 3d4208b6c..3bd9ee376 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -179,6 +179,25 @@ export type ComposioVendor = { */ const CONFIG_SUFFIX = "(OpenBot)"; +/** + * How long one catalogue answer is served to everybody who asks for it. + * + * THE CATALOGUE IS READ ONCE PER SEARCH KEYSTROKE OTHERWISE, WHICH IS WHAT THIS IS ABOUT. The admin + * picker debounces its search field and then asks `/composio/apps`, and that route filters in this + * process precisely because Composio's toolkit listing takes no search term — so every distinct term + * a person types is another request for the whole directory, a few hundred rows of it, to answer a + * question about one. Typing "linear" pulls the catalogue four or five times, and enabling the app + * afterwards pulls it once more. + * + * TEN MINUTES BECAUSE OF WHAT GOES STALE IN IT. The rows are Composio's published toolkits: an app + * is added to their catalogue or its action count moves every so often, never within one + * administrator's sitting, and the worst a stale row can do here is show a description or a count + * that is a few minutes behind. Held for a working session it would be a cache nobody could explain + * to an operator whose new app is missing; held for seconds it would not survive the debounce it + * exists for. + */ +const DIRECTORY_TTL_MS = 10 * 60 * 1000; + /** * Both seams, over one vendor client. * @@ -189,8 +208,18 @@ const CONFIG_SUFFIX = "(OpenBot)"; * * The vendor is captured in a closure rather than stored on either returned object, so neither * `actions` nor `broker` offers a route back to the client or to the key it holds. + * + * @param now The clock the catalogue's lifetime is measured against, injected for the same reason + * the vendor is. A test that could not move the clock could only assert the cache's hit by counting + * calls and would have to sleep ten minutes to assert its expiry, so the window would be the one + * thing here no test could reach. It is a parameter of the builder rather than of + * {@link ComposioBroker.listApps}, because the seam's callers are routes and none of them has an + * opinion about what time it is. */ -export function buildComposioClient(vendor: ComposioVendor): { +export function buildComposioClient( + vendor: ComposioVendor, + now: () => number = Date.now, +): { actions: ComposioActions; broker: ComposioBroker; } { @@ -232,6 +261,66 @@ export function buildComposioClient(vendor: ComposioVendor): { return answer.items; }; + /** + * The catalogue answer this process is currently serving, and the moment it was asked for. + * + * A PROMISE RATHER THAN THE ROWS, WHICH IS THE WHOLE ANSWER TO CONCURRENCY. The entry is written + * before the request is answered, so a second caller arriving while the first is still in flight + * finds it and awaits the same request. Holding the resolved rows instead would leave the window + * this cache exists to close wide open: three people opening the picker together, or one person's + * debounce firing twice, are exactly the case where nothing is cached yet, and each of them would + * start their own catalogue fetch and then overwrite each other's answer. + * + * IT IS PER BUILT CLIENT, not per module. This adapter is built once per deployment key, so in + * this process that is one cache; in a test it is one cache per {@link buildComposioClient}, which + * is what lets each test below start from nothing without an API for emptying it. + */ + let heldDirectory: { at: number; apps: Promise } | null = null; + + /** + * The catalogue as the vendor answers it, mapped to the rows an administrator chooses from. + * + * ONE PAGE AT THE CEILING, SORTED BY USAGE, AND NO SEARCH TERM. + * + * The order matters because the page is finite: at the ceiling the apps most likely to be wanted + * are the ones that must not be the ones cut off. The absent search term is the subtler half — + * the SDK's list params name no search field + * (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, `src/types/toolkit.types.ts:9-15`) + * and the parse strips what it does not name, so a term passed here would vanish before the + * request and leave a caller believing they had filtered a list nobody filtered. Searching the + * catalogue is this deployment's own job, over the rows below. + */ + const fetchDirectory = async (): Promise => { + const toolkits = await vendor.toolkits.get({ + limit: LISTING_LIMIT, + sortBy: "usage", + }); + return toolkits.map((toolkit) => ({ + slug: toolkit.slug, + name: toolkit.name, + /* + * Each absence becomes the value that reads honestly on an administrator's screen. An empty + * description shows as no description; a null logo is the field's documented way of saying + * the vendor published none, which renders as a gap rather than as a broken image. + * + * The categories are the DISPLAY names rather than the slugs, because this list is read by a + * person choosing an app and "Productivity" is what they are choosing by. + * + * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a + * number and the shape offers no way to say "not published", so a toolkit that publishes no + * count reads as an app with no actions. It is the conservative direction — it understates + * the size of a change rather than overstating it — and Composio publishes a count for every + * toolkit measured, so this is a guard against the vendor rather than a routine case. + */ + description: toolkit.meta.description ?? "", + logo: toolkit.meta.logo ?? null, + categories: (toolkit.meta.categories ?? []).map( + (category) => category.name, + ), + actionCount: toolkit.meta.toolsCount ?? 0, + })); + }; + const actions: ComposioActions = { async listActions(toolkit, page): Promise { /* @@ -303,47 +392,58 @@ export function buildComposioClient(vendor: ComposioVendor): { }; const broker: ComposioBroker = { + /** + * The catalogue, from memory where this process asked for it less than ten minutes ago. + * + * BOTH CALLERS READ THE SAME HELD ANSWER, AND THE SECOND OF THEM IS THE INTERESTING ONE. The + * search route filters the directory in this process, so caching it is what stops a debounced + * search field from pulling a few hundred rows once per term. The enable route then reads the + * directory again to check that the slug it was handed is one Composio lists, and that read + * comes out of the same cache — which is the right answer rather than a concession, because + * the slug being checked is one this deployment handed the browser out of THIS cache moments + * earlier. The check exists to refuse a slug the catalogue never published — a request composed + * by hand, or a row left over from a url somebody edited — and a ten-minute-old catalogue + * settles that question exactly as well as a fresh one. The case it gives up is an app Composio + * withdrew within the window, whose cost is one `mcp_servers` row for an app that answers + * nothing, removable on the page that added it; the case it buys is that pressing Add does not + * re-read a catalogue the picker just read. + * + * A FAILURE IS NEVER HELD. The entry is dropped when its request rejects, so a vendor that + * refused once is asked again by the next caller rather than refusing from memory for ten + * minutes — the failures here are an unset or wrong API key and Composio being down, and the + * first two are fixed by an operator who then presses the button again, which must be allowed + * to work. The callers already sharing that one in-flight request do share its failure, which + * is the truth about their request: they asked while it was being answered. + * + * THERE IS NO INVALIDATION, AND THE DESIGN ASKED FOR ONE. It wanted the directory "refreshable + * by an explicit reload", and that is not built: the lifetime above is the whole of the + * freshness story. Nothing in this deployment reloads a catalogue today — no page, route or job + * has such a control — so the method would have no caller, and an invalidation API with no + * caller is an untested path that reads like a guarantee. The moment a reload button exists, + * this is where it attaches. + */ async listApps(): Promise { + const held = heldDirectory; + if (held && now() - held.at < DIRECTORY_TTL_MS) return held.apps; + /* - * ONE PAGE AT THE CEILING, SORTED BY USAGE, AND NO SEARCH TERM. - * - * The order matters because the page is finite: at the ceiling the apps most likely to be - * wanted are the ones that must not be the ones cut off. The absent search term is the - * subtler half — the SDK's list params name no search field - * (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, `src/types/toolkit.types.ts:9-15`) - * and the parse strips what it does not name, so a term passed here would vanish before the - * request and leave a caller believing they had filtered a list nobody filtered. Searching - * the catalogue is this deployment's own job, over the rows below. + * Stamped when the request goes out rather than when it comes back, so a slow catalogue is + * held for slightly less than the full window rather than for the window plus its own + * latency. Written into the slot before it is awaited, which is what a concurrent caller + * finds. */ - const toolkits = await vendor.toolkits.get({ - limit: LISTING_LIMIT, - sortBy: "usage", + const entry = { at: now(), apps: fetchDirectory() }; + heldDirectory = entry; + /* + * The drop on failure, registered here rather than written as a try/catch around an await so + * that this method hands every caller the one shared promise. `heldDirectory === entry` + * because a later request may already have replaced this one, and clearing that would throw + * away a good answer over an old failure. + */ + entry.apps.catch(() => { + if (heldDirectory === entry) heldDirectory = null; }); - return toolkits.map((toolkit) => ({ - slug: toolkit.slug, - name: toolkit.name, - /* - * Each absence becomes the value that reads honestly on an administrator's screen. An - * empty description shows as no description; a null logo is the field's documented way of - * saying the vendor published none, which renders as a gap rather than as a broken image. - * - * The categories are the DISPLAY names rather than the slugs, because this list is read - * by a person choosing an app and "Productivity" is what they are choosing by. - * - * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a - * number and the shape offers no way to say "not published", so a toolkit that publishes - * no count reads as an app with no actions. It is the conservative direction — it - * understates the size of a change rather than overstating it — and Composio publishes a - * count for every toolkit measured, so this is a guard against the vendor rather than a - * routine case. - */ - description: toolkit.meta.description ?? "", - logo: toolkit.meta.logo ?? null, - categories: (toolkit.meta.categories ?? []).map( - (category) => category.name, - ), - actionCount: toolkit.meta.toolsCount ?? 0, - })); + return entry.apps; }, async ensureAuthConfig({ toolkit, name }): Promise { diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 9b6a6e6dc..77190b5f0 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -177,6 +177,161 @@ describe("the app catalogue", () => { }); }); +/** + * The catalogue's lifetime, asserted by counting what the vendor was asked rather than what came + * back. + * + * THE COST BEING AVOIDED IS NOT HYPOTHETICAL. The admin picker's search field debounces and then + * asks `/composio/apps`, which filters the whole directory in this process because Composio's + * toolkit listing takes no search term — so without a cache each distinct term a person types pulls + * a few hundred rows over the wire, and pressing Add pulls them once more. Every test here therefore + * asserts a CALL COUNT: an implementation that answered correctly and asked five times would pass + * any assertion that only looked at the rows. + * + * The clock is the builder's second argument, which is why these can be written at all. Each + * {@link buildComposioClient} holds its own cache, so a test starts from an empty one by building, + * and moves time by assigning rather than by waiting ten minutes. + */ +describe("holding the catalogue", () => { + /** The one row these tests map, kept out of the way of what they are actually asserting. */ + const GMAIL = { + slug: "gmail", + name: "Gmail", + meta: { description: "Send and read mail.", toolsCount: 63 }, + }; + const GMAIL_ROW = { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: null, + categories: [], + actionCount: 63, + }; + + test("a second listing inside the window asks the vendor nothing", async () => { + let calls = 0; + let clock = 1_000_000; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => { + calls += 1; + return [GMAIL]; + }, + }, + }), + () => clock, + ); + + const first = await broker.listApps(); + // Nine minutes is a person searching, choosing and enabling: the whole interaction this cache + // exists for happens inside one window. + clock += 9 * 60 * 1000; + const second = await broker.listApps(); + + expect(calls).toBe(1); + expect(first).toEqual([GMAIL_ROW]); + expect(second).toEqual([GMAIL_ROW]); + }); + + test("a listing after the window asks again, and answers with what it just read", async () => { + let calls = 0; + let clock = 1_000_000; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => { + calls += 1; + // The catalogue moves between the two reads, which is the only way to tell a second + // request apart from a cache that happened to be asked twice. + return calls === 1 + ? [GMAIL] + : [ + GMAIL, + { slug: "linear", name: "Linear", meta: { toolsCount: 12 } }, + ]; + }, + }, + }), + () => clock, + ); + + await broker.listApps(); + clock += 10 * 60 * 1000 + 1; + const later = await broker.listApps(); + + expect(calls).toBe(2); + expect(later).toEqual([ + GMAIL_ROW, + { + slug: "linear", + name: "Linear", + description: "", + logo: null, + categories: [], + actionCount: 12, + }, + ]); + }); + + test("callers arriving while a listing is in flight share the one request", async () => { + let calls = 0; + let answer: (toolkits: unknown[]) => void = () => {}; + const inFlight = new Promise((resolve) => { + answer = resolve; + }); + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: () => { + calls += 1; + return inFlight; + }, + }, + }), + () => 1_000_000, + ); + + // Not awaited between the two, because that is the case: three people opening the picker + // together, or one debounce firing twice, all arrive before the first answer exists. A cache + // that held the ROWS rather than the request would be empty for every one of them. + const both = Promise.all([broker.listApps(), broker.listApps()]); + answer([GMAIL]); + const [first, second] = await both; + + expect(calls).toBe(1); + expect(first).toEqual([GMAIL_ROW]); + expect(second).toEqual([GMAIL_ROW]); + }); + + test("a refusal is not held, so the next caller asks the vendor again", async () => { + let calls = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => { + calls += 1; + // The real first failure here is a key that is unset or wrong, and the operator who + // fixes it presses the button again within seconds. A cached refusal would keep + // refusing for ten minutes with nothing left to fix. + if (calls === 1) throw new Error("Composio refused the catalogue."); + return [GMAIL]; + }, + }, + }), + () => 1_000_000, + ); + + await expect(broker.listApps()).rejects.toThrow(/refused/); + // The clock has not moved: the window is still open and it is the FAILURE rather than the + // window that must not be remembered. + const recovered = await broker.listApps(); + + expect(calls).toBe(2); + expect(recovered).toEqual([GMAIL_ROW]); + }); +}); + describe("executing an action", () => { test("an action belonging to another app is refused before anything is sent", async () => { const executed: unknown[] = []; From 8869a8fab35ef964f68bfaf9ed9103c7449458b1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:19:15 +0200 Subject: [PATCH 124/215] Ask for one row's address instead of the whole plugin surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four routes loaded every server, every tool and every grant in the deployment to learn one row's url. Confirming is the sharp end: both account cards ask it on mount, so opening a large app's page read the entire tool and grant table to answer whether one row is brokered. The connect route paid it before its branch, so the OAuth flow beside it — which this work never touched — paid it too. Two reads rather than one, and the split is the point. The three per-id callers know the id, so a primary-key lookup answers them whatever the deployment holds. The directory has no id at all and needs strictly less: the app comes off the url, so handing that caller an id as well would put back the very field it must never read the app out of. Answering urls and nothing else makes that structural rather than a convention somebody has to keep. Cost only. An id naming no row still refuses the same way, a row that is not brokered still falls through to the consent flow, and a refusal still names the app's title. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 28 +++++--- server/src/plugins/store.ts | 66 +++++++++++++++++++ server/tests/plugin-connect-route.test.ts | 5 +- server/tests/plugin-oauth-callback.test.ts | 2 +- server/tests/plugin-routes.test.ts | 61 +++++++++-------- server/tests/plugin-store.integration.test.ts | 50 ++++++++++++++ 6 files changed, 171 insertions(+), 41 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 0bcbfce97..45a063100 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -483,10 +483,11 @@ export function createPluginRoutes( * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written * here. * - * `enabled` comes off `toolkitOf(server.url)` and never off the row's id. The url is where the + * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the * transport reads which app a call is against, so it is the only reading that decides anything; * the id names the row — `composio-linear` — and reading one as the other would quietly work - * until somebody renamed a row. + * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that + * property made structural: there is no id here to read by mistake. */ routes.get("/composio/apps", requireUser, async (context) => { /* @@ -523,8 +524,8 @@ export function createPluginRoutes( : directory; const enabled = new Set( - (await store.listServers()) - .map((server) => toolkitOf(server.url)) + (await store.serverUrls()) + .map((url) => toolkitOf(url)) .filter((toolkit): toolkit is string => toolkit !== null), ); return context.json({ @@ -687,10 +688,14 @@ export function createPluginRoutes( * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the * directory route says: the url is where the transport reads which app a call is against, and * the id is a row name that happens to look similar. + * + * ONE ROW, BY ID. Every request to this route pays for this read, including the ones that fall + * through to the OAuth flow below, because the branch cannot be taken until the row is in hand + * — so what it costs has to be a lookup of three columns rather than the whole plugin surface. + * `serverAddress` answering `undefined` is an id naming no row, which falls through exactly as + * a missing row did when this was a `.find`. */ - const row = (await store.listServers()).find( - (server) => server.id === serverId, - ); + const row = await store.serverAddress(serverId); const toolkit = row ? toolkitOf(row.url) : null; if (row && toolkit) { if (!composio) { @@ -908,6 +913,11 @@ export function createPluginRoutes( * the connect branch above both give: the url is where the transport reads which app a call is * against, and the id is a row name that happens to look similar. * + * ONE ROW, BY ID, AND CONFIRM IS WHY. Both brokered account screens call that route from an + * effect when they mount, so this read runs on every page load — and it used to be + * `listServers`, which materialises every tool and every grant in the deployment to answer + * whether one row is brokered. + * * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this * deployment really has — what is wrong is that its connection does not live at Composio, and * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an @@ -926,9 +936,7 @@ export function createPluginRoutes( | { toolkit: string; refusal?: undefined } | { toolkit?: undefined; refusal: { error: string; status: 400 | 503 } } > => { - const row = (await store.listServers()).find( - (server) => server.id === serverId, - ); + const row = await store.serverAddress(serverId); const toolkit = row ? toolkitOf(row.url) : null; if (!toolkit) { return { diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 3dc3d5fb3..f87a3ce3e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -158,6 +158,16 @@ export type ServerRecord = { withdrawn: WithdrawnGrant[]; }; +/** + * A server row as the surfaces that only need to know where it is see it. + * + * Three columns of {@link ServerRecord} and none of what hangs off it, because the callers this is + * for ask one question: which vendor is this row addressed at. The title travels with the url + * because their refusals name it — "You already have an account connected to Linear" is the app's + * name, which is the only one of the two a person has ever seen on a screen. + */ +export type ServerAddress = { id: string; title: string; url: string }; + export type SkillRecord = { id: string; slug: string; @@ -3108,6 +3118,62 @@ export function createPluginStore(options: PluginStoreOptions) { }); }, + /** + * Where one server is, by id, and nothing that hangs off it. + * + * WHY IT EXISTS BESIDE {@link listServers}. Three routes asked that one for a single row's url + * — the brokered branch of connect, and the confirm and disconnect pair behind + * `brokeredAppFor` — and it answers by running three queries and materialising every server, + * every tool and every grant in the deployment. Confirm is the sharp end: both brokered account + * screens call it from an effect on mount, so opening a large app's page read the whole tool and + * grant table to ask whether one row is brokered. None of the three looks at a tool or a grant. + * + * THE URL IS THE ROW'S OWN, read out of the column rather than composed from the id. The whole + * brokered feature rests on the two being allowed to differ: `addBrokeredApp` writes + * `composio://` and names the row for it, but nothing holds them equal afterwards, and a + * row called `gmail` at `composio://slack` is exactly the shape the connection gate was once + * keyed on the wrong half of. The catalogue reconciliation {@link effectiveUrl} applies for + * `listServers` is deliberately not applied here, and changes no answer: it only ever + * substitutes the pinned host of a first-party entry, and neither reading of such a row names a + * Composio app. + * + * `undefined` for an id naming no row, which is what the `.find` over the whole list answered + * before — so a route that refused an unknown id still refuses it, in the same words. + */ + async serverAddress(serverId: string): Promise { + const [row] = await database + .select({ + id: mcpServers.id, + title: mcpServers.title, + url: mcpServers.url, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + return row; + }, + + /** + * Where every added server is, for the one caller whose question is about the whole set. + * + * A SECOND READ RATHER THAN {@link serverAddress} IN A LOOP, and rather than one method serving + * both. The app directory asks which of Composio's apps are already enabled here, which has no + * id to look up — answered a row at a time it would be one query per app in the directory. And + * it needs strictly less than the row read hands back: the app comes off the url, so an id and + * a title would be nothing but two fields a reader could take the app out of by mistake. The + * directory route's own comment says why that matters — the url is where the transport reads + * which app a call is against, and the id is a row name that happens to look similar. + * + * Ordered, so two readings of an unchanged deployment answer alike. + */ + async serverUrls(): Promise { + const rows = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .orderBy(asc(mcpServers.id)); + return rows.map((row) => row.url); + }, + /** * The skills this person may see: the deployment's, plus their own. * diff --git a/server/tests/plugin-connect-route.test.ts b/server/tests/plugin-connect-route.test.ts index bbaf50613..aa995c3e5 100644 --- a/server/tests/plugin-connect-route.test.ts +++ b/server/tests/plugin-connect-route.test.ts @@ -44,9 +44,10 @@ function app(store: { /* * The read that decides whether this is a brokered row, which the handler makes before * anything about the consent flow. None of these vendors is one: a brokered app is reached - * through Composio and enters none of the flow these tests are about. + * through Composio and enters none of the flow these tests are about. `undefined` is an id + * naming no row, which falls through to the flow below exactly as a non-brokered row does. */ - listServers: async () => [], + serverAddress: async () => undefined, ...store, } as never, signedIn(), diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts index a66b75c9b..89f08936f 100644 --- a/server/tests/plugin-oauth-callback.test.ts +++ b/server/tests/plugin-oauth-callback.test.ts @@ -58,7 +58,7 @@ function app(input: { const store = { // The brokered read the connect handler makes before anything about the consent flow. This // deployment has no brokered rows, so it answers nothing and the flow below is untouched. - listServers: async () => [], + serverAddress: async () => undefined, oauthClientFor: async () => ({ clientId: "dyn-1", clientSecret: "" }), ensureOAuthClient: async () => ({ clientId: "dyn-1", clientSecret: "" }), recordConnection: diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 80198c699..3bfae1fd8 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -601,8 +601,9 @@ function directoryApp( ) { const added: Array<{ slug: string; title: string; by: string }> = []; const store = { - // Every read the plugins surface makes on its way to the route under test. - listServers: async () => servers, + // Every read the plugins surface makes on its way to the route under test. The directory asks + // for urls and is handed urls: the rows below carry an id as well, and the route never sees it. + serverUrls: async () => servers.map((server) => server.url), listSkills: async () => [], listGrants: async () => [], addBrokeredApp: async (input: { @@ -669,7 +670,8 @@ describe("the Composio directory", () => { test("an app is enabled by the url of the row, not by the row's id", async () => { // Which app a row is comes off its url and only off its url, because that is where the // transport reads it from. An id read as an app name is a different question wearing the same - // answer's clothes. + // answer's clothes — and the read behind this route now hands over urls alone, so the id below + // is one the route could not consult even if it wanted to. const { app } = directoryApp(undefined, "admin", [ { id: "an-id-nobody-should-read", url: "composio://slack" }, ]); @@ -968,7 +970,7 @@ const WRONG_KEY = Object.assign(new Error("Request failed"), { /** * One brokered app, added, and the person connecting their own account to it. * - * WHICH APP THIS IS COMES OFF THE ROW'S URL. `listServers` answers one row whose url is + * WHICH APP THIS IS COMES OFF THE ROW'S URL. `serverAddress` answers one row whose url is * `composio://linear`, and the branch under test reads the app out of it with `toolkitOf` rather * than off the id — the id is a row name (`composio-linear`) and reading one as the other works * right up until somebody renames a row. @@ -1010,31 +1012,34 @@ function brokeredApp( reason: string; }> = []; + const rows = [ + { + id: "composio-linear", + // The app's name, which is the one of the two a person has ever seen. The refusal below + // reads it off the row, and the id beside it is what that refusal used to quote instead. + title: "Linear", + url: "composio://linear", + }, + /* + * An ordinary OAuth row, so that "this app is not brokered" is a real row and not a missing + * one. The two routes below answer the same way for both, and this is the half that would + * otherwise go untested: an id naming nothing at all is easy to refuse, while a server this + * deployment really has whose connection simply does not live at Composio is where a + * confusing answer would come from. + */ + { id: "notion", title: "Notion", url: "https://notion.test/mcp" }, + ]; + const store = { - // Every read the plugins surface makes on its way to the route under test. - listServers: async () => [ - { - id: "composio-linear", - // The app's name, which is the one of the two a person has ever seen. The refusal below - // reads it off the row, and the id beside it is what that refusal used to quote instead. - title: "Linear", - url: "composio://linear", - provenance: "composio", - }, - /* - * An ordinary OAuth row, so that "this app is not brokered" is a real row and not a missing - * one. The two routes below answer the same way for both, and this is the half that would - * otherwise go untested: an id naming nothing at all is easy to refuse, while a server this - * deployment really has whose connection simply does not live at Composio is where a - * confusing answer would come from. - */ - { - id: "notion", - title: "Notion", - url: "https://notion.test/mcp", - provenance: "curated", - }, - ], + /* + * Every read the plugins surface makes on its way to the route under test. + * + * Three columns, looked up by id, because that is all these routes ask for: the url they read + * the app out of and the title a refusal names. The whole server list is what they used to ask + * for, and a stub that still answered one would be pretending they need more than they do. + */ + serverAddress: async (serverId: string) => + rows.find((row) => row.id === serverId), listSkills: async () => [], listGrants: async () => [], brokeredConnection: async (input: { toolkit: string; userId: string }) => { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 853ecdc62..5f4c3ef94 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4812,6 +4812,56 @@ test("the Plugins page shows a brokered action with the effect the vendor record ).toEqual([{ name: "GMAIL_FETCH_EMAILS", effect: "read" }]); }); +/** + * The narrow read the brokered routes make, and the one thing it must not do. + * + * CRITERION. `serverAddress` answers the url the ROW holds, whatever the row is called. + * + * REASON. Everything brokered rests on the id and the url being allowed to differ: `addBrokeredApp` + * writes `composio://` and names the row for it, and nothing afterwards holds the two equal. + * The routes read the app out of the url for exactly that reason, so a read that answered them with + * `composio://${id}` — composed, and always agreeing with the id — would put the mismatch back + * underneath four call sites at once and read as correct on every row anybody had not renamed. The + * fixture is seeded with the disagreement on purpose, which is what `seedComposioGmail`'s own `url` + * option exists for. + * + * The url listing is asserted off this fixture rather than out of a case of its own: it answers the + * directory's set question from the same column, and the property worth pinning is the same one. + */ +test("a server's address is the url the row holds, not one composed from its id", async () => { + const { store, database } = await freshStore(); + // A row called `gmail` pointing at Slack, which is the shape the connection gate was once keyed + // on the wrong half of. + await seedComposioGmail(database, store, { url: "composio://slack" }); + + expect(await store.serverAddress("gmail")).toEqual({ + id: "gmail", + title: "Gmail", + // Not `composio://gmail`. That is what composing the url from the id would have answered, and + // it is the app this row does not run against. + url: "composio://slack", + }); + /* + * By membership rather than by equality, because this database is not only this test's: the + * suite's own `google-drive` row is there, and on a deployment somebody is using so is whatever + * they have added. What is being asserted is which of the two spellings of this row reaches the + * directory, and that survives the company. + */ + const urls = await store.serverUrls(); + expect(urls).toContain("composio://slack"); + expect(urls).not.toContain("composio://gmail"); +}); + +test("a server id naming no row is answered with nothing", async () => { + const { store, database } = await freshStore(); + await seedComposioGmail(database, store); + + // `undefined` rather than a throw or an empty row, because that is what the `.find` over the + // whole server list answered before — and the routes turn it into the 400 that says this app is + // not reached through a broker. + expect(await store.serverAddress("composio-gmail")).toBeUndefined(); +}); + test("refreshing a Composio app records each action's effect, destructive marker and version", async () => { const { store, database } = await freshStore(); useComposioClient({ From ea559386b3358d814114ae3bb2efa0490dd875f0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 13:31:30 +0200 Subject: [PATCH 125/215] Ship the broker's SDK, which every container was about to boot without MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package was a development dependency, added when the only thing that imported it was a test skipped without a key. Wiring the adapter into startup made it a runtime import — unconditional, before any key is read — while the runtime image still installs with production dependencies only. So every containerised deployment would have failed to boot, with a key or without one, and nothing in an ordinary pull request run would have said so: the job that builds the image is behind a label. Verified rather than reasoned about. A production install from these manifests resolves the package now and did not before. One claim in the connector's documentation corrected while here. It said the sentence about this deployment's grants being the entire narrowing appears on the app's own screen. It does not; it appears in the documentation, which now says so and points out that somebody reading only the screen will not meet it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- bun.lock | 2 +- docs/plugins/composio.md | 5 ++++- server/package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 462f1e57f..bf28de4f6 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -79,7 +80,6 @@ "zod": "^4.4.3", }, "devDependencies": { - "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7", diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index ea1a78010..eda5659fc 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -158,11 +158,14 @@ find the connection after the person is gone. The authorization config keeps Composio's default scopes. Narrowing them properly would mean choosing scopes before anybody has been granted anything, which is backwards, so it is not done -pre-emptively. Stated plainly, and stated in these words on the app's own page: +pre-emptively. Stated plainly: > **The vendor-side grant is as wide as Composio's own app asks for, and this deployment's grants > are the entire narrowing.** +This page is where that is stated. The app's own screen does not repeat it — worth knowing, because +an operator who reads only the screen will not meet it. + This is the position Notion is already in. What keeps a Bot's reach small is which actions are switched on for it, and nothing at the vendor stands behind that. diff --git a/server/package.json b/server/package.json index 8a963497c..75153261f 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.59", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@composio/core": "^0.18.1", "@copilotkit/runtime": "1.70.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -28,7 +29,6 @@ "zod": "^4.4.3" }, "devDependencies": { - "@composio/core": "^0.18.1", "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7" From 396ba8b01a139fb1ef70d1f1c7b94b7f93c3f4c8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 16:33:33 +0200 Subject: [PATCH 126/215] Actually revoke the grant, and stop claiming more than was done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENDING AN ACCOUNT DID NOT END IT. The call this deployment made to withdraw somebody's access soft-deletes at the broker: it stops us using the account and preserves the record, and the grant at Google or Slack stays live unless the request asks for the upstream credentials to be revoked. It did not ask. So every path that promised to end access where it lives — a person disconnecting, an administrator removing an app, a person being offboarded — left the real grant standing, and the trail recorded that we had withdrawn it. Worse than it first looked: the vendor's own wrapper hard-codes the body it sends, so the flag could not be passed through it at all and the two deletes now reach the client underneath. The authorization-config delete carried the same trap. AND THE BOOLEAN NOW MEANS WHAT IT SAYS. With the flag set the upstream withdrawal runs as a background job the vendor documents no way to poll, so completion is not knowable when the trail is written. The field is `vendorRevocationRequested` throughout — the account is gone at the broker synchronously, the withdrawal was asked for and accepted, and nothing here claims to have watched it finish. The prose in the store, the audit field's own documentation and the disconnect route were rewritten to match rather than left implying completion. Four more ways this deployment was talking past the vendor, all found by reading the code cold: The wrong authorization config. Both the delete and the consent link took the first row of an unordered listing and never read the suffix this file writes to mark its own — so removing an app could delete a config an operator made by hand, with every account on it, and a consent link could attach somebody to a config nobody here created. Ours are now identified by that suffix, disabled ones are visible, and a config that is not ours is left alone rather than adopted or destroyed. Too narrow a question. The account listing asked only for private accounts, whose default it was relying on, and only for the active ones. A shared account read as not connected; an in-flight grant was invisible to the withdrawal that should have caught it. The two questions now ask for what each actually needs, which is not the same breadth. A partial withdrawal reported as none. The deletes ran one at a time with no partial-failure path, so a throw part-way withdrew some grants and then abandoned the caller, recording nothing for anybody — including the people whose access really had ended. A catalogue that could be silently short. The directory took one page at the ceiling with no refusal, while the action listing beside it refuses exactly that and explains why at length. A truncated catalogue was then held for ten minutes and made the enable route tell an administrator that a real app is not one the broker lists. The refusals this file writes are now recognisable to the route that maps them, so a missing config and a vendor outage stop producing the same wrong advice. The held catalogue is copied per caller rather than shared mutable. Ten adapter tests became twenty-four, and every new assertion was proven by breaking the thing it guards and watching it go red. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/audit.ts | 13 +- server/src/plugins/broker.ts | 89 ++- server/src/plugins/composio-adapter.ts | 535 +++++++++++++++--- server/src/plugins/routes.ts | 25 +- server/src/plugins/store.ts | 91 +-- server/tests/composio-adapter.test.ts | 492 +++++++++++++++- server/tests/composio-connections.test.ts | 4 +- server/tests/plugin-routes.test.ts | 4 +- server/tests/plugin-store.integration.test.ts | 40 +- 9 files changed, 1126 insertions(+), 167 deletions(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index ada81df10..cb3c7a984 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -181,10 +181,15 @@ export const auditEventTypes = [ * their access". `reason` distinguishes somebody disconnecting their own account from an * administrator removing them, because those are the same effect and very different events. * - * `vendorRevoked` says whether the grant at the vendor was withdrawn as well, and is currently - * false: removing somebody stops this deployment holding a usable secret, and the grant at Google - * outlives it until it is revoked there. Recorded rather than glossed, because a row that implied - * otherwise would be worse than no row. + * `vendorRevocationRequested` says whether the grant at the vendor was asked to be withdrawn as + * well. It is false for every credential this deployment holds in its own vault: removing + * somebody stops us holding a usable secret, and the grant at Google outlives it until it is + * revoked there, which nothing on that path asks for. It is true for a brokered account whose + * withdrawal Composio accepted — accepted rather than completed, because the upstream revocation + * runs as a background job with no supported way to poll it, which is why the field is named for + * the ask. Recorded rather than glossed, because a row that implied otherwise would be worse + * than no row, and this field once did exactly that: it was called `vendorRevoked` and said true + * while the grant at Google stood untouched. */ "mcp.account_disconnected", // Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index e0da3fce9..3e999820e 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -62,7 +62,14 @@ export type ComposioBroker = { * on the second call would leave a person's existing connections pointing at the first one. */ ensureAuthConfig(config: { toolkit: string; name: string }): Promise; - /** Drop this deployment's auth config for the app, which is what removing an app has to do. */ + /** + * Drop this deployment's own auth configs for the app, which is what removing an app has to do. + * + * ITS OWN, WHICH IS A NARROWER PROMISE THAN "THE APP'S". An auth config lives in an operator's + * Composio dashboard beside any they made by hand there, and removing an app from these pages is + * not a mandate to delete somebody's dashboard work. An implementation has to be able to tell the + * two apart before it deletes anything, and to leave anything it cannot claim. + */ deleteAuthConfig(toolkit: string): Promise; /** * Begin one person's connection to one app, answering the url they have to visit. @@ -95,17 +102,57 @@ export type ComposioBroker = { /** Whether this person currently has an account attached to this app at the vendor. */ isConnected(request: { userId: string; toolkit: string }): Promise; /** - * Withdraw this person's grant at the vendor, answering WHAT ACTUALLY HAPPENED. + * Ask the vendor to withdraw this person's grant, answering WHAT WAS ACTUALLY ASKED. + * + * True where this deployment found at least one account and asked the vendor to revoke it, false + * where there was none to withdraw — not "the call did not throw". The audit trail records that + * answer as `mcp.account_disconnected`'s `vendorRevocationRequested`, and the whole value of that + * field is that a reader can tell an account this deployment acted on from one that outlives it + * somewhere else. A boolean that always said true would make the row a worse record than no row. * - * True where a grant was withdrawn, false where there was none to withdraw — not "the call did - * not throw". The audit trail records that answer as `mcp.account_disconnected`'s - * `vendorRevoked`, and the whole value of that field is that a reader can tell a grant this - * deployment ended from one that outlives it somewhere else. A boolean that always said true - * would make the row a worse record than no row. + * "REQUESTED" IS AS FAR AS ANY IMPLEMENTATION CAN HONESTLY GO, and the name of the field says so + * because the first one did not. This boolean used to be called `vendorRevoked` and was written + * by an adapter that soft-deleted the account and asked for no revocation at all, so a trail that + * said a grant had been withdrawn recorded one that was still live at Google. What a broker can + * promise synchronously is that the account is gone at the broker — nothing here can call with it + * again — and that the upstream withdrawal was asked for; whether the provider honoured it + * happens afterwards, out of sight of the call that asked. A field that claimed the stronger + * thing would be the one row in the trail nobody could rely on. + * + * A PARTIAL ASK IS A FAILURE RATHER THAN A TRUE. One person can hold more than one account for + * one app, and an implementation that ended some of them and could not end the rest has not + * disconnected anybody: their app still answers. It must throw, so that the row this deployment + * holds — the only thing that names which app to try again against — is still standing when they + * press disconnect a second time. */ revoke(request: { userId: string; toolkit: string }): Promise; }; +/** + * A refusal this deployment authored, whose own message is the whole explanation. + * + * THE ROUTE CANNOT TELL AN AUTHORED REFUSAL FROM A VENDOR OUTAGE WITHOUT A TYPE, which is the only + * reason this class exists. `routes.ts` answers a thrown broker error by reaching into it for the + * vendor's own sentence and, finding none, saying what a vendor failure deserves to be told — + * "Composio said nothing about why, check the key, check their status". That advice is wrong twice + * over for a sentence this deployment wrote itself: Composio was reachable, answered, and the thing + * that has to change is here rather than there. Every refusal raised below this line is written for + * the person who will read it and names the step that fixes it, so the one correct thing a route + * can do with it is pass it through. + * + * WHICH MAKES THE CLASS A PROMISE ABOUT THE MESSAGE rather than a category of failure. Nothing is + * raised as one of these unless its sentence is safe to show anybody who could have made the + * request — no url that is a bearer capability, no key, no vendor object — because that is exactly + * what raising it asks the route to do. A failure this file cannot explain stays a plain `Error`, + * so the route keeps reaching for the vendor's own words instead of inventing better ones. + */ +export class BrokerRefusalError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerRefusalError"; + } +} + /** * The state a deployment with no Composio key is in, raised rather than returned. * @@ -118,8 +165,14 @@ export type ComposioBroker = { * * The setting is named in the message because the message is the whole remedy: an operator reading * it needs the name of the variable to set, and nothing else about this deployment will tell them. + * + * A {@link BrokerRefusalError} BECAUSE IT IS THE ORIGINAL ONE. It was the only authored refusal a + * route could recognise when this file had one class, and it is a refusal of exactly that kind: a + * sentence written here, naming the step that fixes it. Keeping its own name is what lets a caller + * ask for this one state in particular — `store.ts` raises it by name, and `routes.ts` sends its + * message where no call was made at all. */ -export class BrokerUnconfiguredError extends Error { +export class BrokerUnconfiguredError extends BrokerRefusalError { constructor() { super( "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set, no Composio surface appears anywhere in the product.", @@ -129,19 +182,19 @@ export class BrokerUnconfiguredError extends Error { } /** - * The one broker failure worth explaining to a reader, and null for every other one. + * The broker failures worth explaining to a reader, and null for every other one. * * NULL RATHER THAN A FALLBACK SENTENCE, which is the whole reason this is a function instead of an - * `error.message` read at each call site. The only thing this module can say with certainty about a - * thrown broker error is that a deployment with no key was asked to do something; a socket that - * hung up, a 500 from the catalogue and a rate limit are all failures it knows nothing about. A - * function that answered those with a sentence of its own would be telling an operator to set a - * setting that is already set, and sending them to their configuration while Composio is down. + * `error.message` read at each call site. The only failures whose message this module can vouch for + * are the ones raised as a {@link BrokerRefusalError}, which is a promise its subclasses make about + * what they say; a socket that hung up, a 500 from the catalogue and a rate limit are all failures + * it knows nothing about. A function that answered those with `error.message` would be putting a + * vendor's object, and whatever it happens to carry, in front of whoever asked. * - * So the caller chooses what to say about a failure it actually has, and this decides only the one - * case it can decide. {@link BrokerUnconfiguredError}'s own message is returned rather than a copy, - * so there is one wording of the remedy and it lives beside the class that raises it. + * So the caller chooses what to say about a failure it actually has, and this decides only the + * cases it can decide. The raised error's own message is returned rather than a copy, so there is + * one wording of each remedy and it lives beside the code that raises it. */ export function brokerSentence(error: unknown): string | null { - return error instanceof BrokerUnconfiguredError ? error.message : null; + return error instanceof BrokerRefusalError ? error.message : null; } diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 3bd9ee376..667c4e71c 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1,5 +1,9 @@ import { Composio } from "@composio/core"; -import type { BrokerApp, ComposioBroker } from "./broker"; +import { + type BrokerApp, + type ComposioBroker, + BrokerRefusalError, +} from "./broker"; import { type ComposioAction, type ComposioActions, @@ -78,6 +82,44 @@ type VendorToolkit = { }; }; +/** + * One auth config as the vendor hands it over, which is three fields because all three decide. + * + * `name` IS THE ONLY PROVENANCE THERE IS. Composio publishes no field saying which client created a + * config, and the listing is scoped to the project rather than to this deployment, so a config an + * operator made by hand in the dashboard comes back beside the ones made here and is otherwise + * identical. The name is the one field this deployment chooses, which is why {@link CONFIG_SUFFIX} + * is written into it and why every decision below reads it. + * + * `status` because a DISABLED config is still a config: it answers the listing, it satisfies the + * "does one exist" question, and a connect link minted against it does not work. The two facts have + * to be separable or an app with a disabled config reads as an app that is ready. + */ +type VendorAuthConfig = { + id: string; + name: string; + status: "ENABLED" | "DISABLED"; +}; + +/** + * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. + * + * The whole enum is named rather than the two or three in use, because the point of the two lists + * below is that they are CHOICES: a reader comparing them can see which statuses each question + * leaves out, and a status added by a vendor version shows up here as a name nothing mentions + * rather than as an answer that quietly got narrower. Written as literals for the reason the + * previous `"ACTIVE"[]` was: the vendor's parameter is an enum and a widened `string[]` does not + * satisfy it. + */ +type VendorAccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + /** * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. * @@ -90,6 +132,17 @@ type VendorToolkit = { * Every member is written with METHOD syntax deliberately. Method parameters are compared * bivariantly, so a real `Composio` — whose signatures carry optional request-options arguments * and wider parameter types than the calls here use — satisfies this without a cast. + * + * THE TWO DELETES ARE NOT `Composio`'s, AND THAT IS THE ONE PLACE THIS SHAPE DIVERGES FROM IT. Both + * of the vendor's own wrappers hard-code the request body they send — `this.client.authConfigs + * .delete(nanoid, undefined, requestOptions)` and the same line for connected accounts + * (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311` and + * `src/models/ConnectedAccounts.ts:532-540`) — so through them the `revoke_on_delete` parameter + * cannot be passed at all, and both calls soft-delete while the grant at Google or Slack stands. So + * the shape below asks for the underlying client's signature instead, and + * {@link createComposioClient} satisfies it from `composio.getClient()`. See + * {@link ComposioBroker.revoke} for what that flag is and what its absence made this deployment + * claim. */ export type ComposioVendor = { tools: { @@ -117,25 +170,65 @@ export type ComposioVendor = { list(query: { toolkit: string; limit: number; - }): Promise<{ items: { id: string; name: string }[] }>; + /** + * Ask for the disabled ones too, ALWAYS, which is why this is the literal and not a boolean. + * + * The vendor's listing returns enabled configs unless asked otherwise + * (`AuthConfigListParamsSchema.showDisabled`, `@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`), and a config this listing cannot see is a config + * {@link ComposioBroker.ensureAuthConfig} creates a second of — which is the exact split that + * method exists to prevent, arriving through the one door it was not watching. Every question + * this file asks of the listing is better served by seeing a disabled config and saying so: + * creation must not duplicate it, deletion must remove it, and consent must refuse against + * it rather than mint a link that cannot work. + */ + showDisabled: true; + }): Promise<{ items: VendorAuthConfig[] }>; create( toolkit: string, options: { type: "use_composio_managed_auth"; name: string }, ): Promise; - delete(id: string): Promise; + /** + * Delete one auth config, and ask for the upstream credentials on it to be revoked too. + * + * THE SAME TRAP AS THE ACCOUNT DELETE BELOW, one level up. The endpoint "soft-deletes an + * authentication configuration" and revokes "the upstream credentials of every connection using + * this auth config" only when the flag is passed (`@composio/client` 0.1.0-alpha.76, + * `resources/auth-configs.d.ts:60-72` and `:651-659`), so a delete without it leaves every + * grant that was ever made against this config alive at the provider. + * + * WHICH IS WHY IT IS PASSED HERE EVEN THOUGH THE ACCOUNTS WERE ALREADY ASKED FOR INDIVIDUALLY. + * Removing an app revokes each connected person first and drops the config last, and that loop + * reaches exactly the people this deployment has a `composio_connections` row for. An account + * whose row drifted — cleared by a confirm that Composio answered `false` to, or lost with a + * database this deployment restored — is invisible to it and still live at the vendor. This is + * the one call that reaches those, and there is nothing on this config that removing the app is + * not meant to end. + */ + delete(id: string, params: { revoke_on_delete: true }): Promise; }; connectedAccounts: { list(query: { userIds: string[]; toolkitSlugs: string[]; /** - * The one status this file ever asks for, spelled as the literal the SDK's enum admits. + * Which statuses this particular question is about — see {@link CONNECTED} and + * {@link REVOCABLE}, which are the only two answers this file gives. + */ + statuses: VendorAccountStatus[]; + /** + * Both sharing models, ALWAYS, which is why this is the literal and not the enum. * - * Written as `"ACTIVE"[]` rather than `string[]` because the vendor's parameter is an enum - * and a widened array does not satisfy it. The narrowness is worth keeping for its own sake - * too: a status this deployment has not thought about cannot be asked for by accident. + * OMITTING IT IS NOT "NO OPINION", in exactly the way an omitted limit is not. The parameter + * defaults to private accounts only (`ConnectedAccountListParamsSchema.accountType`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:286-293`), so a person whose + * account for an app is a SHARED one reads as not connected, is told to connect an app they + * already have, and — far worse — is invisible to the revoke, which then reports that there + * was nothing to withdraw while their grant stands. Neither of the two questions this file + * asks has any reason to care how an account is shared: the gate is about whether this person + * can act through the app, and the revoke is about ending every account they can act through. */ - statuses: "ACTIVE"[]; + accountType: "ALL"; limit: number; }): Promise<{ items: { id: string }[] }>; /** @@ -166,7 +259,27 @@ export type ComposioVendor = { authConfigId: string, options: { callbackUrl: string }, ): Promise<{ redirectUrl?: string | null }>; - delete(id: string): Promise; + /** + * Delete one connected account, and ask for the grant behind it to be revoked too. + * + * WITHOUT THE FLAG THIS CALL DOES NOT REVOKE ANYTHING, and that is the vendor's own description + * of it: it "soft-deletes a connected account by marking it as deleted in the database", which + * "prevents the account from being used for API calls but preserves the record" + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:59-72`). The refresh + * token at Google or Slack survives that untouched. Every path in this deployment that claims + * to end somebody's access — a person disconnecting, an administrator removing an app, a person + * being offboarded — runs through here, so an unflagged delete made all three of those claims + * false at once and wrote `true` into the audit trail beside them. + * + * WHAT THE FLAG BUYS IS A REQUEST AND NOT A RESULT, which is the whole reason + * {@link ComposioBroker.revoke}'s answer is named the way it is. The upstream revocation runs as + * a background job; the response carries its `revoke_job_id` and the vendor documents that no + * generally available endpoint polls it (`:7447-7459`). So the answer is deliberately typed as + * `unknown` and read for nothing: there is no field on it this deployment could turn into a + * stronger claim than "we asked", and a shape declared here that nothing reads is a shape a + * vendor rename can break for no benefit. + */ + delete(id: string, params: { revoke_on_delete: true }): Promise; }; }; @@ -179,6 +292,63 @@ export type ComposioVendor = { */ const CONFIG_SUFFIX = "(OpenBot)"; +/** + * Whether this deployment made that auth config, which is the question every decision here turns on. + * + * THE SUFFIX IS WRITTEN FOR EXACTLY THIS AND WAS NOT BEING READ. Both callers used to take the + * first row of an unordered listing, and the two consequences are of different sizes. Removing an + * app deleted whatever came back first — which can be a config an operator built by hand, with + * their own scopes and their own tool restrictions, taking every account on it down with it. + * Beginning a connection attached a person to whatever came back first — which can be a + * configuration nobody here chose and this deployment cannot see or tighten. And because the order + * is the vendor's, the two calls can resolve DIFFERENT rows, so an app could be removed while + * people kept connecting against a config the removal left behind. + * + * MATCHED ON THE END OF THE NAME rather than on the whole of it, because the rest of the name is an + * app's title as an administrator saw it at enable time and titles are edited. The suffix is the + * part this file writes. Trailing whitespace is tolerated for the same reason it is tolerated + * anywhere a human-edited string is compared: a name that picked up a space in a dashboard is the + * same config. + */ +function madeHere(config: VendorAuthConfig): boolean { + return config.name.trimEnd().endsWith(CONFIG_SUFFIX); +} + +/** + * The statuses that answer "is this person connected", which is the narrow question of the two. + * + * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never finished + * it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; counting any of + * them as connected tells a person their app is wired up and then fails every call they make with + * it. + */ +const CONNECTED: VendorAccountStatus[] = ["ACTIVE"]; + +/** + * The statuses that answer "what is there to revoke", which is a deliberately wider question. + * + * THE TWO QUESTIONS ARE NOT THE SAME ONE, AND TREATING THEM AS ONE LEFT GRANTS STANDING. This used + * to be a single ACTIVE listing shared by both, on the argument that "connected" and "there is + * something to revoke" are the same fact. They are not. A half-finished consent can already have + * been granted at the provider with the callback never delivered; an `EXPIRED` account is an access + * token that lapsed and a refresh token that did not; an `INACTIVE` one is a live grant the vendor + * has set aside. None of them should tell a person they are connected, and every one of them is + * something whose withdrawal is the entire point of pressing disconnect. + * + * `REVOKED` IS THE ONE STATUS LEFT OUT, and left out on purpose rather than forgotten. It is the + * only value that positively says the grant is already gone, so including it would have this + * deployment delete a tombstone and then record that it ended somebody's access — the one way the + * audit field can be made to lie in the direction nobody would check. + */ +const REVOCABLE: VendorAccountStatus[] = [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", +]; + /** * How long one catalogue answer is served to everybody who asks for it. * @@ -198,6 +368,30 @@ const CONFIG_SUFFIX = "(OpenBot)"; */ const DIRECTORY_TTL_MS = 10 * 60 * 1000; +/** + * The held catalogue as a copy nobody else holds, which is what makes handing it out safe. + * + * WHAT WAS HANDED OUT WAS THE CACHE ITSELF. One array of one set of row objects was returned to + * every caller for ten minutes, so a route that sorted the rows in place reordered the catalogue for + * everybody, and one that edited a row — a title trimmed for display, a description truncated — + * edited what the next caller would read as Composio's answer. Nothing does that today, which is + * precisely the problem with leaving it: the first caller that does will have changed a cache it + * had no idea it was holding, and the fault will surface in the NEXT request rather than its own. + * + * `categories` IS COPIED TOO, because a shallow spread of the row would hand the same array on. It + * is the one field here that is not a primitive. + * + * COPIED RATHER THAN FROZEN, which was the other candidate. Freezing would make the sharing safe by + * making a mutation throw, but the type says `BrokerApp[]` and a caller is entitled to sort a list + * it was given; turning a reasonable caller into a `TypeError` is a worse answer than a few hundred + * small objects, which is nothing beside the request this cache exists to avoid. + */ +function copyOf(apps: Promise): Promise { + return apps.then((held) => + held.map((app) => ({ ...app, categories: [...app.categories] })), + ); +} + /** * Both seams, over one vendor client. * @@ -224,41 +418,82 @@ export function buildComposioClient( broker: ComposioBroker; } { /** - * This person's live accounts for this app, which two of the broker's questions are about. + * This person's accounts for this app, in whichever states the ASKING question is about. * - * Shared because {@link ComposioBroker.isConnected} and {@link ComposioBroker.revoke} have to - * agree: "connected" and "there is something to revoke" are the same fact, and two listings - * written separately drift apart the first time one of them gains a status the other lacks. - * - * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never - * finished it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; - * counting either as connected tells a person their app is wired up and then fails every call - * they make with it. + * ONE LISTING WITH THE STATUSES AS ITS ARGUMENT, rather than one listing both callers share. + * They shared one until it turned out that the shared answer was wrong for one of them: see + * {@link CONNECTED} and {@link REVOCABLE} for why "is this person connected" and "what is there + * to revoke" are different questions. What they do share is everything a drift between them + * would come from — the breadth of `accountType`, the limit, and the fact that both ask about one + * person and one app — so the difference between them is exactly the list of statuses and is + * visible at both call sites. */ - const activeAccounts = async (userId: string, toolkit: string) => { + const accountsFor = async ( + userId: string, + toolkit: string, + statuses: VendorAccountStatus[], + ) => { const answer = await vendor.connectedAccounts.list({ userIds: [userId], toolkitSlugs: [toolkit], - statuses: ["ACTIVE"], + statuses, + accountType: "ALL", limit: LISTING_LIMIT, }); return answer.items; }; /** - * This deployment's auth configs for one app. + * The auth configs for one app that THIS DEPLOYMENT made, oldest name first. + * + * THE LISTING IS SCOPED TO THE PROJECT AND NOT TO THIS DEPLOYMENT, which is the correction. An + * auth config is scoped to the project the API key belongs to — so nothing here is hidden from + * this listing, and that was read as "everything it returns is ours". It is not: an operator with + * the same project open in Composio's dashboard can create configs for the same app by hand, for + * purposes this deployment knows nothing about. {@link madeHere} is the only thing that tells the + * two apart, and every caller below is about an object one of them must not touch. * - * EVERYTHING THIS LISTING RETURNS BELONGS TO THIS DEPLOYMENT, which is what makes the two - * callers below legitimate. An auth config is scoped to the project the API key belongs to, so - * the key is the filter: there is no other tenant's config to be found here and none of ours is - * hidden from it. + * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure + * being fixed here is two calls resolving different rows; a total order on the id makes the + * choice this file makes a stable one, whoever asks and whenever. */ - const authConfigsFor = async (toolkit: string) => { + const configsMadeHere = async (toolkit: string) => { const answer = await vendor.authConfigs.list({ toolkit, limit: LISTING_LIMIT, + showDisabled: true, }); - return answer.items; + return answer.items + .filter(madeHere) + .sort((one, other) => one.id.localeCompare(other.id)); + }; + + /** + * Ask for every one of them and answer with what refused, rather than stopping at the first. + * + * A THROW MID-LOOP ABANDONS GRANTS THAT ARE STILL LIVE. Both callers below are deleting a set of + * things that each independently hold somebody's access, and an exception out of the second of + * five leaves three untouched and unmentioned — while the caller is told only about the one that + * failed, so nothing in the answer says the loop did not finish. Attempting all of them makes the + * failure a statement about a set: this many were asked for and this many refused. + * + * SERIALLY RATHER THAN TOGETHER, for the same reason every other call here goes out one at a + * time: the vendor rate-limits, and a person with several accounts is not a reason to open + * several connections. The order is the listing's, which is sorted. + */ + const askForEach = async ( + items: T[], + ask: (item: T) => Promise, + ): Promise => { + const refused: unknown[] = []; + for (const item of items) { + try { + await ask(item); + } catch (error) { + refused.push(error); + } + } + return refused; }; /** @@ -295,6 +530,33 @@ export function buildComposioClient( limit: LISTING_LIMIT, sortBy: "usage", }); + + if (toolkits.length >= LISTING_LIMIT) { + /* + * A FULL PAGE IS NOT A COMPLETE CATALOGUE, and this deployment cannot find out which it is. + * The same refusal `./composio` makes of a full action listing, for the same reason and one + * line of vendor code apart. + * + * `LISTING_LIMIT` is the largest page the toolkit endpoint allows, and while its params do + * name a `cursor` (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, + * `src/types/toolkit.types.ts:9-15`) there is nothing to put in it: the SDK's + * `ToolKitListResponse` is a bare array (`:53`) and `transformToolkitListResponse` drops the + * response's `next_cursor` before any caller sees it. So a catalogue of exactly this many + * apps and one with more of them answer identically here, and there is no second request that + * could tell them apart. + * + * COMMITTED AS COMPLETE IT WOULD BE CACHED AS COMPLETE, which is what makes this worse than + * one short answer. The fragment is held for ten minutes and served to both callers, so an + * administrator searching for an app past the cut is told nothing matched, and the enable + * route — which checks a slug against this same directory — tells them a real app "is not an + * app Composio lists". Thrown from inside the fetch so the failure is never the thing that + * gets held: `listApps` drops an entry whose request rejected. + */ + throw new BrokerRefusalError( + `Composio answered with ${toolkits.length} apps, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. A partial directory is not shown, because an app missing from it reads exactly like an app Composio does not publish.`, + ); + } + return toolkits.map((toolkit) => ({ slug: toolkit.slug, name: toolkit.name, @@ -424,7 +686,7 @@ export function buildComposioClient( */ async listApps(): Promise { const held = heldDirectory; - if (held && now() - held.at < DIRECTORY_TTL_MS) return held.apps; + if (held && now() - held.at < DIRECTORY_TTL_MS) return copyOf(held.apps); /* * Stamped when the request goes out rather than when it comes back, so a slow catalogue is @@ -443,7 +705,7 @@ export function buildComposioClient( entry.apps.catch(() => { if (heldDirectory === entry) heldDirectory = null; }); - return entry.apps; + return copyOf(entry.apps); }, async ensureAuthConfig({ toolkit, name }): Promise { @@ -453,12 +715,29 @@ export function buildComposioClient( * another and connecting the next person to that leaves two populations of connections for * one app, and removing "the" config later drops half of them. * + * THE LOOK IS FOR ONE THIS DEPLOYMENT MADE, WHICH IS NARROWER THAN "ANY". It used to be any, + * and the two ways that was wrong pull in opposite directions. A disabled config of ours was + * invisible to the listing, so this created the very second config it exists to prevent — and + * then `authorize` refused, because it looked with the same blind listing and found the app + * had no config at all. A config an operator made by hand, meanwhile, satisfied the check and + * this created nothing, leaving every later decision here pointed at an object nobody here + * chose. Asking for our own answers both: the disabled one counts, and somebody else's does + * not. + * + * WHICH MEANS AN APP CAN END UP WITH TWO CONFIGS, ONE OF THEM SOMEBODY ELSE'S, and that is + * the intended outcome rather than a tolerated one. Adopting a hand-made config would have + * this deployment mint people's connections against scopes and tool restrictions it cannot + * see, and delete it when the app is removed. A config of our own, named, is the thing every + * decision in this file can actually reason about. + * * This is a read followed by a write and therefore not atomic: two administrators pressing * enable at the same instant can both find nothing and both create. Composio offers no * create-if-absent, so the window is the vendor's rather than this deployment's, and the - * cost of losing that race is a spare config rather than a lost connection. + * cost of losing that race is a spare config rather than a lost connection — spare rather + * than orphaned, because both carry the suffix and `deleteAuthConfig` takes every one of + * ours. */ - const existing = await authConfigsFor(toolkit); + const existing = await configsMadeHere(toolkit); if (existing.length > 0) return; await vendor.authConfigs.create(toolkit, { @@ -469,20 +748,41 @@ export function buildComposioClient( async deleteAuthConfig(toolkit): Promise { /* - * QUIET WHERE THERE IS NOTHING TO DELETE, because removing an app has to be able to happen - * twice. An app can be removed, re-enabled and removed again, two administrators can press - * the button together, and an app enabled before this deployment created configs at all has - * none to drop. In every one of those the end state is the one that was asked for, so a - * throw would report a failure while the caller got exactly what they wanted. + * EVERY CONFIG OF OURS, AND NOTHING THAT IS NOT OURS. * - * ONE CONFIG, NOT ALL OF THEM. {@link ComposioBroker.ensureAuthConfig} creates at most one, - * so a second config for the same app was made by hand in Composio's dashboard for some - * purpose this deployment knows nothing about, and removing an app here is not a mandate to - * delete somebody's dashboard work. + * This used to delete whichever row the vendor happened to return first, on the reasoning + * that {@link ComposioBroker.ensureAuthConfig} creates at most one, so a second one must be + * somebody's dashboard work and must be left alone. The reasoning was right and the code did + * the opposite of it: with no test of the name, "the first row" is as likely to BE the + * hand-made config — deleting it, and with it every account anybody had connected against it. + * Reading the name inverts that. Anything without the suffix is untouched whatever order it + * arrives in, and everything with it goes, which is also the only way the spare config from a + * lost enable race is ever cleaned up. + * + * QUIET WHERE THERE IS NOTHING OF OURS TO DELETE, because removing an app has to be able to + * happen twice. An app can be removed, re-enabled and removed again, two administrators can + * press the button together, and an app enabled before this deployment created configs at all + * has none to drop. In every one of those the end state is the one that was asked for, so a + * throw would report a failure while the caller got exactly what they wanted. */ - const [config] = await authConfigsFor(toolkit); - if (!config) return; - await vendor.authConfigs.delete(config.id); + const ours = await configsMadeHere(toolkit); + const refused = await askForEach(ours, (config) => + vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), + ); + if (refused.length > 0) { + /* + * LOUD, because the caller is `removeServer` and the thing it is in the middle of is taking + * an app away from everybody. A config left standing is a live grant that the removal was + * supposed to end, and the app's row is deleted after this returns — so a swallowed failure + * here is the one state nothing in this deployment can find again. The count is the whole + * message: an operator who can see that one of two configs went knows that pressing remove + * again finishes the job rather than repeating it. + */ + throw new BrokerRefusalError( + `Composio removed ${ours.length - refused.length} of this deployment's ${ours.length} authorization configs for ${toolkit} and refused the rest, so the app has not been fully withdrawn. Removing it again asks only for what is left.`, + { cause: refused[0] }, + ); + } }, async authorize({ @@ -505,14 +805,42 @@ export function buildComposioClient( * dashboard. Neither is something a person pressing Connect can fix, so the sentence names * the app and the administrator's step rather than leaving them at a link that would attach * their account to a configuration nobody here chose. + * + * AND "NONE" MEANS NONE OF OURS, which is the correction. The read used to take whichever row + * the vendor returned first, so an app whose only config was one an operator built by hand + * read as ready and this minted somebody's connection against it — scopes this deployment + * cannot see, tool restrictions it cannot read, and an object it must not delete. A + * connection is a lasting attachment to whatever config it was made against, so guessing here + * is not a guess that can be corrected later. */ - const [config] = await authConfigsFor(toolkit); - if (!config) { - throw new Error( + const ours = await configsMadeHere(toolkit); + if (ours.length === 0) { + throw new BrokerRefusalError( `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, ); } + /* + * A DISABLED CONFIG IS NOT A CONFIG TO CONNECT AGAINST, and it is now visible enough to say + * so. The listing asks for disabled configs — it has to, or the creation above duplicates one + * — which means this is the first read that can meet one. A link minted against it does not + * work, so sending a person to the vendor would spend their consent and end with nothing + * attached; and nothing they can do from the page they are on changes it, because enabling a + * config happens in Composio's dashboard. + * + * THE FIRST OF SEVERAL, WHICH IS A CHOICE AND NOT AN ACCIDENT. More than one enabled config + * of ours means a lost enable race, and both are equally ours and equally valid. What + * mattered about the old "first row" was that the order was the vendor's and the next caller + * could get a different one; the listing is sorted on the id, so this is the same config for + * every person and for the removal that later drops all of them. + */ + const config = ours.find((held) => held.status === "ENABLED"); + if (!config) { + throw new BrokerRefusalError( + `Every authorization config this deployment holds at Composio for ${toolkit} is disabled, so a connection begun against one could not complete. An administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + /* * THE RETURN ADDRESS IS THE WHOLE POINT OF THIS CALL, and it is the caller's rather than * this file's: the adapter knows Composio, and where a person belongs afterwards is a fact @@ -543,7 +871,7 @@ export function buildComposioClient( * anywhere, here or elsewhere: whoever opens it attaches an account to this person's * connection, so it is handed to the browser that asked and then forgotten. */ - throw new Error( + throw new BrokerRefusalError( `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send this person to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, ); } @@ -551,24 +879,58 @@ export function buildComposioClient( }, async isConnected({ userId, toolkit }): Promise { - return (await activeAccounts(userId, toolkit)).length > 0; + return (await accountsFor(userId, toolkit, CONNECTED)).length > 0; }, async revoke({ userId, toolkit }): Promise { /* - * THE ANSWER IS WHAT HAPPENED, not whether the call threw. `false` here means there was no - * grant to withdraw, which is what the audit trail's `vendorRevoked` is for: a reader has to - * be able to tell a grant this deployment ended from one that outlives it somewhere else. + * THE ANSWER IS WHAT WAS ASKED FOR, not whether the call threw. `false` here means there was + * nothing to withdraw, which is what the audit trail's `vendorRevocationRequested` is for: a + * reader has to be able to tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * ASKED FOR, RATHER THAN DONE, AND THE FIELD IS NAMED FOR THAT. The delete carries + * `revoke_on_delete`, which is what turns it from a record-keeping soft-delete into an actual + * withdrawal — and what it starts is a background job the vendor gives no supported way to + * poll. So the account is gone at the broker by the time this returns and nothing here can + * call with it again; whether Google has torn up the refresh token happens afterwards. `true` + * claims exactly that much. See {@link ComposioVendor} for the two declarations this rests on. * - * EVERY ACCOUNT, not the first. One person can hold more than one account for one app — two - * mailboxes, or a stale account beside a fresh one — and each of them is a grant this - * deployment's calls could run under. Leaving one behind would report a revocation that did - * not revoke. + * EVERY ACCOUNT, not the first, and in every state that could still be a grant. One person + * can hold more than one account for one app — two mailboxes, or a stale account beside a + * fresh one, or a shared account beside their own — and each of them is access this + * deployment's calls could run under. See {@link REVOCABLE} for why the listing here is wider + * than the one behind `isConnected`. */ - const accounts = await activeAccounts(userId, toolkit); - for (const account of accounts) { - await vendor.connectedAccounts.delete(account.id); + const accounts = await accountsFor(userId, toolkit, REVOCABLE); + const refused = await askForEach(accounts, (account) => + vendor.connectedAccounts.delete(account.id, { revoke_on_delete: true }), + ); + + if (refused.length > 0) { + /* + * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw + * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which + * is the only thing in this deployment that names which app this person connected. Answer + * `true` here on a partial and that row is deleted, the trail records a disconnection, and + * the account this call could not end is left live with nothing pointing at it — the exact + * state the store's revoke-before-delete order exists to make impossible. Throwing leaves + * the row standing, so pressing disconnect again is a second attempt with everything the + * first one had, and the accounts already gone are no longer in the listing, so the retry + * converges rather than repeating. + * + * WHICH IS ALSO WHY IT IS NOT A `true` WITH A GRUMBLE. Nothing was disconnected in the + * sense the person asked about: their app still answers. The count is in the sentence + * because "some of your accounts were withdrawn" is the one thing a reader cannot work out + * for themselves, and the vendor's own error is kept as `cause` for whoever is reading a + * log rather than a page. + */ + throw new BrokerRefusalError( + `Composio withdrew ${accounts.length - refused.length} of this person's ${accounts.length} accounts for ${toolkit} and refused the rest, so their access to it has not ended. Disconnecting again asks only for the accounts that are left.`, + { cause: refused[0] }, + ); } + return accounts.length > 0; }, }; @@ -577,27 +939,56 @@ export function buildComposioClient( } /** - * The one line that turns this deployment's API key into a vendor client. + * The lines that turn this deployment's API key into a vendor client. * * Everything this file decides lives in {@link buildComposioClient}, which is why this function has - * no body worth testing: it constructs the vendor and hands it over. The key is a parameter here + * no decision worth testing: it constructs the vendor and hands it over. The key is a parameter here * and a private field of the vendor's client thereafter, and no path out of this module carries it * — see the module comment. + * + * IT IS NO LONGER ONE LINE, AND THE REASON IS THE TWO DELETES. `Composio` used to satisfy + * {@link ComposioVendor} whole, passed straight in. It cannot any more: its own `authConfigs.delete` + * and `connectedAccounts.delete` send a hard-coded empty body and therefore cannot ask for the + * upstream revocation, which is the difference between ending somebody's access and filing it away. + * The underlying `@composio/client` takes the parameter, so those two members are satisfied from + * `getClient()` and the rest from the SDK's own models. A wrapper per member rather than a spread, + * so that the arrow's own type checks against the shape above — a vendor method whose signature + * drifted would fail here rather than at the call site. + * + * NO NEW IMPORT, WHICH IS WHY THE ONE-IMPORT-SITE RULE SURVIVES THIS. `getClient()` is public on the + * SDK's own object and the client's types are inferred from it; `@composio/client` is not named + * anywhere under `server/src`, so a version bump still has exactly this file to be read against. */ export function createComposioClient(apiKey: string): { actions: ComposioActions; broker: ComposioBroker; } { - return buildComposioClient( - new Composio({ - apiKey, - // Their default telemetry installs its own interrupt handlers, and this is a self-hosted - // product whose operator never opted into a third party's analytics. - allowTracking: false, - // Both default the other way, so both have to be said. The version check reaches npm for the - // SDK's latest release as the client is constructed, and a deployment's boot must not depend - // on the vendor's release feed. - disableVersionCheck: true, - }), - ); + const composio = new Composio({ + apiKey, + // Their default telemetry installs its own interrupt handlers, and this is a self-hosted + // product whose operator never opted into a third party's analytics. + allowTracking: false, + // Both default the other way, so both have to be said. The version check reaches npm for the + // SDK's latest release as the client is constructed, and a deployment's boot must not depend + // on the vendor's release feed. + disableVersionCheck: true, + }); + const client = composio.getClient(); + + return buildComposioClient({ + tools: composio.tools, + toolkits: composio.toolkits, + authConfigs: { + list: (query) => composio.authConfigs.list(query), + create: (toolkit, options) => + composio.authConfigs.create(toolkit, options), + delete: (id, params) => client.authConfigs.delete(id, params), + }, + connectedAccounts: { + list: (query) => composio.connectedAccounts.list(query), + link: (userId, authConfigId, options) => + composio.connectedAccounts.link(userId, authConfigId, options), + delete: (id, params) => client.connectedAccounts.delete(id, params), + }, + }); } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 45a063100..9fa6fc7f9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -68,16 +68,25 @@ export type ConnectingPersonCheck = (userId: string) => Promise; * * 502 RATHER THAN 500, because nothing here broke: this deployment asked a third party and the third * party did not answer usefully, which is the same reading the dynamic-registration failure below - * already gives. {@link BrokerUnconfiguredError} is the one exception and keeps the 503 every other - * no-broker answer on these routes uses — nobody was asked at all, and the remedy is one environment - * variable long. + * already gives. + * + * `./broker`'s `BrokerRefusalError` IS THE EXCEPTION, AND IT IS A WIDER ONE THAN IT WAS. It used to + * be the one no-key state; it is now every refusal the broker layer AUTHORED — no key, an app whose + * authorization config this deployment never created or cannot use, a consent the vendor answered + * with nowhere to send anybody, a catalogue too large to be sure of. All of them share the property + * that made the first one special: Composio answered, this deployment decided, and the sentence + * already names the step that fixes it. So the message is passed through and the status stays 503, + * which reads correctly for every one of them — the brokered surface is unavailable for this app + * until somebody changes something here, rather than unavailable because a third party is down. The + * generic sentence below is for failures this deployment genuinely cannot explain, and answering one + * of these with it would tell an administrator to check a key that is fine. */ function brokerRefusal( error: unknown, generic: string, ): { error: string; status: 502 | 503 } { - const unconfigured = brokerSentence(error); - if (unconfigured) return { error: unconfigured, status: 503 }; + const authored = brokerSentence(error); + if (authored) return { error: authored, status: 503 }; return { error: vendorSentence(error) ?? generic, status: 502 }; } @@ -1030,9 +1039,9 @@ export function createPluginRoutes( * * The order is the store's and the argument for it is made there: the row is the only thing that * says which app this person connected, so a delete that ran before the revoke could leave a live - * grant on somebody's mailbox that nothing here can reach. What comes back is what happened — - * `vendorRevoked` false is a grant that was already gone — and it is passed through rather than - * rewritten, because telling those two apart is the whole value of the field. + * grant on somebody's mailbox that nothing here can reach. What comes back is what was asked for + * — `vendorRevocationRequested` false is a grant that was already gone — and it is passed through + * rather than rewritten, because telling those two apart is the whole value of the field. * * `reason` IS "self" BECAUSE OF WHO IS ASKING. The other word the store takes is * `person_removed`, which belongs to an administrator offboarding somebody from the People diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index f87a3ce3e..4b6d904e5 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2514,7 +2514,7 @@ export function createPluginStore(options: PluginStoreOptions) { * access should see which of the three this was. */ reason: "mcp_server_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }, }); } @@ -2587,14 +2587,14 @@ export function createPluginStore(options: PluginStoreOptions) { .orderBy(asc(composioConnections.userId)); /* - * What the broker actually did for each of them, kept so the trail below records the - * answer rather than the attempt. False where there is no broker at all: a deployment whose + * What the broker was actually asked for each of them, kept so the trail below records the + * answer rather than the call. False where there is no broker at all: a deployment whose * key has since been unset can still remove the app, and it could not have been calling it - * either way — but nothing was ended at Composio and the row must not claim otherwise. + * either way — but nothing was asked of Composio and the row must not claim otherwise. */ - const vendorRevoked = new Map(); + const vendorRevocationRequested = new Map(); for (const connection of connected) { - vendorRevoked.set( + vendorRevocationRequested.set( connection.userId, broker ? await broker.revoke({ userId: connection.userId, toolkit }) @@ -2638,13 +2638,17 @@ export function createPluginStore(options: PluginStoreOptions) { // administrator took the whole app away and the person did nothing. reason: "mcp_server_removed", /* - * What happened, not what was attempted — {@link ComposioBroker.revoke}'s own - * answer, passed through. True where a grant was withdrawn, false where there was - * none to withdraw or where this deployment has no broker to have asked. The value - * of the field is exactly that a reader can tell a grant this deployment ended from - * one that outlives it somewhere else, so a constant here would be worse than none. + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was + * found and its withdrawal asked for, false where there was none to withdraw or + * where this deployment has no broker to have asked. The value of the field is + * exactly that a reader can tell an account this deployment acted on from one that + * outlives it somewhere else, so a constant here would be worse than none. It says + * "requested" because that is the strongest thing the vendor's answer supports: + * the upstream withdrawal runs as a background job nothing here can poll. */ - vendorRevoked: vendorRevoked.get(connection.userId) ?? false, + vendorRevocationRequested: + vendorRevocationRequested.get(connection.userId) ?? false, }, }); } @@ -3905,10 +3909,19 @@ export function createPluginStore(options: PluginStoreOptions) { * ends this call, and no row and no trail entry claims an account was disconnected when the * account is still live. * - * `vendorRevoked` IS WHAT HAPPENED, NOT WHAT WAS ATTEMPTED — {@link ComposioBroker.revoke}'s - * own answer, passed through. True where a grant was withdrawn, false where there was none to - * withdraw, and the value of the field is exactly that a reader can tell a grant this - * deployment ended from one that outlives it somewhere else. + * `vendorRevocationRequested` IS WHAT WAS ASKED FOR, NOT THAT A CALL WAS MADE — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was found and its + * withdrawal asked for, false where there was none to withdraw, and the value of the field is + * exactly that a reader can tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * IT SAYS "REQUESTED" BECAUSE THE VENDOR'S ANSWER SUPPORTS NOTHING STRONGER, and the field was + * renamed from `vendorRevoked` when that turned out to be false in the plainest way: the + * adapter behind it was soft-deleting the account and asking for no upstream revocation at all, + * so every row saying a grant had been withdrawn described one still live at Google. The ask is + * now made; what a broker can promise synchronously is that the account is gone at Composio and + * that the provider has been asked, because the withdrawal itself runs as a background job with + * no supported way to poll it. * * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer * and never the account itself (see {@link brokeredConnection}), so its absence is not @@ -3928,9 +3941,10 @@ export function createPluginStore(options: PluginStoreOptions) { * {@link confirmBrokeredConnection} files its own event under, one act the other way round: * the trail records acts, and a call that changed nothing performed none. * - * WHICH IS NOT THE SAME QUESTION AS `vendorRevoked`. A row here with no grant at the vendor is - * a disconnect — the gate this deployment decides every brokered call on was open, and this - * call closed it — so the event is filed, saying `vendorRevoked: false`. A grant at the vendor + * WHICH IS NOT THE SAME QUESTION AS `vendorRevocationRequested`. A row here with no grant at + * the vendor is a disconnect — the gate this deployment decides every brokered call on was + * open, and this call closed it — so the event is filed, saying + * `vendorRevocationRequested: false`. A grant at the vendor * with no row here is a disconnect too, and the weightier of the two, because somebody's live * account was ended; the event is filed for that as well. Only where both are absent is there * no act to record, and the two cases stay legible in the trail because the field still says @@ -3949,10 +3963,10 @@ export function createPluginStore(options: PluginStoreOptions) { * caller happened to spell. */ reason: "self" | "person_removed"; - }): Promise<{ vendorRevoked: boolean }> { + }): Promise<{ vendorRevocationRequested: boolean }> { if (!broker) throw new BrokerUnconfiguredError(); - const vendorRevoked = await broker.revoke({ + const vendorRevocationRequested = await broker.revoke({ userId: input.userId, toolkit: input.toolkit, }); @@ -3969,7 +3983,7 @@ export function createPluginStore(options: PluginStoreOptions) { ) .returning({ toolkit: composioConnections.toolkit }); - if (deleted || vendorRevoked) { + if (deleted || vendorRevocationRequested) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -3982,12 +3996,12 @@ export function createPluginStore(options: PluginStoreOptions) { // row, and only these two fields tell them apart. owner: input.userId, reason: input.reason, - vendorRevoked, + vendorRevocationRequested, }, }); } - return { vendorRevoked }; + return { vendorRevocationRequested }; }, /** @@ -4079,7 +4093,7 @@ export function createPluginStore(options: PluginStoreOptions) { * which one this was. */ reason: "person_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }, }); } @@ -4124,14 +4138,14 @@ export function createPluginStore(options: PluginStoreOptions) { .orderBy(asc(composioConnections.toolkit)); /* - * What the broker actually did for each app, kept so the trail below records the answer - * rather than the attempt. False where there is no broker at all: a deployment whose key has + * What the broker was actually asked for each app, kept so the trail below records the answer + * rather than the call. False where there is no broker at all: a deployment whose key has * since been unset can still offboard somebody, and it could not have been calling Composio - * either way — but nothing was ended there and the row must not claim otherwise. + * either way — but nothing was asked there and the row must not claim otherwise. */ - const vendorRevoked = new Map(); + const vendorRevocationRequested = new Map(); for (const connection of brokered) { - vendorRevoked.set( + vendorRevocationRequested.set( connection.toolkit, broker ? await broker.revoke({ userId, toolkit: connection.toolkit }) @@ -4157,14 +4171,17 @@ export function createPluginStore(options: PluginStoreOptions) { owner: userId, reason: "person_removed", /* - * What happened, not what was attempted — {@link ComposioBroker.revoke}'s own answer, - * passed through, and the one place this half differs from the vault loop above. - * There the grant at Google outlives our copy of the secret and the field can only - * say so; here there was no secret of ours and the account itself was ended, or was - * already gone, or there was no broker to ask. The value of the field is exactly that - * a reader can tell those apart, so a constant here would be worse than none. + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through, and the one place this half + * differs from the vault loop above. There the grant at Google outlives our copy of + * the secret and nothing was asked of anybody, so the field can only say false; here + * there was no secret of ours and the account itself was deleted at Composio with its + * withdrawal asked for, or there was nothing to ask about, or there was no broker to + * ask. The value of the field is exactly that a reader can tell those apart, so a + * constant here would be worse than none. */ - vendorRevoked: vendorRevoked.get(connection.toolkit) ?? false, + vendorRevocationRequested: + vendorRevocationRequested.get(connection.toolkit) ?? false, }, }); } diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 77190b5f0..b8ec6e932 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { BrokerRefusalError, brokerSentence } from "../src/plugins/broker"; import { LISTING_LIMIT } from "../src/plugins/composio"; import { buildComposioClient } from "../src/plugins/composio-adapter"; @@ -398,7 +399,13 @@ describe("beginning one person's connection", () => { list: async (query: unknown) => { listed.push(query); return { - items: [{ id: "ac_this_deployments", name: "Linear (OpenBot)" }], + items: [ + { + id: "ac_this_deployments", + name: "Linear (OpenBot)", + status: "ENABLED", + }, + ], }; }, }, @@ -418,9 +425,12 @@ describe("beginning one person's connection", () => { "https://openbot.test/settings/connected-accounts/composio-linear", }); - // The config this deployment already holds for the app, found by the listing the key itself - // scopes — and no `authConfigs.create`, which would refuse in `fakeVendor` if it were reached. - expect(listed).toEqual([{ toolkit: "linear", limit: LISTING_LIMIT }]); + // The config this deployment already holds for the app — and no `authConfigs.create`, which + // would refuse in `fakeVendor` if it were reached. The listing asks for disabled configs too, + // because a config it cannot see is one `ensureAuthConfig` would create a second of. + expect(listed).toEqual([ + { toolkit: "linear", limit: LISTING_LIMIT, showDisabled: true }, + ]); expect(linked).toEqual([ [ "user_1", @@ -470,3 +480,477 @@ describe("beginning one person's connection", () => { expect(linked).toEqual([]); }); }); + +/** + * Choosing WHICH auth config, which is a question this file used to answer with "the first one". + * + * An auth config lives in the project the API key belongs to, beside any an operator built by hand + * in Composio's own dashboard, and the vendor's listing has no documented order. So "the first row" + * is a coin toss between an object this deployment created and an object it knows nothing about — + * and the two callers tossed it separately, so they could land on different rows. The name is the + * only provenance Composio offers: {@link CONFIG_SUFFIX} is written into it at creation for exactly + * this, and was then read by nobody. + */ +describe("telling this deployment's auth configs from anybody else's", () => { + /** What a listing of one app's configs looks like when an operator has been in the dashboard. */ + const MIXED = [ + { id: "ac_by_hand", name: "Linear", status: "ENABLED" }, + { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }, + ]; + + test("a connection is begun against the config this deployment made, not the first row", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: MIXED }) }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + // A connection is a lasting attachment to whatever config it was made against: scopes, tool + // restrictions and a lifetime this deployment neither chose nor can read. Attaching somebody to + // the hand-made one is not a mistake a later call can correct. + expect(linked).toEqual([ + [ + "user_1", + "ac_ours", + { callbackUrl: "https://openbot.test/settings/connected-accounts/x" }, + ], + ]); + }); + + test("an app whose only config was made by hand is refused rather than borrowed", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [MIXED[0]] }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + await expect(refused).rejects.toThrow(/linear/); + expect(linked).toEqual([]); + }); + + test("removing an app drops every config of ours and leaves the hand-made one standing", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + ...MIXED, + // The spare from a lost enable race: two administrators both found nothing and both + // created. Both are ours, so both go — leaving one behind would leave live grants. + { + id: "ac_ours_spare", + name: "Linear (OpenBot)", + status: "ENABLED", + }, + ], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // `revoke_on_delete` on each, because the endpoint soft-deletes and revokes nothing without it + // — and this is the one call that reaches an account whose local row drifted away. + expect(deleted).toEqual([ + ["ac_ours", { revoke_on_delete: true }], + ["ac_ours_spare", { revoke_on_delete: true }], + ]); + }); + + test("a disabled config of ours stops a second one being created", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + // Disabled configs are asked for, because this listing is what decides whether to create. + // A listing that omitted them would find nothing and create the split it exists to stop. + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + create: async (...call: unknown[]) => { + created.push(call); + }, + }, + }), + ); + + await broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + + expect(created).toEqual([]); + }); + + test("a disabled config is a refusal rather than a link that cannot work", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refused = broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }); + + // Sending somebody to consent against a disabled config spends their consent and attaches + // nothing, and nothing on the page they are on can fix it. + await expect(refused).rejects.toThrow(/disabled/); + expect(linked).toEqual([]); + }); + + test("an app with only somebody else's config still gets one of our own", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [MIXED[0]] }), + create: async (...call: unknown[]) => { + created.push(call); + }, + }, + }), + ); + + await broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + + // Adopting the hand-made one would have this deployment mint connections against scopes it + // cannot see and delete an operator's work when the app is removed. + expect(created).toEqual([ + [ + "linear", + { type: "use_composio_managed_auth", name: "Linear (OpenBot)" }, + ], + ]); + }); +}); + +/** + * Ending somebody's access, which is the claim this whole surface is here to be able to make. + * + * THE DELETE DOES NOT REVOKE, and that is the vendor's own description of it: it "soft-deletes a + * connected account by marking it as deleted in the database", preserving the record, unless + * `revoke_on_delete` is passed. Every path that says it ended somebody's access — a person + * disconnecting, an app being removed, a person being offboarded — runs through this method and + * wrote `true` into the audit trail while the refresh token at Google was untouched. The assertions + * here are therefore on WHAT WENT OUT rather than on what came back: an implementation that dropped + * the flag answers every one of them identically. + */ +describe("withdrawing one person's grants", () => { + test("the delete asks for the upstream credentials to be revoked", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual([["ca_1", { revoke_on_delete: true }]]); + }); + + test("the listing asks about every state a grant can be hiding in", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return { items: [] }; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + false, + ); + + /* + * `accountType` because its default is private accounts only, so a shared account is invisible + * to a listing that omits it — and an invisible account is a live grant this answers `false` + * about. The statuses because an unfinished consent or a lapsed token is still something a + * provider is holding. `REVOKED` is the one left out: it is the only status that says the grant + * is already gone, and deleting a tombstone would have this report a withdrawal that never was. + */ + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", + ], + accountType: "ALL", + limit: LISTING_LIMIT, + }, + ]); + }); + + test("being connected is a narrower question, and asked as one", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return { items: [{ id: "ca_1" }] }; + }, + }, + }), + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + + // ACTIVE only — an unfinished or expired account must not tell somebody their app is wired up — + // but `accountType: "ALL"` all the same, because a shared account is a connected account. + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + accountType: "ALL", + limit: LISTING_LIMIT, + }, + ]); + }); + + test("a refusal partway through still asks about the accounts behind it", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + deleted.push(id); + }, + }, + }), + ); + + const refused = broker.revoke({ userId: "user_1", toolkit: "gmail" }); + + // The third account is the whole point: a throw at the second used to abandon it, so a grant + // nobody ever asked about outlived a call that reported only the failure of a different one. + await expect(refused).rejects.toThrow(/2 of this person's 3 accounts/); + expect(deleted).toEqual(["ca_1", "ca_3"]); + }); + + test("a partial withdrawal is a failure rather than a reported disconnection", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + }, + }, + }), + ); + + /* + * NOT A `true`. `store.ts` revokes and only then deletes the `composio_connections` row, which + * is the only thing naming which app this person connected; a `true` here deletes that row, the + * trail records a disconnection, and the account this call could not end is left live with + * nothing pointing at it. The throw leaves the row standing, so pressing disconnect again is a + * second attempt with everything the first one had. + */ + await expect( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ).rejects.toBeInstanceOf(BrokerRefusalError); + }); +}); + +/** + * The three refusals this file authors, and the one thing a route has to be able to do with them. + * + * `routes.ts` answers a thrown broker error by reaching into it for the vendor's own sentence and, + * finding none, telling the reader that Composio said nothing about why and that an administrator + * should check this deployment's key. That advice is wrong for every sentence below: Composio + * answered, this deployment decided, and the remedy is already written down. `brokerSentence` is the + * one seam that tells the two apart, so these assert the recognition rather than the wording. + */ +describe("refusals a route can tell from an outage", () => { + test("an app with no config of ours is recognised as this deployment's own refusal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + ); + + const error = await broker + .authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }) + .catch((raised: unknown) => raised); + + expect(brokerSentence(error)).toMatch(/An administrator/); + }); + + test("a consent with nowhere to send anybody is recognised too", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + }, + // An API-key toolkit is connected by typing a secret rather than by visiting a page, so the + // vendor answers with no url. Nothing is wrong with the key, and saying so would be a + // second wrong answer on top of a first. + connectedAccounts: { link: async () => ({ redirectUrl: null }) }, + }), + ); + + const error = await broker + .authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }) + .catch((raised: unknown) => raised); + + expect(brokerSentence(error)).toMatch(/no page to visit/); + }); +}); + +/** + * What the catalogue is allowed to be, given that it is cached and then believed. + */ +describe("a catalogue that might be a fragment", () => { + test("a full page is refused rather than held for ten minutes", async () => { + let calls = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => { + calls += 1; + return Array.from({ length: LISTING_LIMIT }, (_, index) => ({ + slug: `app_${index}`, + name: `App ${index}`, + meta: {}, + })); + }, + }, + }), + () => 1_000_000, + ); + + /* + * `LISTING_LIMIT` is the largest page the toolkit endpoint allows and the SDK drops the + * response's cursor, so a catalogue of exactly this size and one larger answer identically. + * Committed, the fragment would be served for ten minutes to the picker AND to the enable + * route, which tells an administrator that a real app "is not an app Composio lists". + */ + await expect(broker.listApps()).rejects.toThrow(/largest page/); + // And the refusal is not what gets held: the next caller asks again. + await expect(broker.listApps()).rejects.toThrow(/largest page/); + expect(calls).toBe(2); + }); + + test("each caller gets its own rows, so one of them cannot edit the cache", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => [ + { + slug: "gmail", + name: "Gmail", + meta: { + description: "Send and read mail.", + categories: [{ slug: "productivity", name: "Productivity" }], + toolsCount: 63, + }, + }, + ], + }, + }), + () => 1_000_000, + ); + + const first = await broker.listApps(); + first[0].name = "Not Gmail"; + first[0].categories.push("Invented"); + first.length = 0; + + // Nothing does this today, which is exactly why leaving it would be a trap: the first caller + // that sorts or trims the rows would be editing what the next nine minutes of callers read as + // Composio's answer, and the fault would surface in somebody else's request. + const second = await broker.listApps(); + expect(second).toEqual([ + { + slug: "gmail", + name: "Gmail", + description: "Send and read mail.", + logo: null, + categories: ["Productivity"], + actionCount: 63, + }, + ]); + }); +}); diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 084596d81..8cffa1f2f 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -273,7 +273,7 @@ test("offboarding somebody retires the app they connected, and the next call is // because this store was built with no broker, so there was nobody to revoke at: // `retireConnectionsFor` ends the account at Composio on a deployment that has one. reason: "person_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }); }); @@ -393,7 +393,7 @@ test("removing the app takes every brokered connection to it", async () => { // An administrator took the whole app away and the person did nothing. Distinct from both // "they disconnected" and "they were removed", which is what an auditor is trying to tell apart. reason: "mcp_server_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }); }); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 3bfae1fd8..452feade2 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1066,7 +1066,7 @@ function brokeredApp( // The revoke comes before the delete, so a throw here is the product's own ordering: the // account is still live at the vendor and the row is still here. if (deployment.storeThrows) throw deployment.storeThrows; - return { vendorRevoked: true }; + return { vendorRevocationRequested: true }; }, }; @@ -1419,7 +1419,7 @@ describe("confirming and ending a brokered connection", () => { }); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ vendorRevoked: true }); + expect(await response.json()).toEqual({ vendorRevocationRequested: true }); /* * The owner and the actor are the same person, and `reason` is the word that says why. The * trail tells this apart from an administrator offboarding somebody by those three fields and diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 5f4c3ef94..205a6dc95 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6353,7 +6353,7 @@ test("disconnecting a brokered connection revokes at the vendor before the row g reason: "self", }); - expect(outcome).toEqual({ vendorRevoked: true }); + expect(outcome).toEqual({ vendorRevocationRequested: true }); expect(rowsWhenRevoked).toEqual(["user_asker"]); expect(order).toEqual(["revoke:user_asker"]); expect(await store.brokeredConnection(pair)).toBeNull(); @@ -6369,7 +6369,7 @@ test("disconnecting a brokered connection revokes at the vendor before the row g reason: "self", // What happened, not what was attempted. The broker said it withdrew a grant, so the trail // says so; a field that always said true would make the row a worse record than none. - vendorRevoked: true, + vendorRevocationRequested: true, }); }); @@ -6417,7 +6417,7 @@ test("a brokered connection outlives a revoke that failed, and a second attempt by: "user_asker", reason: "self", }), - ).toEqual({ vendorRevoked: true }); + ).toEqual({ vendorRevocationRequested: true }); expect(await store.brokeredConnection(pair)).toBeNull(); }); @@ -6425,7 +6425,7 @@ test("a brokered connection outlives a revoke that failed, and a second attempt * A revoke that found nothing to withdraw says so, and the row goes all the same. * * CRITERION. Where the broker answers `false`, both the outcome and the trail carry - * `vendorRevoked: false`, and the `composio_connections` row is deleted regardless. + * `vendorRevocationRequested: false`, and the `composio_connections` row is deleted regardless. * * REASON. This is the grant somebody already ended in Composio's own dashboard. The account is * gone at the vendor, so the local row is the stale half of a pair that has drifted and deleting @@ -6434,8 +6434,8 @@ test("a brokered connection outlives a revoke that failed, and a second attempt * somewhere else is a worse record than none, because whoever reads back for who ended it is * given the wrong answer in the same words as the right one. * - * THE FALSE IS THE WHOLE TEST. `vendorRevoked` is indistinguishable from a hardcoded `true` until - * a revoke answers no, and no other test in this file exercises one. + * THE FALSE IS THE WHOLE TEST. `vendorRevocationRequested` is indistinguishable from a hardcoded + * `true` until a revoke answers no, and no other test in this file exercises one. */ test("a brokered disconnect that withdrew no grant records that it withdrew none", async () => { const { broker, order } = brokerSpy({ revoke: async () => false }); @@ -6449,7 +6449,7 @@ test("a brokered disconnect that withdrew no grant records that it withdrew none by: "user_asker", reason: "self", }), - ).toEqual({ vendorRevoked: false }); + ).toEqual({ vendorRevocationRequested: false }); expect(order).toEqual(["revoke:user_asker"]); // Gone, because there was nothing at the vendor and the row was therefore the half that had @@ -6466,7 +6466,7 @@ test("a brokered disconnect that withdrew no grant records that it withdrew none server: "gmail", owner: "user_asker", reason: "self", - vendorRevoked: false, + vendorRevocationRequested: false, }); }); @@ -6507,7 +6507,7 @@ test("a brokered disconnect with nothing to disconnect files nothing in the trai by: "user_asker", reason: "self", }), - ).toEqual({ vendorRevoked: false }); + ).toEqual({ vendorRevocationRequested: false }); expect(order).toEqual(["revoke:user_asker"]); expect(await store.brokeredConnection(pair)).toBeNull(); @@ -6524,7 +6524,7 @@ test("a brokered disconnect with nothing to disconnect files nothing in the trai by: "user_asker", reason: "self", }), - ).toEqual({ vendorRevoked: true }); + ).toEqual({ vendorRevocationRequested: true }); const disconnected = auditStore .recorded() @@ -6535,7 +6535,7 @@ test("a brokered disconnect with nothing to disconnect files nothing in the trai server: "gmail", owner: "user_asker", reason: "self", - vendorRevoked: true, + vendorRevocationRequested: true, }); }); @@ -6742,14 +6742,14 @@ test("removing an app revokes everybody, then clears rows, then drops the config * is the whole change: the row used to say `false` here whatever happened, which was * honest only while the removal left every account live at Composio. */ - vendorRevoked: true, + vendorRevocationRequested: true, }, { actor: "admin", server: "linear", owner: "user-b", reason: "mcp_server_removed", - vendorRevoked: true, + vendorRevocationRequested: true, }, ]); } finally { @@ -6882,14 +6882,14 @@ test("removing a person revokes their brokered accounts at the broker", async () * is the whole change: the row used to say `false` here whatever happened, which was * honest only while offboarding left every account live at Composio. */ - vendorRevoked: true, + vendorRevocationRequested: true, }, { actor: "admin", server: "linear", owner: "user_leaver", reason: "person_removed", - vendorRevoked: true, + vendorRevocationRequested: true, }, ]); }); @@ -6897,8 +6897,8 @@ test("removing a person revokes their brokered accounts at the broker", async () /** * And where there was no grant left to withdraw, offboarding says so. * - * CRITERION. A broker answering `false` leaves `vendorRevoked: false` in the trail, and the row is - * deleted and counted just the same. + * CRITERION. A broker answering `false` leaves `vendorRevocationRequested: false` in the trail, and + * the row is deleted and counted just the same. * * REASON. The account was already ended in Composio's own dashboard, so the local row is the stale * half of a pair that has drifted. What must not happen is the trail claiming this deployment @@ -6932,7 +6932,7 @@ test("offboarding a brokered account nobody held any more withdraws nothing, and server: "gmail", owner: "user_leaver", reason: "person_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }); }); @@ -6940,7 +6940,7 @@ test("offboarding a brokered account nobody held any more withdraws nothing, and * The same false, on the other act that ends a brokered connection. * * CRITERION. `removeServer` on a brokered row whose broker answers `false` records - * `vendorRevoked: false`, and still clears the row and drops the config. + * `vendorRevocationRequested: false`, and still clears the row and drops the config. * * REASON. The removal test above pins the `true`, which a literal `true` in the store would pass * just as well — and one did, for the whole of this suite, until this test. A field whose only @@ -6971,7 +6971,7 @@ test("removing an app records the grant it did not withdraw as not withdrawn", a server: "gmail", owner: "user_asker", reason: "mcp_server_removed", - vendorRevoked: false, + vendorRevocationRequested: false, }); }); From a2fb3bfc652f3dc16df8fcbccc203e08dc8e7647 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 16:33:33 +0200 Subject: [PATCH 127/215] Refuse an answer this transport cannot read, rather than calling it an empty success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard deciding whether the vendor's envelope is readable asked only whether the value was an object, and an array clears that test. So an answer nothing here could read was handed to the model as a call that worked and found nothing, and was audited as a success. It now asks the question this module already had a predicate for. Beside it, a failure reported as something other than a sentence was collapsed to silence and likewise read as success. Three outcomes are now distinguishable: the vendor said it failed and gave a reason, the vendor said it failed and the reason is unreadable, and the envelope itself could not be read — the last wearing this deployment's words rather than the vendor's, which is what this file requires. And one malformed action no longer takes down a whole app. An action listed without a name became a row whose name was undefined against a column that forbids it, failing the insert for all sixty of its siblings. The listing is now refused rather than the action skipped, because a refresh commits a listing as the complete truth about an app: skipping one element would silently drop the recorded effect, destructive marker and version of everything missing, and the version is the one no later refresh can reconstruct. Nobody could even be told which action vanished, since the absent field is the one that names it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 81 +++++++++++++++- server/tests/composio-transport.test.ts | 124 ++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index b2b736467..4c033e4cc 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -285,7 +285,12 @@ export function effectOf(tags: readonly string[] | undefined): { return { effect: "write", destructive: false }; } -/** A JSON Schema node, or null for anything that is not one. */ +/** + * A plain object — a JSON Schema node, a listed action, an execute envelope — or null for anything + * that is not one, which is the only question three separate readers in this file have of a value + * the vendor sent. An array answers null, because `typeof [] === "object"` is the trap every one of + * them would otherwise fall into on its own. + */ function schemaNode(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) @@ -457,6 +462,43 @@ export async function listTools(connection: { ); } + /* + * AN ACTION WITH NO SLUG BREAKS THE LISTING RATHER THAN BEING DROPPED FROM IT. + * + * The slug is the action's whole identity here: it becomes `name` in `mcp_tools`, which is NOT + * NULL and half the primary key, it is what a grant records, and it is the `slug` {@link callTool} + * sends back to Composio. `ToolSchema` spells it required, so an element without one is not an + * action the vendor published — and left to the map below it becomes a tool named `undefined` + * that fails the insert, taking down the refresh of an app whose other sixty actions were fine. + * + * SKIPPING IT WOULD BE THE WRONG REPAIR, and this file's own distinction between an empty answer + * and a broken one says why. Dropping the element makes this a SHORT listing, and `refreshTools` + * commits a listing as the complete truth about the app: the replace is a delete and an insert, + * so every recorded action missing from it is deleted with its `effect`, `destructive` and + * `version` — and `version` is the one no refresh can reconstruct where the vendor publishes + * none. That is the fragment committed as complete that the full-page refusal below exists to + * prevent, arriving one element at a time; and here nobody could even be told which action went + * missing, because the thing that names it is the thing that is absent. + * + * NOR IS IT THE FILE FILTER'S CASE, which drops actions and is right to. Those are well-formed + * actions the vendor published in full that this deployment cannot serve — a standing decision + * about a known action, taken the same way on every refresh. This is an answer that could not be + * read, which is the criterion the element check above already throws on, one field further in. + * + * BLANK COUNTS AS ABSENT, for the reason the version below is trimmed: `callTool` would send the + * padding to Composio as the action's name, and no `mcp_tools` row keyed on whitespace is a name + * anybody meant to grant. + */ + if ( + actions.some( + (action) => typeof action.slug !== "string" || action.slug.trim() === "", + ) + ) { + throw new Error( + `Composio's action list for ${toolkit} contained an action with no slug, which is the name this deployment would have to record it under and send back to call it. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing this one could not be read from.`, + ); + } + if (actions.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. @@ -696,13 +738,37 @@ function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { * vendor reporting anything: the schema makes it impossible from the real client, and reading it as * a failure would turn a projection looser than the schema into a refusal of a call that worked. * + * AN `error` THAT IS NOT A SENTENCE IS NOT SILENCE, and reading the field through a `typeof` that + * collapsed everything else to `""` made the two indistinguishable. `{ message: … }`, or the list + * of issues a gateway puts there, arriving beside `successful: true` came out of here as null: the + * call was handed to the model as content, `store.ts` audited `mcp.call_succeeded`, and the field + * the vendor put its complaint in was shown to nobody. Which branch it takes turns on the flag, + * because the two say different things. Beside `successful: false` the vendor has already reported + * the failure and only its reason is unreadable, which is what {@link unexplained} is for. Beside + * anything else nothing here knows whether the action ran at all — the third kind of failure + * {@link callTool} names, ours rather than the vendor's, and so worded in our own words. + * * Null when there is nothing to report, so the caller can tell "succeeded" from "failed silently". */ function reportedFailure( answer: ComposioResult, toolName: string, ): string | null { - const sentence = typeof answer.error === "string" ? answer.error.trim() : ""; + // Read as `unknown` because the type is this module's projection and the value is the vendor's: + // `ToolExecuteResponseSchema` spells this field a nullable string, and a field that is neither is + // exactly the case below. + const reported: unknown = answer.error; + if ( + reported !== null && + reported !== undefined && + typeof reported !== "string" + ) { + return answer.successful === false + ? unexplained(toolName) + : `${toolName} was sent to Composio and Composio answered, but this deployment could not read what it said about the call: the answer's error was neither a sentence nor null, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; + } + + const sentence = typeof reported === "string" ? reported.trim() : ""; if (answer.successful !== false && sentence === "") return null; return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? unexplained(toolName) @@ -813,8 +879,17 @@ export async function callTool( * * A vendor fault it is not, so it does not get the vendor's words. This is the third kind of * failure the comment above names: Composio answered and this deployment could not read it. + * + * THE QUESTION IS ASKED THROUGH {@link schemaNode} RATHER THAN WRITTEN OUT AGAIN HERE, and what + * the difference costs is an array. A hand-written `typeof answer === "object"` admits one: + * `[]`, or an envelope unwrapped one level too far, read `error` and `successful` as undefined, + * reported nothing, and came back `isError: false` saying "The action returned nothing." So the + * one shape this guard exists to refuse was the one shape that reached the model as a call that + * worked and found nothing, and `store.ts` wrote a success beside it. The listing above asks its + * elements this identical question through that predicate; there is one right answer to it and + * one place to keep it. */ - if (typeof answer !== "object" || answer === null) { + if (schemaNode(answer) === null) { return failure( `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: it was not the { data, error, successful } envelope Composio's own schema requires.`, ); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 72409569d..99c3e6565 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -798,6 +798,41 @@ describe("listing an app's actions", () => { } }); + test("an action with no slug breaks the listing rather than being dropped from it", async () => { + /* + * The slug is the action's whole identity: `name` in `mcp_tools`, which is NOT NULL and half + * the primary key, the string a grant records, and the `slug` `callTool` sends back to call it. + * An element without one reached the map and became a tool named `undefined`, so one malformed + * action in a listing of sixty took the entire app's refresh down on the insert. + * + * REFUSED RATHER THAN SKIPPED, which is what this asserts. Skipping would hand `refreshTools` a + * SHORT listing, and it commits a listing as the complete truth about the app: the replace is a + * delete and an insert, so every recorded action missing from it is deleted along with its + * `version`, under a refresh that reported success. Nobody could even be told which action went + * missing, because the thing that names it is the thing that is not there. + */ + for (const slug of [undefined, null, "", " ", 7]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug, description: "Nameless.", tags: ["readOnlyHint"] }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + // The success arm says nothing about a slug, so this is the refusal and not a message match. + expect(outcome).toContain("slug"); + expect(outcome).toContain("gmail"); + } + }); + test("a version made only of whitespace is recorded as no version at all", async () => { /* * TRIMMED ON THE WAY IN BECAUSE IT IS TRIMMED ON THE WAY OUT. `callTool` trims the recorded @@ -1339,6 +1374,95 @@ describe("calling one action", () => { expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); }); + test("a list where the envelope should be is refused, not called an empty success", async () => { + /* + * `typeof [] === "object"`, so an array cleared a guard written as a bare `typeof` test and was + * then read as an envelope: `error` and `successful` came off it as undefined, so nothing was + * reported, and `data` came off it as undefined, so `resultOf` answered "The action returned + * nothing." An answer this deployment could not read reached the model as a call that worked + * and found nothing, and `store.ts` wrote `mcp.call_succeeded` beside it — which is the exact + * outcome the third kind of failure exists to keep off the trail. + * + * Not a hypothetical shape: an envelope unwrapped one level too far, or a client that answers + * the batch form, is a list where this expects a record. + */ + for (const shape of [[], [{ data: { messages: [] }, successful: true }]]) { + useComposioClient( + recording({ + execute: async () => shape as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toContain("returned nothing"); + } + }); + + test("an error that is not a sentence is not read as silence", async () => { + /* + * `ToolExecuteResponseSchema` spells `error` a nullable string, so an object — or the issue + * list a gateway leaves there — is a field this deployment cannot read. Collapsed to `""` by a + * `typeof` test, it was indistinguishable from the vendor saying nothing went wrong: the data + * was handed to the model as content and the call was audited as a success, with the one field + * carrying the complaint shown to nobody. + * + * THE FLAG DECIDES WHICH SENTENCE, because the two states differ. Beside `successful: false` + * the vendor has already reported the failure and only its reason is unreadable, which is the + * actionable connection sentence. Beside a claimed success nothing here knows whether the + * action ran, and that failure is ours rather than the vendor's, so it says so in our words. + */ + const unreadable = { message: "Quota exceeded.", code: 429 }; + + useComposioClient( + recording({ + execute: async () => + ({ + data: { messages: [{ id: "m1" }] }, + error: unreadable, + successful: true, + }) as unknown as ComposioResult, + }).client, + ); + + const claimed = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(claimed.isError).toBe(true); + expect(claimed.text).toContain("could not read"); + // The data must not be handed over as content beside a complaint nobody could read. + expect(claimed.text).not.toContain("m1"); + + useComposioClient( + recording({ + execute: async () => + ({ + data: {}, + error: unreadable, + successful: false, + }) as unknown as ComposioResult, + }).client, + ); + + const reported = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(reported.isError).toBe(true); + expect(reported.text).toContain("Plugins page"); + }); + test("an answer that is not an envelope refuses rather than throwing", async () => { /* * THE NEVER-THROW CONTRACT, asserted against the shape that broke it. This module documents a From 61fe1a4b0940a0ded57ff9a50acf400ab2ffad7a Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 16:33:33 +0200 Subject: [PATCH 128/215] Stop the key-diagnosis script failing worst when the key is bad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its three vendor reads were unguarded, so the case it exists to diagnose — a key that does not work — escaped as an unhandled rejection and printed the vendor's error object, two stack traces, the refusal body and every response header, going around the redactor that is this script's one rule. Reproduced before fixing, and it is now a single line. That line is also worth reading. It came from the error's outer message, which the transport module documents as a placeholder saying nothing; the actionable sentence is nested two levels inside, and this file now uses the helper that digs it out. The truncation signal its own documentation promised is emitted at last, for both listings: how many apps came back, and whether the answer arrived full and is therefore probably cut short. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- scripts/composio-smoke.ts | 80 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/scripts/composio-smoke.ts b/scripts/composio-smoke.ts index 952325b69..cb4930754 100644 --- a/scripts/composio-smoke.ts +++ b/scripts/composio-smoke.ts @@ -21,12 +21,15 @@ * every line this script writes goes through {@link say}, which redacts it. That is belt and braces * on purpose: the adapter promises not to quote the key, but a vendor exception is a foreign object * and "this SDK does not put the key in an error" is not a promise this file is in a position to - * make on the SDK's behalf. + * make on the SDK's behalf. Which is why no vendor call below is awaited bare — an unhandled + * rejection is printed by the runtime rather than by this file, and that is the one way out past + * {@link say}. {@link ask} is what closes it. */ import { type ComposioResult, effectOf, LISTING_LIMIT, + vendorSentence, } from "../server/src/plugins/composio"; import { createComposioClient } from "../server/src/plugins/composio-adapter"; @@ -107,16 +110,66 @@ function say(line: string): void { * whatever else the vendor attached to it, and `console.error(error)` prints all of it — which is * the path by which a key ends up in a terminal and in whatever captured it. So the shape is * discarded here and the one human sentence is kept, and even that goes out through {@link say}. + * + * WHICH MESSAGE, THOUGH, IS NOT THE OUTER ONE. `error.message` on a Composio throw is "Error + * executing the tool GMAIL_GET_PROFILE" — the placeholder `./composio` documents as the sentence + * never worth passing on, and on a diagnostic it is worse than useless: it names the thing the + * reader just asked for and says nothing about why the key could not do it. The actionable + * sentence — "API Key is not valid", "No connected account found for user ID …" — is nested two + * levels inside `cause`, beside the whole HTTP response. {@link vendorSentence} is the reach that + * takes that sentence and nothing else, and it is imported rather than rewritten here so that a + * vendor changing the nesting breaks one place. + * + * The thrown message stays as the fallback, because a failure that is not a Composio throw at all — + * DNS, a proxy, a TLS refusal — carries its whole diagnosis there. */ function sentence(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return ( + vendorSentence(error) ?? + (error instanceof Error ? error.message : String(error)) + ); +} + +/** + * One vendor read, with a thrown failure reported rather than raised. + * + * WITHOUT THIS THE READS BELOW GO ROUND THE REDACTOR, which is the one rule this file has. A + * top-level await that rejects is an unhandled rejection, and the runtime prints the thrown value + * itself: for a bad key that is the vendor's error object with the 401 body, every response header + * and two stack traces, none of it through {@link say}. A bad key is also the FIRST thing this + * script exists to diagnose — so the crash was reserved for exactly the case the script was written + * for, and the guarantee at the top of this file held only while nothing went wrong. + * + * IT EXITS RATHER THAN ANSWERING A SENTINEL, because each read below is a precondition for the ones + * after it: a catalogue that could not be read makes "no connection" a statement about nothing. + */ +async function ask(attempt: string, read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + say(`${attempt} failed: ${sentence(error)}`); + process.exit(1); + } } const { actions, broker } = createComposioClient(key); -const apps = await broker.listApps(); +const apps = await ask("Listing the apps this key can see", () => + broker.listApps(), +); const app = apps.find((candidate) => candidate.slug === APP); say(`Composio listed ${apps.length} apps for this key.`); +if (apps.length >= LISTING_LIMIT) { + /* + * Said because at the ceiling a whole catalogue and a cut-off one are the same array, and no + * second request can tell them apart — see {@link LISTING_LIMIT}, which is the largest page this + * SDK can express. So a full page is reported as a full page rather than as the catalogue, and an + * app missing from it may be past the cut rather than absent from the account. + */ + say( + `That is the whole page Composio will answer with (${LISTING_LIMIT}), so the catalogue came back full and anything missing below may be past the cut rather than absent.`, + ); +} if (!app) { /* * Stated rather than shrugged at. A catalogue that does not contain Gmail is a key pointed at @@ -130,7 +183,10 @@ if (!app) { } say(`${app.name} publishes ${app.actionCount} actions.`); -const connected = await broker.isConnected({ userId: user, toolkit: APP }); +const connected = await ask( + `Asking whether ${user} has a ${APP} connection`, + () => broker.isConnected({ userId: user, toolkit: APP }), +); say( connected ? `${user} has a live ${APP} connection.` @@ -155,7 +211,21 @@ if (!connected) { * and the behaviour labels, which are what makes the claim "read-only" checkable instead of * asserted in a comment. */ -const listed = await actions.listActions(APP, { limit: LISTING_LIMIT }); +const listed = await ask(`Listing ${APP}'s actions`, () => + actions.listActions(APP, { limit: LISTING_LIMIT }), +); +/* + * The count, because it is the number {@link APP} was chosen for: Gmail publishes enough actions + * that a page truncated at the ceiling, or narrowed to the vendor's "important" subset, reads as an + * obviously wrong number beside the count the catalogue published — and neither one announces + * itself. Printing only the catalogue's figure left the comparison this file promises impossible to + * make. + */ +say( + listed.length >= LISTING_LIMIT + ? `Composio answered with ${listed.length} ${APP} actions, which is the whole page it will answer with (${LISTING_LIMIT}), so that listing came back full and is likely cut off.` + : `Composio listed ${listed.length} of the ${app.actionCount} actions ${app.name} publishes.`, +); const action = listed.find((candidate) => candidate.slug === READ_ACTION); if (!action) { say( From f81686a37110c196032579f75338c4234b88136b Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 16:33:33 +0200 Subject: [PATCH 129/215] Ship the pruned dependencies the image went to the trouble of building MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build produces a production-pruned tree and then the runtime stage took the server's half from the unpruned one, so the pruning applied to the root and not where the server actually resolves. Three development-only packages travelled into the image and one of them, the mock the test suite uses, arrived as a dangling link along with two of its command shims. Verified rather than reasoned about: both trees were built and diffed, every runtime dependency the server declares resolves in the pruned half with symlink targets identical to the unpruned one, and the finished image now reports no dangling links where it previously reported three. The read-only check somebody is told to run after connecting an account also shipped as a manifest entry pointing at a file the image did not contain. It contains it now — one file, not the whole directory, since the other entries need packages the prune removes or a test suite the image does not carry. Its question is about this deployment's key, which lives in the container rather than on somebody's laptop, so that is where it should be answerable. Left deliberately, and noted in place rather than changed quietly: the computer-use component ships its own unpruned tree, sixty-four megabytes of which twenty-three are a compiler nothing there runs. It installs from its own lockfile rather than as part of this workspace, so there is no pruned half to take and fixing it means a second production install in the build — a change to the component whose breakage would be worst, and not one to make while shipping something else. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- Dockerfile | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 674ce392d..79c2f2bca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,6 @@ RUN cd agent-computer && bun install --frozen-lockfile # A second tree with the build-time dependencies left out, for the runtime stage to take. Vite, # biome and the test tooling are a gigabyte that nothing in a running container imports. RUN mkdir -p /prod && cp package.json bun.lock /prod/ \ - && cp -r app/package.json /prod/app-package.json \ && cd /prod && mkdir -p app server worker \ && cp /src/app/package.json app/package.json \ && cp /src/server/package.json server/package.json \ @@ -100,10 +99,21 @@ RUN case "${TARGETARCH}" in \ WORKDIR /app +# BOTH HALVES OF THE TREE COME FROM /prod. A workspace install is two directories, not one: the +# packages it could hoist go to the root `node_modules`, and a per-workspace `node_modules` sits +# beside each manifest holding the rest. The server's half used to be taken from /src, which is the +# unpruned install, so the prune above bought nothing where the server actually resolves — and +# `@copilotkit/aimock`, a development dependency, shipped as a symlink into a store the prune had +# emptied, along with the two `.bin` shims pointing at it. Every dependency `server/package.json` +# declares resolves from the /prod half; the ones it does not declare are absent now rather than +# present and broken. COPY --from=deps /prod/node_modules node_modules +COPY --from=deps /prod/server/node_modules server/node_modules COPY --from=deps /src/package.json package.json COPY --from=deps /src/bun.lock bun.lock -COPY --from=deps /src/server/node_modules server/node_modules +# The browser's tree is a separate install root with its own lockfile rather than a workspace of the +# one above, so there is no /prod half of it to take and it ships as resolved, `typescript` included. +# Pruning it would mean a second `--production` install in the stage above. COPY --from=deps /src/agent-computer/node_modules agent-computer/node_modules COPY server server @@ -112,6 +122,19 @@ COPY examples examples COPY agent-computer/src agent-computer/src COPY agent-computer/package.json agent-computer/package.json +# `bun run composio:smoke`, because the manifest copied above carries that entry and the question it +# answers belongs here rather than on a laptop: it asks what THIS deployment's Composio key can see, +# and that key is the one in this container's environment. It reads `server/src/plugins/composio*`, +# which is already in the image, so the file itself was the only thing missing and the entry was an +# instruction that could not be followed where it shipped. +# +# ONE FILE, NOT `scripts/`. The others there are the laptop's. `diagram` and `mock:knowledge` reach +# for `roughjs` and `@copilotkit/aimock`, which the prune above removes; `test:ci` runs a suite that +# is not in the image; `generate:app-config` writes a file the build has already baked into +# `app/dist`. Copying the directory ships four more entries with nothing to do here, to fix one that +# has something to do. +COPY scripts/composio-smoke.ts scripts/composio-smoke.ts + # The built app, served by the API on the same origin. There is no CORS in this server, so this is # not a convenience: two origins would simply fail. COPY --from=app-build /src/app/dist app/dist From 121d0ab9129579a98d1d723748c91ee6f1945f38 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:24:19 +0200 Subject: [PATCH 130/215] Say what the wire can send, so the reads that trust it stop compiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor row declarations in the Composio adapter are hand-written TypeScript over values that arrive off a socket, and a TypeScript interface on a wire value is an assertion rather than a check. They asserted more than Composio delivers: `@composio/core`'s `transform()` validates with `safeParse` and, where that fails, logs a warning and returns the unvalidated object anyway (0.18.1, `src/utils/transform.ts:26-36`), so a toolkit with a null name, a count that arrived as a string, an auth config whose status is outside the enum and an account with no id all reach this file exactly as they came off the wire. Every read of them compiled, because the declaration said they could not happen. The declarations now say what the wire can produce, and the two list responses carry the `nextCursor` both vendor schemas have always had and this projection discarded — the signal that a page at the ceiling is a fragment, destroyed at the type, where no caller and no test of a caller could ask about it. DELIBERATELY NON-BEHAVIOURAL. Nothing below the declarations changed; the diff is types and the comments explaining them, the suite that covers this file still passes 24/24, and no value is parsed, coerced or refused that was not parsed, coerced or refused before. Widening the type is the strictly-additive shadow of the parsed boundary this seam actually wants: it changes what a reader is allowed to assume, and leaves what happens at runtime to the fix that follows. WHICH MEANS THE TREE DOES NOT TYPECHECK UNTIL THAT FIX LANDS, and that is the point of the change rather than a side effect. `bunx tsc --noEmit` from `server/` reports three errors — `madeHere` trimming a name that may be null, the directory map handing `BrokerApp[]` a row whose name, description, categories and action count are all wider than it declares, and `revoke` passing an account id that may be absent to a delete that requires a string. Each is a read that was always unguarded and is now unmissable. They are findings, not regressions, and they are closed by writing the guards rather than by silencing the types. Two of the widenings the audit proposed were checked against the installed SDK and deliberately not made. `meta` stays required because `transformToolkitListResponse` builds it and reads through it, so a row without one never arrives — it becomes a `ComposioToolkitFetchError` instead. And the tool row stays as narrow as the SDK's schema because it is the one answer here the SDK really validates: `transformToolCases` ends in a throwing `ToolSchema.parse`, and `ToolkitSchema` spells the inner slug required. Widening either would have been this file asserting something false in the other direction. The full-page refusal in `fetchDirectory` and the one in `composio.ts` were also compared for extraction into a shared helper, and they are not the same refusal: one throws `BrokerRefusalError` and the other a plain `Error`, which is the distinction the route reads to decide whether an operator is told about their API key, and the two sentences name different nouns and different remedies. A helper would have had to pick one of each, which is a behaviour change wearing a refactor's clothes, so both are left where they are. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 99 +++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 667c4e71c..67d721c63 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -54,6 +54,16 @@ import { * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for * what is done when it is in fact missing. + * + * THIS IS THE ONE ROW BELOW WHOSE FIELDS ARE AS NARROW AS THE SDK'S SCHEMA, AND THE REASON IS THAT + * THIS IS THE ONE ROW THE SDK ACTUALLY VALIDATES. `Tools.transformToolCases` ends in + * `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing parse + * rather than the warn-only `transform()` every other listing here goes through — and both calls + * this file makes run through it (`:561` for the listing, `:719` for the single tool). So a tool + * whose `slug` is missing, or whose `toolkit` is present without a `slug`, does not arrive as a + * malformed row: it arrives as a `ZodError`. `ToolkitSchema` spells that inner `slug` required + * (`src/types/tool.types.ts:12-16`), so `toolkit?: { slug: string }` is a guarantee this file may + * rest on, unlike every declaration under it. */ type VendorTool = { slug: string; @@ -70,15 +80,37 @@ type VendorTool = { * `meta` is where all of it lives and every field of it is optional, which is not the SDK being * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. * See {@link ComposioBroker.listApps} below for what each absence becomes. + * + * WIDER THAN THE SDK'S OWN SCHEMA, BECAUSE THE SCHEMA IS NOT ENFORCED ON THIS PATH. `transform()` + * validates with `safeParse` and, where that fails, logs a warning and `return transformed` — the + * unvalidated object — anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`). So + * `ToolKitItemSchema` spelling `name` required and `toolsCount` a number describes the answer + * Composio means to send rather than the one this file has to be able to read: a row whose `name` + * is null, or whose count arrives as a string, reaches the map below exactly as it came off the + * wire and the only thing that noticed was a log line. A TypeScript interface over a wire value is + * an assertion and not a check, so the least this one can do is assert something true. + * + * `meta` ITSELF IS NOT WIDENED, AND THAT IS A FINDING RATHER THAN AN OVERSIGHT. + * `transformToolkitListResponse` builds each row's meta itself and reads `item.meta.categories` + * while doing so (`src/utils/transformers/toolkits.ts:21-34`), so a row that carries no meta never + * arrives here at all — the read throws and `Toolkits.getToolkits` rethrows it as + * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:76-82`). Every row that does arrive carries + * a meta object. Its `categories` are objects for the same reason, the transformer constructing + * each one; the values inside them are copied across verbatim and are therefore worth exactly what + * the wire is worth. + * + * `description` AND `toolsCount` ARE `unknown` RATHER THAN A WIDER UNION, because the wire can put + * anything in them and a union would be another guess. `unknown` is the type that forces the + * reader to say what it does with a value it has not checked, which is the whole point. */ type VendorToolkit = { slug: string; - name: string; + name?: string | null; meta: { - description?: string; - logo?: string; - categories?: { slug: string; name: string }[]; - toolsCount?: number; + description?: unknown; + logo?: string | null; + categories?: { name?: string | null }[]; + toolsCount?: unknown; }; }; @@ -94,11 +126,23 @@ type VendorToolkit = { * `status` because a DISABLED config is still a config: it answers the listing, it satisfies the * "does one exist" question, and a connect link minted against it does not work. The two facts have * to be separable or an app with a disabled config reads as an app that is ready. + * + * BOTH OF THOSE ARE DECLARED AS THE WIRE CAN SEND THEM RATHER THAN AS THE SDK SPELLS THEM, for the + * reason the toolkit row above gives at length: `transformAuthConfigRetrieveResponse` copies + * `name` and `status` across verbatim inside a warn-only `transform()` + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:29-58`), so + * `AuthConfigRetrieveResponseSchema` requiring a string name and an `ENABLED`/`DISABLED` enum is + * not something this file can rest on. A null name reaches {@link madeHere}, and a status the + * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and + * "disabled" are different facts and only one of them is worth telling an operator. + * + * `id` IS LEFT ALONE deliberately: it is as unvalidated as the other two, and widening it belongs + * with the guard that would then be written for it rather than ahead of one. */ type VendorAuthConfig = { id: string; - name: string; - status: "ENABLED" | "DISABLED"; + name?: string | null; + status?: string; }; /** @@ -183,7 +227,22 @@ export type ComposioVendor = { * it rather than mint a link that cannot work. */ showDisabled: true; - }): Promise<{ items: VendorAuthConfig[] }>; + }): Promise<{ + items: VendorAuthConfig[]; + /** + * The vendor's own word for "there is another page", which was being discarded at this type. + * + * `AuthConfigListResponseSchema` carries it (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:129-133`) and + * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer + * (`src/utils/transformers/authConfigs.ts:72-80`). Omitting it here did not make the + * truncation go away; it made it unobservable, because a field a projection does not name is + * a field no caller and no test of a caller can ask about. Declared before anything reads it + * for exactly that reason: the listings below take one page at {@link LISTING_LIMIT} and + * treat it as the whole answer, and the guard that ends that has to have something to read. + */ + nextCursor?: string | null; + }>; create( toolkit: string, options: { type: "use_composio_managed_auth"; name: string }, @@ -230,7 +289,29 @@ export type ComposioVendor = { */ accountType: "ALL"; limit: number; - }): Promise<{ items: { id: string }[] }>; + }): Promise<{ + /** + * The id is the only field read off an account, and it is read off the wire unchecked. + * + * `transformConnectedAccountResponse` spreads the raw item and overrides the fields it + * renames (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:52-66`), so + * `id` arrives exactly as Composio sent it inside the same warn-only `transform()` as + * everything else here. An account with no id is the one shape the revoke below cannot act + * on, and it is the shape nothing was checking for. + */ + items: { id?: string | null }[]; + /** + * The same truncation signal as the auth-config listing above, from the same vendor schema. + * + * `ConnectedAccountListResponseSchema` spells it `nullish` + * (`src/types/connectedAccounts.types.ts:297-303`) and the transformer sets it on every + * answer (`src/utils/transformers/connectedAccounts.ts:109-117`). It matters more here than + * anywhere else in this file: {@link ComposioBroker.revoke} answers `true` for "this + * person's access has ended", and it has only ever seen one page of the accounts it would + * have to end. + */ + nextCursor?: string | null; + }>; /** * Mint one person's connect link against one auth config, with the page to come back to. * From db8d2bf446a3bf9f39330914e1a412f72c115397 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:32:48 +0200 Subject: [PATCH 131/215] Make this suite able to fail on the defects it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two levers from the round-2 convergence audit, both deliberately non-behavioural: nothing under `server/src` executes differently, and the only source edit is the word `export` in front of two existing declarations. THE FIRST ASKS EVERY METHOD OF THE SEAM WHAT ESCAPES IT. The rule — a refusal a reader can act on arrives as a `BrokerRefusalError` naming the step that fixes it, and anything else leaves the route reaching for the vendor's own words — lives entirely in prose, and TypeScript has no checked exceptions, so nothing enumerates the calls that translate and nothing notices a new one that does not. The table drives each of the eight methods twice, at the vendor call that decides its answer rather than the first one on the way in: once with a bare outage carrying nothing, once with a vendor error that NAMES its condition the way `@composio/core`'s own error classes do. What escapes must leave the route a sentence — its own or the vendor's — or appear on an allow-list where each entry says in words why "Composio did not say why, check the key" is the honest answer. The allow-list is asserted in both directions, so an entry outlives the state it describes by exactly one test run. The method list is read off the objects the builder returns and typed against `ComposioBroker` and `ComposioActions`, so a ninth method with no entry fails the table's own completeness test rather than being the door nobody probed. THE SECOND STOPS THE FIXTURES SATISFYING THE PROPERTIES UNDER TEST. `authorize`'s three refusals all name the app, so `toThrow(/linear/)` passed whichever branch ran and the no-config branch could be deleted with the suite green; each site now asserts the remedy unique to its branch and the absence of the other two. The auth-config fixtures arrive spare-first, so a filter alone answers a different config than every assertion names and the stability sort is genuinely under test. The partial config deletion gets the test its revoke twin already had, counted rather than merely thrown. And one table feeds each of the four vendor listings four shapes the wire actually produces — no answer, the envelope from next door, a row that is not an object, a row without the field that identifies it — and asks for a refusal in a sentence rather than the name of a method that was not there. Three test defects the audit noted fall inside those edits and are fixed here: page sizes measured against the imported `LISTING_LIMIT`, which moved with it and pinned nothing; an `expect()` inside a stub whose throw the code under test catches and rewrites into a sentence about Composio; and `"gmail"` used as a malformed shape, which made the assertion that the failure names the app satisfiable by the shape itself. TWENTY-TWO CASES ARE RED ON PURPOSE and must stay red until the fix cycle: the six `named vendor condition` rows of the seam table, where the vendor explained itself and the explanation reaches the reader as advice about their API key, and all sixteen malformed-listing rows, which today produce `config.name.trimEnd is not a function` and its relatives — or no refusal at all, where a slug-less action and an id-less account pass straight through. Those failures are the mandatory findings made falsifiable; closing them is a change to the adapter, not to this file. Spot-checked by mutation rather than by reading: replacing the config sort with a plain copy now kills two tests that survived it before, deleting `authorize`'s no-config branch kills three, and dropping the partial-deletion throw kills three. All three mutations were reverted and the tree is unchanged apart from the two exports, which exist so the smoke script can stop re-implementing the placeholder its own docblock forbids. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 4 +- server/tests/composio-adapter.test.ts | 660 ++++++++++++++++++++++-- server/tests/composio-transport.test.ts | 19 +- 3 files changed, 643 insertions(+), 40 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 4c033e4cc..f28fd1183 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -599,7 +599,7 @@ export function vendorSentence(error: unknown): string | null { * for. Matched on its opening rather than on the whole string, because the slug varies and the * punctuation after it has not been stable across vendor versions. */ -const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; +export const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; /** * What to say when the vendor reported a failure and said nothing about it. @@ -609,7 +609,7 @@ const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; * call or invent a reason. The likely cause by a wide margin is a connection that has lapsed, which * is a person's own two-click fix on the page named here. */ -function unexplained(toolName: string): string { +export function unexplained(toolName: string): string { return `${toolName} failed and Composio did not say why. Check that this app is still connected on its Plugins page, then try again.`; } diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index b8ec6e932..de3f41980 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { BrokerRefusalError, brokerSentence } from "../src/plugins/broker"; -import { LISTING_LIMIT } from "../src/plugins/composio"; +import { + type ComposioBroker, + BrokerRefusalError, + brokerSentence, +} from "../src/plugins/broker"; +import { + type ComposioActions, + LISTING_LIMIT, + vendorSentence, +} from "../src/plugins/composio"; import { buildComposioClient } from "../src/plugins/composio-adapter"; /** @@ -66,6 +74,90 @@ function fakeVendor(parts: { }; } +/** + * The page every listing here asks for, WRITTEN OUT rather than imported. + * + * AN ASSERTION THAT IMPORTS THE CONSTANT IT IS ABOUT CANNOT FAIL WHEN THAT CONSTANT MOVES, because + * both sides move together: each `limit` assertion below asked for whatever page the adapter had + * just decided to ask for, and `LISTING_LIMIT` 1000 -> 20 left all of them passing. The sibling + * `composio-transport.test.ts` writes its two numbers out for exactly this reason and documents it + * at length; this file imported one and called it pinned. + * + * So the literal lives here, and the imported constant is read in exactly one test below — which is + * where the argument for the number belongs: 1000 is the vendor's stated page ceiling and therefore + * the whole listing. + */ +const WHOLE_LISTING = 1000; + +/** The page this deployment sends somebody back to once the consent screen is done with them. */ +const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; + +/** + * The three auth configs this file reasons about, named once rather than spelled at each fixture. + * + * Two of them are ours and one is an operator's dashboard work, and every decision the adapter + * makes about an app's configs is a decision about which of the three it is looking at. Naming + * them is what lets a fixture be written OUT of the order its assertion expects — see {@link MIXED} + * — instead of being an array whose index quietly carries the answer. + */ +const BY_HAND = { id: "ac_by_hand", name: "Linear", status: "ENABLED" }; +const OURS = { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }; +/** The spare from a lost enable race: two administrators both found nothing and both created. */ +const OURS_SPARE = { + id: "ac_ours_spare", + name: "Linear (OpenBot)", + status: "ENABLED", +}; + +/** + * The three sentences `authorize` can refuse with, told apart by the remedy each one prescribes. + * + * `rejects.toThrow(/linear/)` MATCHES ALL THREE, WHICH IS NOT A DETAIL. Deleting the no-config + * branch outright left this suite at 24 pass / 0 fail, because the call then fell through to the + * disabled branch and that sentence names the app too. The three remedies are three different acts + * by three different people — an administrator adding the app again, an operator enabling the + * config in Composio's own dashboard, and nobody at all because the app is not connected by + * visiting a page — so a test that cannot tell the sentences apart cannot tell a correct + * classification from a wrong one, which is the whole thing these refusals exist to get right. + * + * Each is the fragment of its own sentence that no other one contains, and each test below asserts + * its own AND the absence of the others. + */ +const NO_CONFIG_REMEDY = + /removing the app on its Plugins page and adding it again creates one/; +const DISABLED_REMEDY = /can enable it in Composio's dashboard/; +const NO_PAGE_REMEDY = + /connected by entering a credential rather than by visiting a page/; + +/** + * The error a call raised, or a failure saying it answered where the test required a refusal. + * + * `rejects.toThrow(...)` cannot be followed by a second question about the SAME error — which kind + * it was, what else its sentence does not say — so every refusal assertion that wanted more than + * one fact about one throw had to settle for the first. This hands the error over instead. + */ +async function failureOf(work: Promise): Promise { + const raised = await work.then( + () => null, + (error: unknown) => error, + ); + if (raised === null) { + throw new Error( + "The call answered where this test requires it to have refused.", + ); + } + return raised as Error; +} + +describe("the page size this file is written against", () => { + test("the module's ceiling is still the number every assertion here spells out", () => { + // The one place the imported constant is read. Changing `LISTING_LIMIT` reddens exactly this + // test, which is where the argument for the number lives, rather than silently moving every + // assertion in the file to whatever the module has just decided. + expect(LISTING_LIMIT).toBe(WHOLE_LISTING); + }); +}); + describe("listing an app's actions", () => { test("the caller's page reaches the vendor, so the vendor's default never applies", async () => { const asked: unknown[] = []; @@ -91,7 +183,7 @@ describe("listing an app's actions", () => { ); const listed = await actions.listActions("gmail", { - limit: LISTING_LIMIT, + limit: WHOLE_LISTING, }); // An omitted limit is not "no opinion": Composio's page defaults to 20, and @@ -99,7 +191,7 @@ describe("listing an app's actions", () => { // limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the // short answer is also a filtered one and nothing in it says so. Passing the limit through is // what turns that flag off, which is why the assertion is on the query and not on the answer. - expect(asked).toEqual([{ toolkits: ["gmail"], limit: LISTING_LIMIT }]); + expect(asked).toEqual([{ toolkits: ["gmail"], limit: WHOLE_LISTING }]); expect(listed).toEqual([ { slug: "GMAIL_FETCH_EMAILS", @@ -156,7 +248,7 @@ describe("the app catalogue", () => { // anybody actually connects are at the top. No search term: the SDK's list params have no such // field, so one passed here would be stripped before the request and the caller would be // filtering against a list nobody filtered. - expect(asked).toEqual([{ limit: LISTING_LIMIT, sortBy: "usage" }]); + expect(asked).toEqual([{ limit: WHOLE_LISTING, sortBy: "usage" }]); expect(apps).toEqual([ { slug: "gmail", @@ -368,8 +460,15 @@ describe("executing an action", () => { // Both apps are named, because a reader holding only one of them cannot tell whether the url // is wrong or the recorded action is. - await expect(refused).rejects.toThrow(/slack/); - await expect(refused).rejects.toThrow(/gmail/); + const refusal = await failureOf(refused); + expect(refusal.message).toMatch(/slack/); + expect(refusal.message).toMatch(/gmail/); + // AND THE STEP, because a sentence that names both apps and stops there leaves the reader + // holding a contradiction with nothing to do about it. The url this action was recorded under + // has changed, and refreshing the app's tools is what reconciles the two. + expect(refusal.message).toMatch( + /Refreshing this app's tools on its Plugins page/, + ); expect(executed).toEqual([]); }); }); @@ -429,7 +528,7 @@ describe("beginning one person's connection", () => { // would refuse in `fakeVendor` if it were reached. The listing asks for disabled configs too, // because a config it cannot see is one `ensureAuthConfig` would create a second of. expect(listed).toEqual([ - { toolkit: "linear", limit: LISTING_LIMIT, showDisabled: true }, + { toolkit: "linear", limit: WHOLE_LISTING, showDisabled: true }, ]); expect(linked).toEqual([ [ @@ -474,7 +573,15 @@ describe("beginning one person's connection", () => { "https://openbot.test/settings/connected-accounts/composio-linear", }); - await expect(refused).rejects.toThrow(/linear/); + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/linear/); + // THE REMEDY AND NOT THE APP'S NAME. All three of this method's refusals name the app, so + // `/linear/` passes whichever branch was taken and the branch under test could be deleted + // outright without reddening anything. What distinguishes this state is what fixes it. + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_PAGE_REMEDY); // And nothing was begun at the vendor: a link against a config chosen by nobody would attach // this person's account to a configuration this deployment cannot see or tighten. expect(linked).toEqual([]); @@ -492,11 +599,18 @@ describe("beginning one person's connection", () => { * this, and was then read by nobody. */ describe("telling this deployment's auth configs from anybody else's", () => { - /** What a listing of one app's configs looks like when an operator has been in the dashboard. */ - const MIXED = [ - { id: "ac_by_hand", name: "Linear", status: "ENABLED" }, - { id: "ac_ours", name: "Linear (OpenBot)", status: "ENABLED" }, - ]; + /** + * What a listing of one app's configs looks like when an operator has been in the dashboard. + * + * SPELLED OUT OF THE ORDER THE ASSERTIONS EXPECT, which is what makes the sort the thing under + * test rather than scenery. Composio documents no order for this listing; `configsMadeHere` + * filters to ours and then sorts on the id, and every fixture this file used to hold was already + * id-ordered — so the sort could be replaced with a plain copy and all 24 tests stayed green. + * Here the spare arrives FIRST and sorts SECOND, so a filter alone answers `ac_ours_spare` where + * every assertion below names `ac_ours`, and the delete's order is the sort's rather than the + * vendor's. + */ + const MIXED = [OURS_SPARE, BY_HAND, OURS]; test("a connection is begun against the config this deployment made, not the first row", async () => { const linked: unknown[] = []; @@ -535,7 +649,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [MIXED[0]] }), + list: async () => ({ items: [BY_HAND] }), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -552,7 +666,13 @@ describe("telling this deployment's auth configs from anybody else's", () => { returnUrl: "https://openbot.test/settings/connected-accounts/x", }); - await expect(refused).rejects.toThrow(/linear/); + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + // The same state as an app with no configs at all — none of OURS — so the same remedy, and + // not the disabled one: the config that exists here is enabled, and enabling it again is + // advice that would send an operator to a dashboard to change nothing. + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); expect(linked).toEqual([]); }); @@ -561,18 +681,10 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - ...MIXED, - // The spare from a lost enable race: two administrators both found nothing and both - // created. Both are ours, so both go — leaving one behind would leave live grants. - { - id: "ac_ours_spare", - name: "Linear (OpenBot)", - status: "ENABLED", - }, - ], - }), + // Both of ours go — leaving one behind would leave live grants — and the hand-made + // one stands. The listing arrives spare-first, so the order asserted below is the sort's + // and not the vendor's. + list: async () => ({ items: MIXED }), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -590,6 +702,37 @@ describe("telling this deployment's auth configs from anybody else's", () => { ]); }); + test("a config left standing is a failure, and the count is the message", async () => { + /* + * THE TWIN OF THE PARTIAL REVOKE, WHICH HAD TWO TESTS WHILE THIS HAD NONE — so this throw + * could be deleted with the whole suite green. The caller is `removeServer`, which deletes the + * app's row once this returns: a config left standing is a live grant that the removal was + * supposed to end, and nothing in this deployment can find it again afterwards. + * + * The count is asserted rather than the fact of a refusal, because the count is the whole + * remedy: an operator who reads that one of two went knows that pressing remove again finishes + * the job rather than repeats it. + */ + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: MIXED }), + delete: async (id: string) => { + if (id === "ac_ours_spare") { + throw new Error("Composio refused that one."); + } + }, + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /removed 1 of this deployment's 2 authorization configs for linear/, + ); + }); + test("a disabled config of ours stops a second one being created", async () => { const created: unknown[] = []; const { broker } = buildComposioClient( @@ -641,8 +784,13 @@ describe("telling this deployment's auth configs from anybody else's", () => { }); // Sending somebody to consent against a disabled config spends their consent and attaches - // nothing, and nothing on the page they are on can fix it. - await expect(refused).rejects.toThrow(/disabled/); + // nothing, and nothing on the page they are on can fix it. The act that DOES fix it is an + // operator's in Composio's own dashboard, which is the one thing the no-config sentence next + // door never says — so that is what this asserts. + const refusal = await failureOf(refused); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); expect(linked).toEqual([]); }); @@ -651,7 +799,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [MIXED[0]] }), + list: async () => ({ items: [BY_HAND] }), create: async (...call: unknown[]) => { created.push(call); }, @@ -740,7 +888,7 @@ describe("withdrawing one person's grants", () => { "INACTIVE", ], accountType: "ALL", - limit: LISTING_LIMIT, + limit: WHOLE_LISTING, }, ]); }); @@ -770,7 +918,7 @@ describe("withdrawing one person's grants", () => { toolkitSlugs: ["gmail"], statuses: ["ACTIVE"], accountType: "ALL", - limit: LISTING_LIMIT, + limit: WHOLE_LISTING, }, ]); }); @@ -847,7 +995,10 @@ describe("refusals a route can tell from an outage", () => { }) .catch((raised: unknown) => raised); - expect(brokerSentence(error)).toMatch(/An administrator/); + // `/An administrator/` opens two of this method's three refusals, so it recognised the class + // and not the branch. The remedy is what a reader is being handed. + expect(brokerSentence(error)).toMatch(NO_CONFIG_REMEDY); + expect(brokerSentence(error)).not.toMatch(DISABLED_REMEDY); }); test("a consent with nowhere to send anybody is recognised too", async () => { @@ -875,7 +1026,9 @@ describe("refusals a route can tell from an outage", () => { }) .catch((raised: unknown) => raised); - expect(brokerSentence(error)).toMatch(/no page to visit/); + expect(brokerSentence(error)).toMatch(NO_PAGE_REMEDY); + expect(brokerSentence(error)).not.toMatch(NO_CONFIG_REMEDY); + expect(brokerSentence(error)).not.toMatch(DISABLED_REMEDY); }); }); @@ -890,7 +1043,7 @@ describe("a catalogue that might be a fragment", () => { toolkits: { get: async () => { calls += 1; - return Array.from({ length: LISTING_LIMIT }, (_, index) => ({ + return Array.from({ length: WHOLE_LISTING }, (_, index) => ({ slug: `app_${index}`, name: `App ${index}`, meta: {}, @@ -954,3 +1107,438 @@ describe("a catalogue that might be a fragment", () => { ]); }); }); + +/** + * WHAT ESCAPES THE SEAM WHEN THE VENDOR THROWS, asked of every method the seam has. + * + * ONE TABLE RATHER THAN A TEST PER DISCOVERY, because the defect this is about has been found four + * times in four methods and each finding was fixed where it was pointed at. `routes.ts` answers a + * thrown broker error by reaching into it for the vendor's own sentence and, finding none, telling + * the reader that Composio said nothing about why and that an administrator should check this + * deployment's Composio key. That is the right thing to say about a socket that hung up and the + * wrong thing to say about every failure the vendor actually explained — and which of the two a + * reader gets is decided by whether the `await vendor.*` that threw happened to sit inside + * something that translates. TypeScript has no checked exceptions, so nothing enumerates the calls + * that do and nothing notices a new one that does not. + * + * THE PROPERTY, STATED ONCE: an error leaving this seam must leave the route something to say — + * either a {@link BrokerRefusalError}, whose sentence this deployment wrote and whose remedy is + * already in it, or a vendor error whose own sentence {@link vendorSentence} can reach. Anything + * else reaches the reader as "check your Composio key", so anything else has to be named below as + * a failure that genuinely deserves that answer. + * + * THE ALLOW-LIST IS ASSERTED IN BOTH DIRECTIONS, which is what stops it becoming a list of + * excuses. An entry on it must actually arrive unexplained; the day a method starts translating + * the failure named there, its entry reddens and has to be deleted rather than quietly outliving + * the state it describes. + * + * THE METHOD LIST IS THE SEAM'S OWN. It is read off the objects {@link buildComposioClient} + * returns rather than copied into this file, and typed as `keyof ComposioBroker | keyof + * ComposioActions`, so a method added to either seam with no entry here fails the completeness + * test below instead of being covered by nobody. + */ +describe("what a vendor failure becomes on its way out of the seam", () => { + /** + * The two ways a vendor call fails, which are two different questions and not one. + * + * An OUTAGE carries nothing: a socket, a 502 from an edge, a timeout. Nobody wrote a sentence + * about it, so there is none to find and "Composio did not say why" is the honest answer. + * + * A NAMED CONDITION is the opposite case and the one that keeps being missed. `@composio/core` + * raises its own error classes — `ComposioMultipleConnectedAccountsError` and the four others + * down the same door: `ComposioAclOnlyForSharedError`, + * `ComposioFailedToCreateConnectedAccountLink`, `ValidationError`, + * `ComposioRequestCancelledError` — and each one is the vendor saying WHICH condition happened, + * in a message it wrote for a reader. None of it is nested where {@link vendorSentence} looks, so + * a seam that lets one through unexamined converts an explanation into "check the key". + */ + const THROWN = [ + { + kind: "an outage", + raise: () => new Error("socket hang up"), + }, + { + kind: "a named vendor condition", + raise: () => + Object.assign( + new Error( + "Multiple connected accounts found for user user_1 and toolkit linear.", + ), + { name: "ComposioMultipleConnectedAccountsError" }, + ), + }, + ]; + + type SeamMethod = keyof ComposioBroker | keyof ComposioActions; + + /** + * The failures this seam is allowed to hand on unexplained, one entry per state and each with + * its reason written down. + * + * Every one of them is the same claim: the vendor call that failed said nothing this deployment + * could pass on, so "Composio did not answer and an administrator should check the key and their + * status page" is genuinely the best thing a reader can be told. That claim is true of a bare + * `Error` out of a listing and it is NOT true of anything the vendor named. + */ + const GENUINE_OUTAGES: { + method: SeamMethod; + kind: string; + because: string; + }[] = [ + { + method: "listApps", + kind: "an outage", + because: + "The catalogue did not answer. There is no app and no person in the question, so the only remedy is the deployment's key or the vendor's status page.", + }, + { + method: "ensureAuthConfig", + kind: "an outage", + because: + "Creating the config failed with nothing said. An administrator pressed Add; the app is not enabled, and what to check is the key.", + }, + { + method: "authorize", + kind: "an outage", + because: + "Minting the link failed with nothing said. Nobody was sent anywhere and nothing was attached, so trying again is the whole of the advice.", + }, + { + method: "isConnected", + kind: "an outage", + because: + "The account listing did not answer. This is a gate rather than a page, and its caller refuses the run either way.", + }, + { + method: "listActions", + kind: "an outage", + because: + "The action listing did not answer. `./composio`'s own `listingSentence` names the app in what it records, so what is missing here is the vendor's reason and not the subject.", + }, + { + method: "execute", + kind: "an outage", + because: + "The call itself failed with nothing said. `callTool` already refuses to pass the vendor's placeholder on and names the app instead.", + }, + ]; + const UNEXPLAINABLE = new Set( + GENUINE_OUTAGES.map((outage) => `${outage.method}/${outage.kind}`), + ); + + /** + * Every method of the seam, with the vendor call that decides its answer aimed at the throw. + * + * THE CALL THAT DECIDES RATHER THAN THE FIRST ONE. Several of these read a listing before they + * do the thing they are named for, and a vendor whose every method threw would have each of them + * fail at that first read — so the table would be eight tests of one listing and F1, which lives + * behind an auth-config listing that succeeds, would be unreachable. Each entry below answers + * everything on the way in and throws at the step the method exists to perform. + */ + const SEAM_CASES: { + method: SeamMethod; + vendor: (raise: () => Promise) => Parameters[0]; + ask: (client: ReturnType) => Promise; + }[] = [ + { + method: "listApps", + vendor: (raise) => ({ toolkits: { get: raise } }), + ask: ({ broker }) => broker.listApps(), + }, + { + method: "ensureAuthConfig", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [] }), create: raise }, + }), + ask: ({ broker }) => + broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + }, + { + method: "deleteAuthConfig", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [OURS] }), delete: raise }, + }), + ask: ({ broker }) => broker.deleteAuthConfig("linear"), + }, + { + method: "authorize", + vendor: (raise) => ({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { link: raise }, + }), + ask: ({ broker }) => + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + }, + { + method: "isConnected", + vendor: (raise) => ({ connectedAccounts: { list: raise } }), + ask: ({ broker }) => + broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + }, + { + method: "revoke", + vendor: (raise) => ({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: raise, + }, + }), + ask: ({ broker }) => + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + }, + { + method: "listActions", + vendor: (raise) => ({ tools: { getRawComposioTools: raise } }), + ask: ({ actions }) => + actions.listActions("gmail", { limit: WHOLE_LISTING }), + }, + { + method: "execute", + vendor: (raise) => ({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: { slug: "gmail" }, + }), + execute: raise, + }, + }), + ask: ({ actions }) => + actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + {}, + ), + }, + ]; + + test("every method the seam offers has an entry in the table", () => { + // Read off the built objects rather than listed here a second time: a method added to either + // projection arrives in this set, and the table that does not cover it fails here rather than + // being the one door nobody thought to probe. + const seam = buildComposioClient(fakeVendor({})); + expect( + [...Object.keys(seam.broker), ...Object.keys(seam.actions)].sort(), + ).toEqual(SEAM_CASES.map((seamCase) => seamCase.method).sort()); + }); + + for (const seamCase of SEAM_CASES) { + for (const thrown of THROWN) { + const allowed = UNEXPLAINABLE.has(`${seamCase.method}/${thrown.kind}`); + test(`${seamCase.method} meeting ${thrown.kind} leaves the route ${ + allowed + ? "nothing to say, and that is named as an outage" + : "a sentence" + }`, async () => { + const client = buildComposioClient( + fakeVendor( + seamCase.vendor(async () => { + throw thrown.raise(); + }), + ), + () => 1_000_000, + ); + + const escaped = await failureOf(seamCase.ask(client)); + const sentence = brokerSentence(escaped) ?? vendorSentence(escaped); + + if (allowed) { + // The allow-list's other direction. This entry claims the reader cannot be told anything + // useful here; the day that stops being true, this line is what says so. + expect(sentence).toBeNull(); + return; + } + expect(sentence).not.toBeNull(); + expect(sentence?.trim()).not.toBe(""); + }); + } + } +}); + +/** + * WHAT EACH VENDOR LISTING IS ALLOWED TO BE, given that this file's types only assert its shape. + * + * A TYPESCRIPT INTERFACE OVER A WIRE VALUE IS AN ASSERTION AND NOT A CHECK, which is the whole + * reason this table exists. `ComposioVendor` declares these four listings with required fields, and + * the SDK that fills them in is warn-only: `@composio/core`'s own `transform()` logs a `safeParse` + * failure and then returns the unvalidated object, so the shapes below are what actually arrives + * rather than what could theoretically arrive. Each one has been observed reaching the adapter — a + * toolkit with no name, an auth config whose name is null, a connected account with no id, a + * category list containing null. + * + * WHAT A MALFORMED ANSWER MUST NOT BECOME IS A CRASH REPORT. `f.toLowerCase is not a function` and + * `null is not an object` are what these produce today: the first as an unhandled 500 on a live + * route, the rest as a 502 telling an administrator to check a key that is perfectly good. So each + * case asserts that the call refuses, and that the refusal is a sentence rather than the name of a + * method that was not there. + * + * ASSERTED AS A PROPERTY AND NOT AS A WORDING, because the fix does not exist yet and a test that + * pinned the sentence would pin whichever one got written. What is required is that a reader be + * told something, and that nothing be sent to the vendor on the strength of a field the answer did + * not carry. + */ +describe("a vendor listing that is not the shape it is declared to be", () => { + /** A recorder for what went out AFTER the malformed answer came back, which must be nothing. */ + type Probe = { + parts: Parameters[0]; + sent: unknown[]; + }; + + const MALFORMED_LISTINGS: { + listing: string; + answers: { shape: string; answer: unknown }[]; + probe: (answer: unknown) => Probe; + ask: (client: ReturnType) => Promise; + /** Whether the refusal has to be one a route passes through as this deployment's own. */ + authored: boolean; + }[] = [ + { + listing: "the app catalogue", + answers: [ + { shape: "nothing at all", answer: null }, + { + shape: "an envelope where a bare list belongs", + answer: { items: [] }, + }, + { shape: "a row that is not an object", answer: ["gmail"] }, + { + shape: "a row with no slug", + answer: [{ name: "Gmail", meta: {} }], + }, + ], + probe: (answer) => ({ + parts: { toolkits: { get: async () => answer } }, + sent: [], + }), + ask: ({ broker }) => broker.listApps(), + authored: true, + }, + { + listing: "this app's auth configs", + answers: [ + { shape: "nothing at all", answer: null }, + { shape: "a bare list where an envelope belongs", answer: [] }, + { + shape: "a row that is not an object", + answer: { items: ["ac_ours"] }, + }, + { + shape: "a row with no name", + answer: { items: [{ id: "ac_ours", status: "ENABLED" }] }, + }, + ], + probe: (answer) => { + const sent: unknown[] = []; + return { + parts: { + authConfigs: { list: async () => answer }, + connectedAccounts: { + link: async (...call: unknown[]) => { + sent.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }, + sent, + }; + }, + ask: ({ broker }) => + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + authored: true, + }, + { + listing: "this person's accounts", + answers: [ + { shape: "nothing at all", answer: null }, + { shape: "a bare list where an envelope belongs", answer: [] }, + { shape: "a row that is not an object", answer: { items: ["ca_1"] } }, + { shape: "a row with no id", answer: { items: [{}] } }, + ], + probe: (answer) => { + const sent: unknown[] = []; + return { + parts: { + connectedAccounts: { + list: async () => answer, + delete: async (...call: unknown[]) => { + sent.push(call); + }, + }, + }, + sent, + }; + }, + ask: ({ broker }) => + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + authored: true, + }, + { + listing: "this app's actions", + answers: [ + { shape: "nothing at all", answer: null }, + { + shape: "an envelope where a bare list belongs", + answer: { items: [] }, + }, + { + shape: "a row that is not an object", + answer: ["GMAIL_FETCH_EMAILS"], + }, + { + shape: "a row with no slug", + answer: [{ description: "Fetch emails from Gmail." }], + }, + ], + probe: (answer) => ({ + parts: { tools: { getRawComposioTools: async () => answer } }, + sent: [], + }), + ask: ({ actions }) => + actions.listActions("gmail", { limit: WHOLE_LISTING }), + // `./composio` authors this one's sentence, not `./broker`: a listing failure is recorded in + // the app's `lastError` rather than answered to a route, so what is required here is a + // sentence and not a class. + authored: false, + }, + ]; + + /** What a failure must never read like: the name of a method that was not there. */ + const A_CRASH = + /is not a function|is not an object|is not iterable|undefined is not|cannot read propert/i; + + for (const listing of MALFORMED_LISTINGS) { + for (const { shape, answer } of listing.answers) { + test(`${listing.listing} answered with ${shape} is refused in a sentence`, async () => { + const probe = listing.probe(answer); + const client = buildComposioClient( + fakeVendor(probe.parts), + () => 1_000_000, + ); + + const failure = await failureOf(listing.ask(client)); + + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message.trim()).not.toBe(""); + if (listing.authored) { + expect(brokerSentence(failure)).not.toBeNull(); + } + // Nothing was sent to the vendor on the strength of a field the answer did not carry: an + // id-less account reaching the delete is a request to withdraw `undefined`, which the + // vendor is free to read as anything at all. + expect(probe.sent).toEqual([]); + }); + } + } +}); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 99c3e6565..7bb9e6028 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -346,9 +346,14 @@ describe("listing an app's actions", () => { }); test("an action arrives with its schema, its effect and its version", async () => { + // RECORDED AND ASSERTED AFTERWARDS, NOT CHECKED INSIDE THE STUB. `listTools` wraps this call in + // the try that turns a throw into `listingSentence`'s sentence, so a failed `expect` in there + // is not a failed test: it is swallowed and re-emerges as a refusal about Composio, which this + // test would then report as a listing failure rather than as the wrong app being asked for. + const asked: string[] = []; const { client } = recording({ listActions: async (toolkit) => { - expect(toolkit).toBe("gmail"); + asked.push(toolkit); return [GMAIL_READ]; }, }); @@ -367,6 +372,7 @@ describe("listing an app's actions", () => { version: "20260903_00", }, ]); + expect(asked).toEqual(["gmail"]); }); test("an action that stages a file is not offered at all", async () => { @@ -781,7 +787,16 @@ describe("listing an app's actions", () => { */ // `[null]` is the same failure one level down: it clears `Array.isArray` and then reaches // `action.inputParameters` in the filter, which is outside that try as well. - for (const shape of [null, undefined, { items: [] }, "gmail", [null]]) { + // NOT `"gmail"` AS THE SCALAR, which is what this loop used to carry: the assertion below is + // that the sentence names the app, and a shape that IS the app's name satisfies it whether the + // name came from the url or from the malformed answer being echoed back. + for (const shape of [ + null, + undefined, + { items: [] }, + "an action list", + [null], + ]) { useComposioClient( recording({ listActions: async () => shape as unknown as ComposioAction[], From 883c9c3d52b1d11516992401c6b32f7005fc6258 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:44:55 +0200 Subject: [PATCH 132/215] Refuse a return address nobody could come back through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComposioBroker.authorize`'s `returnUrl` is documented as REQUIRED because a consent with no return leg strands the person who granted it, but the field is spelled `string` and nothing between the route and the vendor ever looked at the value. The adapter hands it to `connectedAccounts.link` as `callbackUrl` unread, so anything the route assembled went to Composio as the address a just-consented person would be sent back to. The literal empty string the review named is not reachable from the one caller: the connect route already answers 503 when `appUrl` is unset, and `connectedAccountsUrlFor` always appends a path, so it cannot return "". The defect that sentence describes is reachable by another door. `appUrl` is an environment string — `OPENBOT_APP_URL`, else the first `TRUSTED_ORIGINS` entry — and `config.ts` only strips its trailing slashes. Set it to `openbot.example.com` and the route builds `openbot.example.com/settings/connected-accounts/composio-linear`, which is not an address a browser on Composio's origin can follow; set it to `localhost:3001` and `localhost:` is parsed as the scheme. Both end where an empty callback would: somebody has granted a third party access to their mailbox and is looking at the vendor's hosted page with no way back here. So the guard is `brokerReturnUrl` in `./broker`, beside the field whose comment makes the promise, and the route hands its address through it before handing it to the broker. It is a runtime check rather than a type because the type cannot do it: the address is assembled at run time from an environment variable, the caller holds a `string`, and every type an ordinary `string` is assignable to admits the empty one too. A branded return-address type would have to be minted by this same check anyway, and would break the call sites below without making any of them safer. The refusals are `BrokerReturnUrlError`, a `BrokerRefusalError` — so `routes.ts` recognises them as authored, passes the sentence through and answers 503 rather than telling an operator to go and check a Composio key that is fine. Each names `OPENBOT_APP_URL`, which is the step that fixes it, and neither quotes the address it refused, for the reason the connect url is never logged. Exported types unchanged — `returnUrl` is still `string`, and the additions are two new exports. Call sites of `authorize` swept all the same, since a type change was the alternative considered: `routes.ts:802` is the only producer in the tree and is the one now guarded; `composio-adapter.ts:869` is the only implementation; the fakes and callers in `plugin-routes.test.ts` and `composio-adapter.test.ts` pass absolute `http`/`https` literals, which the guard accepts unchanged. `app/` builds no return address at all. Red before green: the four new cases failed on the missing export, then passed once the guard existed. Four mutants of it were killed by the suite — dropping the empty check, accepting any scheme, returning a constant address, and quoting the refused address in the message. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 81 +++++++++++++++++++++++ server/src/plugins/routes.ts | 16 +++-- server/tests/composio-broker-seam.test.ts | 72 ++++++++++++++++++-- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 3e999820e..82039c409 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -96,6 +96,15 @@ export type ComposioBroker = { * a query or a header would be an open redirect with a consent screen in front of it. The one * caller builds it from the deployment's configured app URL and narrows the page within it to * a known name, the same way this repository's own OAuth `returnTo` is narrowed. + * + * AND `string` IS THE WHOLE OF WHAT THE TYPE CAN PROMISE, WHICH IS WHY + * {@link brokerReturnUrl} EXISTS. "Required" above means "not optional", and `""`, `" "` and + * `openbot.example.com/settings/...` are all required values: they satisfy this field and + * reach the vendor as a callback nobody returns through. Nor can a narrower type fix it — the + * address is assembled at run time from an environment variable, so the caller holds a + * `string` and every type an ordinary `string` is assignable to admits the empty one too. The + * promise this comment makes is therefore kept by the guard below, and a caller hands its + * address through that before it hands it here. */ returnUrl: string; }): Promise<{ redirectUrl: string }>; @@ -181,6 +190,78 @@ export class BrokerUnconfiguredError extends BrokerRefusalError { } } +/** + * A return address that could not bring anybody back, refused before a consent is spent on it. + * + * ITS OWN CLASS BECAUSE ITS REMEDY IS ITS OWN. Every other refusal in this file is about Composio — + * a key that is not set, a config this deployment never made, a consent the vendor answered with no + * page. This one is about this deployment's own address for itself, and the person who can act on it + * is an operator with `OPENBOT_APP_URL` in front of them. A caller that could not tell the two apart + * would send somebody to check a Composio key that is perfectly fine. + * + * A {@link BrokerRefusalError} because it keeps that class's promise about the message: the sentence + * is written here, names the step that fixes it, and carries no url. Which matters more than usual + * for this one — the value it is refusing is the thing a bad message would be tempted to quote, and + * an address is the half of a connect link that says which deployment and which person it is for. + */ +export class BrokerReturnUrlError extends BrokerRefusalError { + constructor(message: string) { + super(message); + this.name = "BrokerReturnUrlError"; + } +} + +/** + * The address a person comes back to, checked to be one, or a refusal instead of a link. + * + * BEFORE THE CONSENT RATHER THAN AFTER IT, which is the entire value of doing this at all. Past this + * point the next thing that happens is a vendor page and somebody granting a third party access to + * their mailbox; a callback that is not a callback is only discovered once they have, by which time + * the thing that would tell them what went wrong is on the deployment they can no longer reach. So a + * caller that has no usable address gets a refusal in place of a link, and nobody spends a consent. + * + * TWO REFUSALS, BECAUSE THEY ARE TWO DIFFERENT MISTAKES. An empty address is a caller that built + * none — the guard in front of this one did not run, or ran against the wrong value. An address that + * is not a web page is a configured one that cannot work: `OPENBOT_APP_URL` set to + * `openbot.example.com`, which no browser can resolve from Composio's origin, or to `localhost:3001`, + * where `localhost:` is read as the scheme. Both are reachable from the settings this deployment + * actually ships — the variable is an environment string and nothing between it and the vendor looks + * at it — and both end with the same person on the same hosted page with nowhere to go. + * + * IT NAMES THE SETTING AND NOT THE VALUE. The setting is the remedy, and it is the same one whether + * the address arrived empty or malformed; the value is a page address for one person's connection + * and belongs in no message, no log and no audit row, for the reason the connect url does not. + * + * The address is returned unchanged rather than normalised. Whatever a caller passes is what the + * vendor is told and what the person's browser is sent to, so a guard that quietly rewrote it would + * be choosing a destination — and choosing where somebody lands holding a just-completed consent is + * exactly the decision this seam keeps in one place. + */ +export function brokerReturnUrl(returnUrl: string): string { + if (returnUrl.trim() === "") { + throw new BrokerReturnUrlError( + "This deployment built no address for Composio to send you back to, so the connection was not begun rather than begun with nowhere to land. Set OPENBOT_APP_URL to the address this deployment's pages are served from, and connecting an app will have a return leg.", + ); + } + if (!isWebAddress(returnUrl)) { + throw new BrokerReturnUrlError( + "The address Composio would send you back to is not a web address, so a consent granted there would end on Composio's own page with no way back here. Set OPENBOT_APP_URL to this deployment's own origin including the scheme — https://openbot.example.com rather than openbot.example.com.", + ); + } + return returnUrl; +} + +/** Whether a browser on somebody else's origin could follow this: absolute, and http or https. */ +function isWebAddress(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + return parsed.protocol === "https:" || parsed.protocol === "http:"; +} + /** * The broker failures worth explaining to a reader, and null for every other one. * diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 9fa6fc7f9..f7bab51e3 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,6 +6,7 @@ import { requireAdmin } from "../auth/guards"; import { type BrokerApp, BrokerUnconfiguredError, + brokerReturnUrl, brokerSentence, type ComposioBroker, } from "./broker"; @@ -801,10 +802,17 @@ export function createPluginRoutes( ({ redirectUrl } = await composio.broker.authorize({ userId: context.var.actor.id, toolkit, - returnUrl: connectedAccountsUrlFor( - connect.appUrl, - { serverId }, - returnTo, + /* + * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. + * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` + * entry — and nothing between there and Composio has ever looked at it, so + * `openbot.example.com` with the scheme left off builds a callback that is not a + * callback. That failure lands after somebody has consented, on the vendor's page, where + * this deployment cannot tell them anything; the guard moves it to before the link is + * minted, where the sentence reaches an operator who can set the variable. + */ + returnUrl: brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), ), })); } catch (error) { diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 2ccdb60c1..b8829159e 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -1,12 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { BrokerUnconfiguredError, brokerSentence } from "../src/plugins/broker"; +import { + BrokerReturnUrlError, + BrokerUnconfiguredError, + brokerReturnUrl, + brokerSentence, +} from "../src/plugins/broker"; /** - * The broker seam's two decisions, asserted with no network and no database. + * The broker seam's three decisions, asserted with no network and no database. * - * Everything else in `./broker` is a type, which a type checker settles and a test cannot. What is - * left to assert is the pair of runtime facts the module exists to fix: what an unconfigured - * deployment says about itself, and what it refuses to say about anything else. + * Most of `./broker` is a type, which a type checker settles and a test cannot. What is left to + * assert is the runtime facts the module exists to fix: what an unconfigured deployment says about + * itself, what it refuses to say about anything else, and which return addresses it will not begin + * a consent against. * * The second of those is the one worth a file. `brokerSentence` is what a caller reaches for when a * broker call throws, and the tempting shape — a fallback sentence for anything it does not @@ -27,3 +33,59 @@ describe("brokerSentence", () => { expect(brokerSentence(new Error("socket hang up"))).toBeNull(); }); }); + +/** + * The return address, which is the one value on this seam a type cannot settle. + * + * `authorize`'s `returnUrl` is documented as required and spelled `string`, and `""`, `" "` and + * `openbot.example.com/settings/...` all satisfy that: they reach Composio as a callback nobody + * returns through, and the person who finds out is the one who has just granted a third party + * access to their mailbox. The route assembles the address at run time from `OPENBOT_APP_URL`, an + * unvalidated environment string, so `string` really is the strongest promise it can make and the + * check belongs here rather than in the type. + * + * Each refusal is asserted by the half of its sentence that only it says. Both name + * `OPENBOT_APP_URL`, because both are fixed there, so a test that asked only for the setting would + * pass just as well if the two branches collapsed into one — and they are two different mistakes: + * an address nobody built, and a configured one that cannot work. + */ +describe("brokerReturnUrl", () => { + test("refuses an address that was never built", () => { + expect(() => brokerReturnUrl("")).toThrow(/built no address/); + expect(() => brokerReturnUrl(" ")).toThrow(/built no address/); + expect(() => brokerReturnUrl("")).toThrow(BrokerReturnUrlError); + }); + + test("refuses an address no browser could come back through", () => { + for (const unusable of [ + "openbot.example.com/settings/connected-accounts/x", + "localhost:3001/settings/connected-accounts/x", + "/settings/connected-accounts/x", + "javascript:alert(1)", + ]) { + expect(() => brokerReturnUrl(unusable)).toThrow(/not a web address/); + } + }); + + test("says which setting fixes it, and never quotes the address", () => { + let thrown: unknown; + try { + brokerReturnUrl("openbot.example.com/settings/connected-accounts/x"); + } catch (error) { + thrown = error; + } + + const sentence = brokerSentence(thrown); + expect(sentence).toContain("OPENBOT_APP_URL"); + expect(sentence).not.toContain("/settings/connected-accounts/x"); + }); + + test("hands back the address a configured deployment built", () => { + expect( + brokerReturnUrl("https://openbot.test/settings/connected-accounts/x"), + ).toBe("https://openbot.test/settings/connected-accounts/x"); + expect(brokerReturnUrl("http://localhost:3001/admin/plugins/x")).toBe( + "http://localhost:3001/admin/plugins/x", + ); + }); +}); From 54175ffb728d06733c674b4c680094283e997c96 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:44:55 +0200 Subject: [PATCH 133/215] Ask a broker that answers, and read only the rows this run owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things kept this suite from failing on what it names. THE STORE HAD NO BROKER, so `vendorRevocationRequested: false` was the absent dependency and never the implementation: `removeServer` and `retireConnectionsFor` both spell the brokerless case as that constant, and with nothing to call, deleting all three broker call sites changed nothing here. All five broker behaviours survived this file and were killed only by `plugin-store.integration.test.ts`. The store is now built with a spy in the shape that suite's own keeps — what is not named throws — recording each ask by app and person, and reading the connection table inside each handler so "revoke before delete" is an assertion rather than a comment. A new test gives one act two people and the vendor a different answer for each, which is the smallest shape that tells a passed-through answer from a constant of either polarity. THE ANONYMOUS ROW WAS READ ACROSS THE WHOLE TABLE. `notNull` admits the empty string, so every run inserts a legal `(toolkit, "")` and only the app half is this run's. Asking what `""` had connected therefore took in every other run's row, including one left by a run interrupted before its cleanup — and against the shared development database that row is permanent, out of reach of the cleanup here, and reddens this file for everybody until somebody edits the database by hand. The read is narrowed to this run's app and ordered, since Postgres promises no order without one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 254 ++++++++++++++++++++-- 1 file changed, 239 insertions(+), 15 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 8cffa1f2f..8af4474b8 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import type { ActionPolicy } from "../src/computer/policy"; import type { @@ -16,6 +16,7 @@ import { pluginGrants, users, } from "../src/db/schema"; +import type { ComposioBroker } from "../src/plugins/broker"; import type { ComposioActions, ComposioResult } from "../src/plugins/composio"; import { useComposioClient } from "../src/plugins/composio"; import { createPluginStore } from "../src/plugins/store"; @@ -129,9 +130,108 @@ const auditStore = { }, }; +/** + * Every connection row THIS RUN owns, as `/`, in a fixed order. + * + * SCOPED TO THE APP AND ORDERED, both load-bearing. {@link toolkit} carries this run's suffix, so + * this reads nothing another run inserted — which matters most for the anonymous actor, whose half + * of the key names nobody and is therefore the one pair another run legitimately holds too. A read + * filtered on the person alone would take in every app's anonymous row at once, and a run that died + * before its cleanup would leave one standing that no cleanup here can reach: these tests run + * against the shared development database, so that row would redden this file for everybody until + * somebody edited the database by hand. The ordering is the same argument one step down — Postgres + * promises none without one, so an unordered read of two rows is compared against whichever order + * the plan happened to produce. + */ +async function connectionsHeld(): Promise { + const rows = await database + .select({ + toolkit: composioConnections.toolkit, + userId: composioConnections.userId, + }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, toolkit)) + .orderBy(asc(composioConnections.userId)); + return rows.map((row) => `${row.toolkit}/${row.userId}`); +} + +/** + * THE BROKER, ASKED FOR REAL, because what these tests name is its own answer. + * + * Every `mcp.account_disconnected` row here carries `vendorRevocationRequested`, and the whole + * value of that field is that a reader can tell an account this deployment ended at Composio from + * one that outlives it there. A store built with NO broker cannot produce anything but `false` for + * it: `removeServer` and `retireConnectionsFor` both spell the absent-broker case as that constant. + * So a suite asserting `false` against a brokerless store was asserting the missing dependency and + * never the implementation — and the same absence hid the revokes themselves and the auth config, + * because with nothing to call, deleting all three call sites changed nothing this file could see. + * + * WHAT IS NOT NAMED THROWS, the discipline `plugin-store.integration.test.ts`'s own spy keeps, and + * for its reason. "The removal asked the broker to revoke" is worth little beside "and asked it + * nothing else": a removal that also listed the catalogue or began somebody's connection would be + * acting on somebody's behalf in a way nothing here has reasoned about, and a stub answering + * plausibly would let that pass unremarked. Nothing in this file enables an app or confirms a + * connection, so those four methods have no caller here and say so. + */ +const unasked = (what: string) => async (): Promise => { + throw new Error(`this suite's path asked the broker to ${what}`); +}; + +/** + * Each ask that reached the vendor, in order, with what this run's table held at the moment of it. + * + * `held` IS HOW "REVOKE BEFORE DELETE" BECOMES AN ASSERTION, and that order is the whole of both + * removals: the row is the only thing in this deployment naming which app a person connected, so a + * delete that ran first would leave a failed revoke with nothing to revoke under — a live grant on + * somebody's mailbox that no operation here could reach. A spy that only counted calls would see + * the two orders identically, so each handler reads the table itself rather than recording its own + * arguments. + */ +const asks: { ask: string; held: string[] }[] = []; + +/** The asks alone, which is what an ordering assertion is about. */ +function asksMade(): string[] { + return asks.map((entry) => entry.ask); +} + +/** + * Whether the vendor finds an account to withdraw, which is the answer the trail has to carry. + * + * A function of the request rather than a flag, so one act can be given a different answer per + * person — the shape that tells a passed-through answer from a constant of either polarity. + */ +let vendorFinds: (request: { userId: string; toolkit: string }) => boolean = + () => true; + +const broker: ComposioBroker = { + listApps: unasked("list the catalogue"), + ensureAuthConfig: unasked("create an auth config"), + authorize: unasked("begin somebody's connection"), + isConnected: unasked("check somebody's connection"), + revoke: async (request) => { + asks.push({ + // Named by app AND person: "two revokes happened" says nothing about who they were for, and + // for `removeServer` who they were for is the whole of what makes a removal repeatable. + ask: `revoke:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + return vendorFinds(request); + }, + deleteAuthConfig: async (forToolkit) => { + // Named by app as well, because the app and the `mcp_servers` id are allowed to differ and the + // config belongs to the app. A removal that dropped the config for the row id would be deleting + // a shape this deployment never made and leaving standing the one it did. + asks.push({ + ask: `deleteAuthConfig:${forToolkit}`, + held: await connectionsHeld(), + }); + }, +}; + const store = createPluginStore({ database, auditStore, + broker, credentials: credentialsStub, encryptionKey: "x".repeat(44), policy: () => policy, @@ -210,12 +310,29 @@ async function seedApp(options: { connect?: boolean } = {}) { } } -/** What this deployment still believes somebody has connected. */ +/** + * Which of THIS RUN'S apps this deployment still believes somebody has connected. + * + * Narrowed to {@link toolkit} and ordered for the reason {@link connectionsHeld} gives, which is + * the same reason and matters for the same row: the anonymous actor. `notNull` admits the empty + * string, so `(toolkit, "")` is a legal pair and every run of this file inserts one — and the only + * half of it that is this run's is the app. Asking what `""` has connected across the whole table + * therefore reads every other run's anonymous row too, including one left behind by a run that was + * interrupted before its cleanup; against the shared development database that row is permanent, + * unreachable by the cleanup here, and reddens this file for everybody until the database is edited + * by hand. Narrowing to this run's app is what makes the assertion about this run. + */ async function connectedToolkitsFor(userId: string): Promise { const rows = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) - .where(eq(composioConnections.userId, userId)); + .where( + and( + eq(composioConnections.userId, userId), + eq(composioConnections.toolkit, toolkit), + ), + ) + .orderBy(asc(composioConnections.toolkit)); return rows.map((row) => row.toolkit); } @@ -229,6 +346,10 @@ beforeEach(async () => { await clean(); events.length = 0; reached.length = 0; + asks.length = 0; + // The vendor finding an account is the ordinary case — somebody connected, so there is a grant to + // withdraw. The one test about the answer itself says otherwise for itself. + vendorFinds = () => true; }); afterEach(() => useComposioClient(null)); @@ -258,6 +379,17 @@ test("offboarding somebody retires the app they connected, and the next call is expect(retired).toBe(1); expect(await connectedToolkitsFor(askerId)).toEqual([]); + /* + * THE ACCOUNT ENDED AT THE VENDOR, not merely forgotten here, which is the half an administrator + * was actually promised. Deleting the row shuts the gate this deployment owns and does nothing to + * the grant: the person's mailbox stays attached at Composio and the offboarding was a lie about + * the only thing that matters. So the ask is asserted, and asserted WHILE THE ROW STILL STOOD — + * the row is the only thing naming which app to revoke, so the other order leaves a failed revoke + * with nothing to revoke under. + */ + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(asks[0].held).toEqual([`${toolkit}/${askerId}`]); + await expect( store.callTool({ ref, args: {}, botId, actorId: askerId }), ).rejects.toThrow(/have not connected/i); @@ -269,11 +401,11 @@ test("offboarding somebody retires the app they connected, and the next call is actor: admin, server: toolkit, owner: askerId, - // An administrator removing somebody, never somebody changing their own mind. And false - // because this store was built with no broker, so there was nobody to revoke at: - // `retireConnectionsFor` ends the account at Composio on a deployment that has one. + // An administrator removing somebody, never somebody changing their own mind. And true because + // the broker answered that it had found this person's account and asked for its withdrawal: + // the field is the vendor's own answer passed through, not that a call was made. reason: "person_removed", - vendorRevocationRequested: false, + vendorRevocationRequested: true, }); }); @@ -305,6 +437,10 @@ test("a connection whose person is already deleted is retired, and stops passing const { retired } = await store.retireConnectionsFor(leaverId, admin); expect(retired).toBe(1); expect(await connectedToolkitsFor(leaverId)).toEqual([]); + // The grant is withdrawn for somebody who no longer exists here, which is the point of the row + // outliving the person: nothing else in this deployment still names the app they connected. + expect(asksMade()).toEqual([`revoke:${toolkit}/${leaverId}`]); + expect(asks[0].held).toEqual([`${toolkit}/${leaverId}`]); await expect( store.callTool({ ref, args: {}, botId, actorId: leaverId }), @@ -318,7 +454,12 @@ test("retiring the same person twice retires nothing the second time", async () useAnsweringClient(); expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(1); + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(0); + // Quiet at the vendor too, and not only in the count. The rows are gone, so there is no app left + // to name — a second pass that asked Composio again would be this deployment guessing. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); }); /** @@ -339,6 +480,9 @@ test("retiring nobody retires nothing and leaves the anonymous row alone", async expect((await store.retireConnectionsFor("", admin)).retired).toBe(0); expect(await connectedToolkitsFor("")).toEqual([toolkit]); + // And nothing reached Composio either. An unattributed offboarding has no account to name, so a + // revoke sent under an empty user id would be this deployment asking the vendor about nobody. + expect(asksMade()).toEqual([]); }); /** @@ -360,12 +504,7 @@ test("the sweep takes this run's anonymous row without reaching by actor", async await clean(); - expect( - await database - .select({ userId: composioConnections.userId }) - .from(composioConnections) - .where(eq(composioConnections.toolkit, toolkit)), - ).toEqual([]); + expect(await connectionsHeld()).toEqual([]); }); /** @@ -384,6 +523,22 @@ test("removing the app takes every brokered connection to it", async () => { expect(await connectedToolkitsFor(askerId)).toEqual([]); + /* + * THE THREE ASKS THIS ACT OWES THE VENDOR, IN THIS ORDER. + * + * Every connected person revoked first, while the rows naming the app still stand, for the reason + * offboarding revokes first. Then the auth config, LAST OF ALL: an orphaned config grants nobody + * anything, while a live account whose config has already been deleted is access nothing left + * here can end. And the config is dropped for the APP, which the `deleteAuthConfig:` half of the + * entry carries. + */ + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); + expect(asks[0].held).toEqual([`${toolkit}/${askerId}`]); + expect(asks[1].held).toEqual([]); + const disconnected = recordedOfType("mcp.account_disconnected"); expect(disconnected).toHaveLength(1); expect(disconnected[0].payload).toMatchObject({ @@ -393,10 +548,65 @@ test("removing the app takes every brokered connection to it", async () => { // An administrator took the whole app away and the person did nothing. Distinct from both // "they disconnected" and "they were removed", which is what an auditor is trying to tell apart. reason: "mcp_server_removed", - vendorRevocationRequested: false, + // And the vendor's own answer about this person's account, passed through. + vendorRevocationRequested: true, }); }); +/** + * WHAT WAS ASKED OF THE VENDOR, NOT THAT A CALL WAS MADE. + * + * CRITERION. `vendorRevocationRequested` on each row is the broker's own answer about THAT person. + * + * REASON. The field exists so a reader can tell an account this deployment ended at Composio from + * one that outlives it somewhere else — a gate cleared here with no grant left at the vendor, and a + * grant the vendor really held and was asked to withdraw. A constant is worse than no field at all, + * because it reads as evidence about every row while describing none of them; it is how the field + * came to be renamed from `vendorRevoked`, when every row saying a grant had been withdrawn was + * describing one still live at Google. + * + * TWO PEOPLE IN ONE ACT, the vendor finding an account for one and none for the other, is the + * smallest shape that tells a passed-through answer from a constant of EITHER polarity: one row + * alone is satisfied by a hardcoded `true` just as the brokerless store satisfied a hardcoded + * `false`. + */ +test("the trail carries the vendor's answer per person, not one answer for the act", async () => { + await seedApp(); + await database + .insert(composioConnections) + .values({ toolkit, userId: leaverId }); + vendorFinds = ({ userId }) => userId === askerId; + + await store.removeServer(toolkit, admin); + + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `revoke:${toolkit}/${leaverId}`, + `deleteAuthConfig:${toolkit}`, + ]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + expect( + disconnected + .map( + (event) => + event.payload as { + owner: string; + vendorRevocationRequested: boolean; + }, + ) + .map(({ owner, vendorRevocationRequested }) => ({ + owner, + vendorRevocationRequested, + })) + .sort((left, right) => left.owner.localeCompare(right.owner)), + ).toEqual([ + { owner: askerId, vendorRevocationRequested: true }, + { owner: leaverId, vendorRevocationRequested: false }, + ]); +}); + /** * ONE KEY FOR "WHAT HAPPENED TO THIS PERSON'S ACCESS", across both acts that can end it. * @@ -442,6 +652,15 @@ test("both acts that end a brokered connection file it under the app", async () expect((await store.retireConnectionsFor(leaverId, admin)).retired).toBe(1); await store.removeServer(renamedId, admin); + // The broker is asked about the APP in both acts, and the auth config dropped for the app too — + // never for the row id, which is a display key the vendor has never heard of. This is the one + // fixture where the two spellings differ, so it is the only one that can tell them apart. + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${leaverId}`, + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); + const disconnected = recordedOfType("mcp.account_disconnected"); expect(disconnected).toHaveLength(2); // Both rows, under one key. Asked as the set of keys rather than row by row, because what the @@ -484,9 +703,14 @@ test("adding the app back does not restore a connection nobody re-granted", asyn expect(reached).toEqual([actionName]); await store.removeServer(toolkit, admin); + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `deleteAuthConfig:${toolkit}`, + ]); // The same app at the same id, added again. Only the server and its action: the Bot's grant // survived the removal on its own, which is a separate defect about `plugin_grants` and not this - // one. + // one. Added by insert rather than through `addBrokeredApp`, so nothing asks the broker again — + // the refusal below is the consent being gone and not an auth config that was never remade. await addApp(); await expect( From f2cfb81c3b02b5fe10013cb10b4454a7f876ebe8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:47:19 +0200 Subject: [PATCH 134/215] Keep this transport's own words on the failures it reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects, all of them the same shape: a value the vendor sent decided what a person or a model reads. A `ZodError` out of the adapter's pre-flight resolve reached `callTool`'s catch with the issue array as its message, so the dump went to the model and into the audit row; the listing path has refused that string since it was written, and now this one does too. The guess that recognises it was duck-typing on the NAME `issues`, which a gateway's validation payload carries as readily as Zod does — so a vendor explaining itself was told to upgrade a package that was working. `vendorSentence` was preferred over the placeholder guard, so "Error executing the tool X" nested in `cause` walked past a check written for exactly that string. In the listing, `tags` reached `effectOf` unchecked: a non-iterable threw `{} is not iterable` into `lastError`, and a string quietly recorded a read-only action as a write. The recorded name kept padding the guard beside it had already measured away. And the file filter could empty a listing, which `refreshTools` commits as an app that advertises nothing — the tool-and-version wipe this function's four other refusals exist to prevent. `JSON.stringify` answers `undefined` rather than throwing for a value with no JSON form, so the cap measured `.length` on it and the engine's own sentence became the second half of one this file wrote. A seventh finding is refuted rather than fixed: duplicate slugs do not fail the insert. `storableTools` keys it by name and keeps the first occurrence, deliberately, and `plugin-store.integration.test.ts` pins one row and a healthy refresh. Refusing here would have stranded every grant on the app. What that de-duplication needed was the trimmed name, which it now gets; the reasoning is written down beside the guards so the refusal is not proposed again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 183 ++++++++++++-- server/tests/composio-transport.test.ts | 313 ++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 24 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index f28fd1183..460849839 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -499,6 +499,42 @@ export async function listTools(connection: { ); } + /* + * A SLUG LISTED TWICE IS NOT REFUSED HERE, and that is a decision rather than an omission. + * + * `mcp_tools` holds one row per name, so the collision is real — but it is already settled one + * layer down and in the other direction: `storableTools` in `./store` keys the insert by name + * and keeps the first occurrence, deliberately, so a vendor that names one action twice records + * it once under a refresh that stays healthy. Refusing here would turn that refresh into a total + * failure and strand every grant on the app, which is the loss the refusals around this one + * exist to prevent. What this function owes that de-duplication is the trimmed name the map + * below records, so two spellings of one slug collide there rather than surviving as two rows. + */ + + /* + * AN ACTION'S LABELS ARE READ, SO THEY HAVE TO BE READABLE. + * + * {@link effectOf} builds a Set out of this field, and the element check above settles only that + * the action is an object. A `tags` that is not iterable — `{}`, a number, a bag of labels keyed + * by index — throws `{} is not iterable` out of the map below, which sits OUTSIDE the try that + * wraps the vendor's call, so that string is what `refreshTools` writes into the row's + * `lastError` for an administrator to read. + * + * A STRING IS THE HALF THAT DOES NOT THROW, and it is the worse one. `new Set("readOnlyHint")` + * is that word's characters, no hint matches any of them, and a read-only action is recorded as + * a write — a classification nobody can see is wrong, on a row an administrator grants from. + * Defaulting either shape to "write" would make the same silent answer deliberate, so both are + * refused here while a sentence can still name the action. + */ + const oddTags = actions.find( + (action) => action.tags !== undefined && !Array.isArray(action.tags), + ); + if (oddTags) { + throw new Error( + `Composio's action list for ${toolkit} described ${oddTags.slug.trim()}'s tags as something other than a list of labels, and those labels are the only thing that says whether an action reads or writes and whether it destroys anything. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose effects could not be read.`, + ); + } + if (actions.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. @@ -536,28 +572,59 @@ export async function listTools(connection: { * that the SDK will read a local path off this server's disk. A model naming a server-side path * is a worse offer than one naming a bucket key, not a better one. */ - return actions - .filter((action) => !stagesAFile(action.inputParameters)) - .map((action) => { - const { effect, destructive } = effectOf(action.tags); + const offered = actions.filter( + (action) => !stagesAFile(action.inputParameters), + ); + + /* + * THE FILTER MAY SHORTEN A LISTING AND MAY NOT EMPTY ONE. + * + * Dropping an action is a standing decision about an action this deployment cannot serve, taken + * the same way on every refresh, and the answer is still a listing. Dropping the last one is not + * that: what leaves here is `[]`, which means "the vendor was asked and advertises nothing" + * everywhere in this codebase, and `refreshTools` commits it as a healthy refresh — a delete and + * an insert that takes every recorded action with its `effect`, `destructive` and, fatally, its + * `version`, which no later refresh reconstructs where Composio publishes none. That is the same + * tool-and-version wipe the empty answer, the unreadable answer, the slug-less action and the + * full page above all refuse; arriving through this filter does not make it a different event. + * + * A vendor answer that was genuinely empty is left alone, because that one IS the vendor + * advertising nothing and is the sentence `refreshTools` should record. + */ + if (actions.length > 0 && offered.length === 0) { + throw new Error( + `Every one of the ${actions.length} actions Composio listed for ${toolkit} asks for a file upload, which this deployment cannot stage, so there is none it can offer. Recording that would say the app advertises nothing and delete every action, effect and version already held for it, so nothing was refreshed and those are kept.`, + ); + } + + return offered.map((action) => { + const { effect, destructive } = effectOf(action.tags); + /* + * TRIMMED HERE BECAUSE IT IS TRIMMED AT THE OTHER END. {@link callTool} trims the recorded + * version and refuses an empty one, so a whitespace-only string that counted as a version + * was written to `mcp_tools` as a version this deployment believes it holds and was then + * permanently uncallable — and the refusal its caller reads names a refresh, which records + * the same blank again. Recording exactly what `callTool` will send is what closes that + * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + */ + const version = action.version?.trim(); + return { /* - * TRIMMED HERE BECAUSE IT IS TRIMMED AT THE OTHER END. {@link callTool} trims the recorded - * version and refuses an empty one, so a whitespace-only string that counted as a version - * was written to `mcp_tools` as a version this deployment believes it holds and was then - * permanently uncallable — and the refusal its caller reads names a refresh, which records - * the same blank again. Recording exactly what `callTool` will send is what closes that - * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + * TRIMMED FOR THE REASON THE VERSION BESIDE IT IS. The guard that admitted this action + * measured `slug.trim()`, so padding was never what made it a name — but the padded string + * was what got recorded: `mcp_tools.name` is NOT NULL and half the primary key, it is what + * a grant points at, and {@link callTool} sends it back to Composio as the action's slug. + * A row keyed on " GMAIL_SEND " is a different action from the one an administrator + * granted and one Composio has never heard of. */ - const version = action.version?.trim(); - return { - name: action.slug, - description: action.description ?? "", - inputSchema: action.inputParameters ?? {}, - effect, - destructive, - ...(version ? { version } : {}), - }; - }); + name: action.slug.trim(), + description: action.description ?? "", + inputSchema: action.inputParameters ?? {}, + effect, + destructive, + ...(version ? { version } : {}), + }; + }); } /** @@ -577,6 +644,14 @@ export async function listTools(connection: { * inventing one here. That choice is not simply "the thrown message": the thrown message is often the * placeholder above, and passing it on tells the reader nothing. See {@link unexplained}. * + * AND THE PLACEHOLDER IS NOT A SENTENCE WHEREVER IT SITS, which is the half this function was + * missing. Both callers check {@link VENDOR_PLACEHOLDER} against the message that was THROWN and + * both prefer this answer over that check, so "Error executing the tool X" arriving nested inside + * `cause` — which is where the vendor puts it when their own gateway had nothing else to say — + * went out past a guard written for exactly that string. A reader who asked for that tool learns + * from it only that they asked; that is true at whatever depth it was found, so the judgement + * belongs here, in the function whose whole job is deciding what is worth passing on. + * * TRIMMED ON THE WAY OUT AND NOT ONLY IN THE GUARD. The two used to disagree — the guard measured a * trimmed string and the return handed back the padded one — so the decision the function had * already made about the string was thrown away at the last line. What comes out is read by a person @@ -589,7 +664,7 @@ export function vendorSentence(error: unknown): string | null { const inner = (outer as { error?: unknown } | null | undefined)?.error; const message = (inner as { message?: unknown } | null | undefined)?.message; const sentence = typeof message === "string" ? message.trim() : ""; - return sentence === "" ? null : sentence; + return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? null : sentence; } /** @@ -614,19 +689,40 @@ export function unexplained(toolName: string): string { } /** - * Whether a thrown listing failure is the SDK's own schema refusing the vendor's answer. + * Whether a thrown failure is the SDK's own schema refusing the vendor's answer. * * Duck-typed rather than `instanceof ZodError` so this file keeps no dependency on the vendor's * package: `@composio/core` reaches it only through {@link useComposioClient}, and importing `zod` * here would tie the transport to whichever major version the vendor happens to bundle — which is * exactly the coupling that makes a schema mismatch possible in the first place. + * + * WHICH IS WHY THE SHAPE HAS TO BE ASKED FOR RATHER THAN THE NAME `issues`. An array under that + * name is not rare and is mostly not Zod's: a gateway's validation payload carries one, and so + * does any error somebody wrote with a list of complaints in it. Answering true for those replaced + * the one sentence saying what actually went wrong with an instruction to upgrade a package that + * is working perfectly — the vendor's own explanation, hidden by a guess about who threw. + * + * A ZOD ISSUE IS RECOGNISED BY WHAT EVERY VERSION OF ONE CARRIES: a `code` naming the failure and + * a `path` locating it. Both have been in the type since zod 3 and neither belongs to the + * hand-written lists above. An empty array is nobody's schema complaint — a parse that refused + * says why — so it is not one either. */ function isSchemaMismatch(error: unknown): boolean { const shaped = error as | { name?: unknown; issues?: unknown } | null | undefined; - return shaped?.name === "ZodError" || Array.isArray(shaped?.issues); + if (shaped?.name === "ZodError") return true; + + const issues = shaped?.issues; + return ( + Array.isArray(issues) && + issues.length > 0 && + issues.every((issue) => { + const node = schemaNode(issue); + return typeof node?.code === "string" && Array.isArray(node.path); + }) + ); } /** @@ -701,7 +797,20 @@ const NOTHING = new Set(["", "null", "{}"]); * CAN THROW, and is called from outside the vendor's `try` for that reason. See {@link callTool}. */ function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { - const text = JSON.stringify(data ?? null, null, 2); + const text: string | undefined = JSON.stringify(data ?? null, null, 2); + /* + * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a value with no JSON form — a + * function, a symbol — and this field is the vendor's while the type saying it is a record is + * ours. That `undefined` went on to {@link cap}, which measures `.length`, so the engine's + * `undefined is not an object (evaluating 'text.length')` became the second half of a sentence + * this file wrote about its own failure. Thrown here instead, in words, because the caller's + * catch is what turns this into a refusal naming the action. + */ + if (text === undefined) { + throw new Error( + "its data has no JSON form at all, so there is nothing to show", + ); + } if (NOTHING.has(text)) { return { text: "The action returned nothing.", @@ -858,6 +967,32 @@ export async function callTool( rest, ); } catch (error) { + /* + * THE SDK'S OWN PARSE THROWS THROUGH HERE, and its message is not a sentence. + * + * `./composio-adapter` resolves the tool before running it — `getRawComposioToolBySlug`, which + * runs `ToolSchema.parse` — and that happens outside the SDK's own try, so a vendor answer + * their schema rejects arrives as a raw `ZodError` whose `message` is the issue array as JSON. + * Handed on, 400 characters of `{"code":"invalid_type","path":[…]}` went into a model's + * context and into `store.ts`'s audit row, wearing the vendor's words for what is a version + * skew between this deployment and their package. + * + * The listing path has refused that string since it was written, and for the same reason it + * says here: nothing an administrator does to this connection will help, and upgrading the + * package will. There is no `cause` to hang the original on either, because a failure leaves + * this function as a RESULT rather than as a throw — so the sentence is the whole of what the + * reader and the audit row get, which is why it names the one step that changes anything. + * + * WHAT IT DOES NOT CLAIM IS THAT NOTHING RAN. The resolve is the likely thrower and it happens + * first, but the SDK parses the execute response through a schema of its own, so the same + * `ZodError` can arrive from after the action ran. Which of the two it was is exactly what + * this deployment cannot read, and a refusal must not settle it by guessing. + */ + if (isSchemaMismatch(error)) { + return failure( + `Composio was asked about ${toolName} and this deployment's @composio/core would not accept what came back: it did not match the shape that package parses with, so nothing here can say whether the action ran. That is a vendor change rather than a setting on this connection — upgrading the package is the fix.`, + ); + } // The vendor's own sentence when there is one, because a generic message costs a diagnosis. const thrown = error instanceof Error ? error.message.trim() : ""; return failure( diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 7bb9e6028..35295bee2 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -295,6 +295,28 @@ describe("finding the vendor's own sentence", () => { expect(vendorSentence(nested({ text: "a nested sentence" }))).toBeNull(); expect(vendorSentence(nested(["a sentence in a list"]))).toBeNull(); }); + + test("the vendor's placeholder is not a sentence, however deep it arrives", () => { + /* + * THE GUARD COVERED THE BRANCH THAT THROWS AND NOT THE ONE THIS FUNCTION READS, which is the + * half that matters: `listingSentence` and `callTool` both prefer this answer over their own + * fallbacks, so "Error executing the tool X" found nested inside `cause` walked straight past a + * check written for exactly that string. This module's own comment calls it the one sentence + * never worth passing on; a reader who asked for that tool learns from it only that they asked. + */ + expect( + vendorSentence(nested("Error executing the tool GMAIL_FETCH_EMAILS")), + ).toBeNull(); + expect(vendorSentence(nested(" error executing the tool X\n"))).toBeNull(); + + // Matched on its opening and not looked for anywhere in the string, because a real sentence + // that goes on to mention the phrase is still a real sentence. + expect( + vendorSentence( + nested("No connected account found; error executing the tool X."), + ), + ).toBe("No connected account found; error executing the tool X."); + }); }); describe("listing an app's actions", () => { @@ -876,6 +898,185 @@ describe("listing an app's actions", () => { // Recorded as the version `callTool` will actually send, rather than as one it has to repair. expect(padded?.version).toBe("20260903_00"); }); + + test("the name recorded is the slug the guard measured, without its padding", async () => { + /* + * The guard above admits an action by measuring `slug.trim()`, and the version beside it is + * recorded trimmed for a reason this file writes down — so the name went to `mcp_tools` and on + * to Composio with the padding still on it. That name is NOT NULL and half the primary key, it + * is what a grant records, and it is the `slug` `callTool` sends back; a row keyed on + * " GMAIL_FETCH_EMAILS " is a different action from the one anybody granted. + */ + useComposioClient( + recording({ + listActions: async () => [ + { ...GMAIL_READ, slug: " GMAIL_FETCH_EMAILS\n" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + expect(tool?.name).toBe("GMAIL_FETCH_EMAILS"); + }); + + test("a slug listed twice is listed twice, because the collision is settled downstream", async () => { + /* + * WHY THIS IS NOT A FIFTH REFUSAL, written down because it was proposed as one. `mcp_tools` + * holds one row per name, so a vendor naming an action twice IS a collision — and + * `storableTools` in `./store` already resolves it before a transaction is opened, keying the + * insert by name and keeping the first occurrence. "a vendor naming one action twice records it + * once" in `plugin-store.integration.test.ts` pins that end: one row, and `lastError` null. + * Refusing here would replace a healthy refresh with a total failure and strand every grant on + * the app — the loss the refusals around this one exist to prevent, caused by one of them. + * + * WHAT THIS PATH DOES OWE THAT DE-DUPLICATION is the name it de-duplicates by. The map records + * the trimmed slug, so two spellings of one action arrive downstream as one name rather than + * as two rows the database will happily keep apart. + */ + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + ...GMAIL_READ, + slug: " GMAIL_FETCH_EMAILS ", + description: "Fetch emails, listed again.", + version: "20260101_00", + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed.map((tool) => tool.name)).toEqual([ + "GMAIL_FETCH_EMAILS", + "GMAIL_FETCH_EMAILS", + ]); + }); + + test("tags that are not a list of labels break the listing rather than the runtime", async () => { + /* + * `effectOf` builds a Set out of whatever is in this field, and the element guard above only + * settles that the action is an object. A `tags` that is not iterable throws + * `{} is not iterable` out of a map that sits OUTSIDE the try wrapping the vendor's call, so + * that string is what `refreshTools` writes into `lastError` for a person to read. + * + * A STRING IS THE WORSE HALF, because it does not throw at all: `new Set("readOnlyHint")` + * yields its characters, no hint matches, and a read-only action is recorded as a write. That + * is the silent wrong answer a refusal exists to prevent, so both shapes are refused rather + * than repaired. + */ + for (const tags of [{}, "readOnlyHint", 7, { 0: "readOnlyHint" }]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_ODD", description: "Odd labels.", tags }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + expect(outcome).not.toBe("the listing was committed"); + expect(outcome).toContain("GMAIL_ODD"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not iterable|is not a function/i); + } + }); + + test("a vendor complaint that merely carries issues is not answered with an upgrade", async () => { + /* + * `isSchemaMismatch` duck-types on the PRESENCE of an `issues` array, and an array under that + * name is not the vendor's SDK refusing its own answer. A gateway's validation payload carries + * one, and so does any error somebody built with a list of complaints in it. Read as a schema + * mismatch, the one sentence saying what actually went wrong is replaced by an instruction to + * upgrade a package that is working perfectly. + */ + for (const thrown of [ + Object.assign( + new Error("Gmail rejected the query: from: is not a search operator."), + { issues: ["from: is not a search operator."] }, + ), + Object.assign(new Error("Composio rejected the request for gmail."), { + issues: [{ field: "query", reason: "required" }], + }), + ]) { + useComposioClient( + recording({ + listActions: async () => { + throw thrown; + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toContain("rejected"); + expect(message).not.toMatch(/upgrad/i); + } + }); + + test("a listing left empty by the file filter is not committed as an app with no actions", async () => { + /* + * THE FILTER CAN EMPTY A LISTING, and an empty listing is the one thing this function's four + * other refusals exist to prevent. `refreshTools` commits a listing as the complete truth + * about the app — the replace is a delete and an insert — so every recorded action goes, with + * its `effect`, `destructive` and `version`, under a refresh that reported success. The + * versions are the loss no later refresh repairs where Composio publishes none. + * + * An app whose actions all stage files is not an app with no actions, and the sentence has to + * say which of the two this is. + */ + const onlyFiles = [ + { + slug: "GMAIL_SEND_EMAIL", + description: "Send an email.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { attachment: FILE_PROPERTY }, + }, + }, + { + slug: "GMAIL_REPLY_TO_THREAD", + description: "Reply to a thread.", + tags: ["createHint"], + version: "20260903_00", + inputParameters: { + type: "object", + properties: { attachment: FILE_PROPERTY }, + }, + }, + ]; + useComposioClient(recording({ listActions: async () => onlyFiles }).client); + + const outcome = await listTools({ url: "composio://gmail" }).then( + (listed) => `the listing was committed with ${listed.length} actions`, + (error: unknown) => (error as Error).message, + ); + + expect(outcome).not.toContain("committed"); + expect(outcome).toContain("gmail"); + expect(outcome).toMatch(/file/i); + + // AND THE VENDOR'S OWN EMPTY ANSWER IS STILL AN ANSWER. `[]` from a vendor that was asked and + // advertises nothing is what every other transport means by it, and `refreshTools` is right to + // commit it. The refusal above is about a listing this deployment emptied, not one that + // arrived empty. + useComposioClient(recording({ listActions: async () => [] }).client); + expect(await listTools({ url: "composio://gmail" })).toEqual([]); + }); }); describe("calling one action", () => { @@ -1507,4 +1708,116 @@ describe("calling one action", () => { expect(result.text).not.toMatch(/is not an object|undefined is not/i); } }); + + test("a schema mismatch on the way to the call is not handed on as a Zod dump", async () => { + /* + * THE SDK'S PARSE RUNS BEFORE THE SDK'S OWN TRY DOES. `./composio-adapter` resolves the tool + * first — `getRawComposioToolBySlug`, which runs `ToolSchema.parse` — so a vendor answer their + * schema rejects arrives here as a raw `ZodError`, whose `message` is the issue array as JSON. + * The listing path has refused that string since the day it was written; this path passed the + * whole dump to the model and into `store.ts`'s audit row, wearing the vendor's words for a + * failure that is a version skew between this deployment and their package. + */ + const issues = [ + { + code: "invalid_type", + expected: "string", + received: "undefined", + path: ["toolkit", "slug"], + message: "Required", + }, + ]; + useComposioClient( + recording({ + execute: async () => { + throw Object.assign(new Error(JSON.stringify(issues, null, 2)), { + name: "ZodError", + issues, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toContain("invalid_type"); + expect(result.text).not.toContain("received"); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // A vendor change rather than anything an administrator can do to this connection, so the + // sentence has to name the one step that helps. + expect(result.text).toMatch(/upgrad/i); + }); + + test("a placeholder nested in the cause is refused like the thrown one", async () => { + /* + * The same string, reached by the route the guard did not cover. `vendorSentence` is preferred + * over the thrown message, so a placeholder sitting where the useful sentence usually sits was + * handed to the model past a check written to stop it. + */ + useComposioClient( + recording({ + execute: async () => { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + { + cause: { + error: { + error: { + message: "Error executing the tool GMAIL_FETCH_EMAILS", + }, + }, + }, + }, + ); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toMatch(/^error executing the tool/i); + expect(result.text).toMatch(/Plugins page/); + }); + + test("an answer that serializes to nothing at all is refused in this file's own words", async () => { + /* + * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a function, a symbol, or + * anything else with no JSON form — `ComposioResult` is this module's projection and the value + * is the vendor's, so that is a shape this path meets rather than one it forbids. The + * `undefined` then reached the cap, which measures `.length`, and the engine's own + * `undefined is not an object` became the second half of a sentence this file wrote. + */ + useComposioClient( + recording({ + execute: async () => ({ + data: (() => "x") as unknown as Record, + error: null, + successful: true, + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toMatch( + /is not an object|undefined is not|TypeError/i, + ); + }); }); From 04e54dda15c072e404175e6ea01748356506a23c Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:47:54 +0200 Subject: [PATCH 135/215] Refuse a vendor answer this adapter cannot read, instead of reading it anyway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every listing here was mapped straight off the wire on the strength of a type annotation, and an annotation over a wire value is an assertion rather than a check: `transform()` validates with `safeParse`, logs a warning where it fails, and returns the unvalidated object anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`). So each of the four listings had one shape that crashed with the name of a vendor method in the message, and — worse — shapes that did not crash at all. The one that was doing real damage is the connected-account listing. A row with no id reached `connectedAccounts.delete(undefined, ...)`, which the vendor is free to read as it likes, after which `revoke` answered `true`, `store.ts` wrote that into the audit trail as this person's access having been withdrawn, and then deleted the one `composio_connections` row that named which app they had connected. A live grant, a trail entry saying it was ended, and nothing left pointing at either. The auth-config listing had the same delete-with-`undefined` one level up, where what survives is a config and every account on it. An auth config whose `status` was absent or carried a word this deployment's SDK has never heard of was reported to the operator as "disabled". It is still a refusal — nothing mints a link against a config it cannot show is enabled, because consent spent against a disabled one attaches nothing — but it now says which of the two it is. Telling somebody to go and enable a config that may already be enabled leaves them with a page insisting on a fact they can see is false and nothing else to try. Every reader answers null on what it cannot read and every caller turns that null into a sentence naming what Composio sent where. None of them coerces: a `?? ""` or a `String(x)` here does not make the answer safe, it turns a fault that could have been reported into an answer given wrongly, which is the defect class rather than the fix for it. The value itself is never quoted — a listing row carries mailbox addresses and account handles, and these sentences are read off an admin page, written into an app's `lastError` and put in front of a model. A status is the exception and is quoted, being a closed set of vendor enum names and the one fact an operator can search a changelog for. The sweep, being every place this file reads something off a vendor answer: toolkits.get guarded: container, row, slug, name, meta, description, logo, each category name, count authConfigs.list guarded: container, row, id, name; status left `unknown` so `authorize` must say what it does with a word that is neither ENABLED nor DISABLED connectedAccounts.list guarded: container, row, id; checked in `accountsFor` so `isConnected` and `revoke` cannot drift apart on it tools.getRawComposioTools guarded: container, row, slug, description, inputParameters, tags, version — despite `ToolSchema.parse` being a throwing parse, because `inputParameters` goes in front of a model as the vendor's own schema tools.getRawComposioToolBySlug AUDITED, LEFT. `resolved.toolkit?.slug` throws where the answer is not an object. Same class, no red test, and a single-object read rather than one of the listings this change is about. connectedAccounts.link AUDITED, LEFT. `request.redirectUrl` throws where the answer is not an object; the absent url itself is already refused below it. tools.execute Answer crosses the seam untouched; `./composio` reads `successful` and `error`, not this file. authConfigs.create Answer typed `unknown` and read for nothing. both deletes Answers typed `unknown` and read for nothing — deliberately, since `revoke_on_delete` starts a background job with no supported way to poll it. nextCursor, both listings Declared and still unread. Separate finding: one page at the ceiling is treated as the whole answer. Verified against the 16 existing red cases in `composio-adapter.test.ts`, which now pass. Two guards were mutated away to confirm they are load-bearing: the account-id check, replaced with `?? ""`, made `revoke` answer instead of refuse; the connected-accounts container check, replaced with `?? []`, reddened both of its cases. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 507 ++++++++++++++++++++++--- 1 file changed, 464 insertions(+), 43 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 67d721c63..1956ed3cd 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -164,6 +164,351 @@ type VendorAccountStatus = | "INACTIVE" | "REVOKED"; +/* + * WHY EVERY LISTING BELOW IS READ OUT OF `unknown` AND NOT OUT OF THE TYPES ABOVE. + * + * The declarations on {@link ComposioVendor} say what the SDK MEANS to send, and each row type says + * at length why that is not the same as what arrives: `transform()` validates with `safeParse`, + * logs a warning where it fails, and returns the unvalidated object anyway (`@composio/core` + * 0.18.1, `src/utils/transform.ts:26-36`). So the declarations are kept — they are the contract a + * version bump is read against, and they are what makes an unchecked read fail to compile — and the + * value itself is checked here before any field is taken off it. + * + * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT. A `?? ""`, a `String(x)` or + * a cast does not make a malformed answer safe; it converts a fault this deployment could have + * reported into an answer it gives wrongly, and the wrong answers are not small ones — an app with + * no name in an administrator's picker, an action with no slug put in front of a model, and the one + * that was actually happening: a delete sent with `undefined` where an account id belongs, + * answered by Composio however it likes, after which the audit trail records that a person's access + * was withdrawn and nothing had been. Every reader below therefore answers null on anything it + * cannot read, and every caller turns that null into a sentence naming what Composio sent. + */ + +/** + * The remedy every shape refusal here ends with, because it is the same act in every one of them. + * + * None of these is a misconfiguration. The key is right, the request is right and the answer + * arrived; what changed is the shape of it, which is a thing nobody operating this deployment can + * correct from any page it has. Saying so is the difference between an operator reading their own + * settings for an hour and an operator upgrading a package. + */ +const VENDOR_SHAPE_REMEDY = + "That is a change in what Composio answers rather than a setting an operator can correct, so upgrading this deployment's @composio/core is what fixes it."; + +/** + * WHAT COMPOSIO PUT SOMEWHERE, NAMED IN A SENTENCE A READER CAN ACT ON. + * + * Every refusal below says what arrived where something else belonged, because "Composio's answer + * was not a shape this deployment reads" sends whoever is holding the page looking through a vendor + * dashboard with nothing to look for. + * + * THE VALUE IS NEVER QUOTED, AND THAT IS THE POINT OF THE FUNCTION RATHER THAN AN INTERPOLATION. A + * listing row carries a person's mailbox address, an account handle and whatever else the vendor + * chose to put on it, and a refusal from here is read off an admin page, written into an app's + * `lastError` and put in front of a model. The shape is the part that is safe to say and is also + * the only part that helps: a reader who knows a list arrived where an object belongs knows which + * vendor change they are looking at. + */ +function sent(value: unknown): string { + if (value === undefined) return "nothing"; + if (value === null) return "null"; + if (Array.isArray(value)) return "a list"; + if (value === "") return "an empty string"; + if (typeof value === "object") return "an object"; + return `a ${typeof value}`; +} + +/** + * A vendor status named as itself, because an enum value is the one wire value worth quoting. + * + * {@link sent} withholds what it is given for a reason that does not reach here: an auth config's + * status is one of a closed set of vendor enum names, carries nobody's data, and IS the finding — + * "Composio called it PENDING" is something an operator can search their dashboard and the vendor's + * changelog for, where "Composio sent a string" is something they can only shrug at. + */ +function named(value: unknown): string { + const text = typeof value === "string" ? value.trim() : ""; + return text === "" ? sent(value) : `"${text}"`; +} + +/** + * One listing row as its fields, and a type predicate so that nothing below needs a cast. + * + * Arrays are excluded deliberately. `typeof [] === "object"` and every field read off one answers + * `undefined`, so a listing of lists would otherwise arrive here as a listing of rows that are + * missing every field — which is a worse sentence for the same fault. + */ +function hasFields(row: unknown): row is Record { + return typeof row === "object" && row !== null && !Array.isArray(row); +} + +/** + * The rows inside an envelope the vendor answers with, or null where it answered something else. + * + * THE TWO CONTAINER SHAPES ARE NOT INTERCHANGEABLE AND COMPOSIO USES BOTH. Toolkits and tools come + * back as bare arrays; auth configs and connected accounts come back as `{ items, nextCursor }`. So + * one where the other belongs is a vendor change rather than a quirk to absorb — and it is the + * change that hides best, because `[].items` is `undefined` rather than an error and a bare list + * where an envelope belongs therefore used to read as an envelope with nothing in it: no configs to + * connect against, no accounts to withdraw, and a confident answer either way. + */ +function itemsOf(answer: unknown): unknown[] | null { + if (!hasFields(answer)) return null; + const items = answer.items; + return Array.isArray(items) ? items : null; +} + +/** + * One field as the non-empty string it has to be, or null where the vendor sent anything else. + * + * EMPTY COUNTS AS ABSENT because every caller of this reads an identifier — a slug, an id, a name + * this file matches a suffix against — and an empty identifier is unusable in exactly the way a + * missing one is, while being the one that reads as present at every glance. + */ +function textOf(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +/** Whether a field is the list of strings it is declared to be, all the way through. */ +function isTextList(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === "string") + ); +} + +/** + * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. + * + * A ROW THIS FILE CANNOT READ STOPS THE WHOLE CATALOGUE, for the reason the full-page guard in + * {@link buildComposioClient} gives at length: the directory is held for ten minutes and both the + * picker and the enable route read the held copy, so a row quietly dropped is an app missing from a + * search and an app whose Add button reports that Composio does not publish it. One refusal an + * operator can act on is worth more than several hundred rows, one of which is a guess. + * + * THE ABSENCES THAT ARE REAL ANSWERS ARE STILL ANSWERS. Composio genuinely publishes toolkits with + * no description, no logo, no category and no count, and each of those is a fact about the app + * rather than a fault in the answer — so an absent one becomes the value that reads honestly on a + * screen, exactly as it did before. What is refused is the other thing: a field that is PRESENT and + * is not what it is declared to be. A count that arrived as the string "63" is not a count, and + * `Number(x)` over it would turn a vendor change into a plausible figure nobody would question. + */ +function appOf(row: unknown, position: number): BrokerApp { + const at = `row ${position + 1} of Composio's app catalogue`; + if (!hasFields(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} as ${at}, where an app belongs, so the directory was not shown. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs. The slug is the only name this deployment has for an app — it is what enabling one records and what every later call names — so the directory was not shown, rather than shown with an app nothing could be done with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const name = textOf(row.name); + if (name === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and ${slug} is a slug rather than a title, so there is nothing to show an administrator choosing between apps. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const meta = row.meta; + if (!hasFields(meta)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta)} where ${slug}'s meta belongs, which is where its description, logo, categories and action count all live. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const description = meta.description ?? ""; + if (typeof description !== "string") { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.description)} where ${slug}'s description belongs. An app that publishes none is ordinary and reads as a gap on the page; an app whose description is not text is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const logo = meta.logo ?? null; + if (typeof logo !== "string" && logo !== null) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.logo)} where ${slug}'s logo belongs, and this deployment puts that value in an image address. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const published = meta.categories ?? []; + if (!Array.isArray(published)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const categories = published.map((entry: unknown, index: number) => { + const label = hasFields(entry) ? textOf(entry.name) : null; + if (label === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(hasFields(entry) ? entry.name : entry)} where the name of ${slug}'s category ${index + 1} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return label; + }); + + const actionCount = meta.toolsCount ?? 0; + if (typeof actionCount !== "number" || !Number.isFinite(actionCount)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.toolsCount)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { slug, name, description, logo, categories, actionCount }; +} + +/** + * One auth config with the two fields every decision here turns on, checked. + * + * `status` IS CARRIED ACROSS UNCHECKED ON PURPOSE, which is the one field of the three this reader + * does not settle. {@link ComposioBroker.authorize} is its only reader and it has three answers to + * give rather than two — enabled, disabled, and a word the vendor has invented since — so a check + * here could only collapse the third into one of the first two, which is the exact defect being + * closed. It is left as `unknown` so the reader has to say what it does with it. + */ +type CheckedAuthConfig = { + id: string; + name: string; + status: unknown; +}; + +/** + * One auth-config row checked into something worth deciding on, or a refusal saying why not. + * + * EVERY ROW IS CHECKED AND NOT ONLY THE ONES THAT TURN OUT TO BE OURS, because which ones are ours + * is precisely what the name decides. A row whose name cannot be read cannot be sorted into "made + * here" or "somebody's dashboard work", and both of the guesses are damaging in opposite + * directions: read as somebody else's, {@link ComposioBroker.ensureAuthConfig} creates a second + * config beside it and splits one app's connections in two; read as ours, + * {@link ComposioBroker.deleteAuthConfig} deletes an object nobody here chose and every account + * anybody had connected against it. + */ +function configOf( + row: unknown, + position: number, + toolkit: string, +): CheckedAuthConfig { + const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; + if (!hasFields(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} as ${at}, where a config belongs. This listing is what decides whether a config is created, connected against or deleted, so a row it cannot read is not a row it may pass over. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const id = textOf(row.id); + if (id === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a deletion names. Nothing was sent, because a delete without one asks Composio to remove whatever it cares to while this deployment records that the app was withdrawn. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const name = textOf(row.name); + if (name === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and the name is the only thing that says whether this deployment made a config or an operator built it by hand in Composio's dashboard. Neither guess is safe: one splits this app's connections across two configs, and the other deletes a config nobody here chose along with every account connected against it. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { id, name, status: row.status }; +} + +/** + * One connected-account row checked down to the only field anything here reads. + * + * THIS IS THE MOST LOAD-BEARING OF THESE READERS AND THE ONE WHOSE ABSENCE WAS DOING REAL DAMAGE. + * {@link ComposioBroker.revoke} deletes by id and then answers `true`, and `store.ts` writes that + * answer into the audit trail as this person's access having been withdrawn before deleting the one + * row in this deployment that names which app they had connected. An id-less account reaching the + * delete is a request to withdraw `undefined` — which the vendor is free to read as anything at all + * — followed by a `true`, a trail entry, and a live grant with nothing left pointing at it. + */ +function accountIdOf(row: unknown, position: number, toolkit: string): string { + const at = `row ${position + 1} of Composio's ${toolkit} accounts for this person`; + if (!hasFields(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} as ${at}, where an account belongs. Whether this person is connected and what there is to withdraw are both read off this listing, so a row it cannot read is answered with neither. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const id = textOf(row.id); + if (id === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a withdrawal names. Nothing was sent, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return id; +} + +/** + * One tool row checked into the action this deployment holds, or a refusal saying why not. + * + * CHECKED DESPITE {@link VendorTool}'s ARGUMENT THAT THIS ONE ROW IS VALIDATED. That argument is + * sound about the SDK — `transformToolCases` ends in a throwing `ToolSchema.parse` rather than the + * warn-only `transform()` everything else here goes through — and it is an argument about the + * vendor's code rather than about this deployment's. A schema is one version away from being + * relaxed, and the difference in cost between the two sides is not close: the check is a few + * comparisons per action, and what it stands between is `inputParameters` going in front of a model + * as the vendor's own schema when it is not a schema at all. + * + * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not + * answered to a route. `./composio` records them in an app's `lastError` for an administrator to + * read on its Plugins page, and `listingSentence` passes an authored message through untouched. + */ +function actionOf( + row: unknown, + position: number, + toolkit: string, +): ComposioAction { + const at = `row ${position + 1} of Composio's action list for ${toolkit}`; + if (!hasFields(row)) { + throw new Error( + `Composio sent ${sent(row)} as ${at}, where an action belongs, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new Error( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs, and the slug is what calling the action names, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const description = row.description; + if (description !== undefined && typeof description !== "string") { + throw new Error( + `Composio sent ${sent(description)} where ${slug}'s description belongs, and that description is what a model reads to decide whether to call it. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const inputParameters = row.inputParameters; + if (inputParameters !== undefined && !hasFields(inputParameters)) { + throw new Error( + `Composio sent ${sent(inputParameters)} where ${slug}'s input schema belongs. That value is put in front of a model as Composio's own JSON Schema for the action, so a thing that is not an object cannot be shown as one. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const tags = row.tags; + if (tags !== undefined && !isTextList(tags)) { + throw new Error( + `Composio sent ${sent(tags)} where ${slug}'s tags belong, and this deployment reads those to decide whether an action only reads or also writes. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const version = row.version; + if (version !== undefined && typeof version !== "string") { + throw new Error( + `Composio sent ${sent(version)} where ${slug}'s version belongs, and the version travels with every call made to the action. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { slug, description, inputParameters, tags, version }; +} + /** * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. * @@ -390,8 +735,15 @@ const CONFIG_SUFFIX = "(OpenBot)"; * part this file writes. Trailing whitespace is tolerated for the same reason it is tolerated * anywhere a human-edited string is compared: a name that picked up a space in a dashboard is the * same config. + * + * IT TAKES A {@link CheckedAuthConfig} AND NOT A {@link VendorAuthConfig}, which is what makes this + * one line honest. A predicate cannot refuse — it answers true or false — so reading a name the + * vendor may not have sent here could only ever have meant silently answering `false`, and `false` + * from this function means "somebody else's config": untouched by a removal, and satisfying the + * check that stops a second one being created. {@link configOf} is where the absence becomes a + * sentence instead, upstream of every caller. */ -function madeHere(config: VendorAuthConfig): boolean { +function madeHere(config: CheckedAuthConfig): boolean { return config.name.trimEnd().endsWith(CONFIG_SUFFIX); } @@ -513,15 +865,28 @@ export function buildComposioClient( userId: string, toolkit: string, statuses: VendorAccountStatus[], - ) => { - const answer = await vendor.connectedAccounts.list({ + ): Promise => { + const answered: unknown = await vendor.connectedAccounts.list({ userIds: [userId], toolkitSlugs: [toolkit], statuses, accountType: "ALL", limit: LISTING_LIMIT, }); - return answer.items; + /* + * CHECKED HERE RATHER THAN AT EITHER CALLER, so that the two questions cannot drift on this. + * `isConnected` answers a boolean and `revoke` deletes by id, and an unreadable listing is the + * wrong answer to both of them for the same reason — `false` claims somebody has no account for + * an app when nobody looked, and a delete without an id claims a withdrawal that never went + * out. The ids are all either caller takes, so the listing hands back ids and nothing else. + */ + const items = itemsOf(answered); + if (items === null) { + throw new BrokerRefusalError( + `Composio answered this person's ${toolkit} accounts with ${sent(answered)} where a list of them belongs, so neither whether they are connected nor what there is to withdraw could be read. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return items.map((row, position) => accountIdOf(row, position, toolkit)); }; /** @@ -538,13 +903,29 @@ export function buildComposioClient( * being fixed here is two calls resolving different rows; a total order on the id makes the * choice this file makes a stable one, whoever asks and whenever. */ - const configsMadeHere = async (toolkit: string) => { - const answer = await vendor.authConfigs.list({ + const configsMadeHere = async ( + toolkit: string, + ): Promise => { + const answered: unknown = await vendor.authConfigs.list({ toolkit, limit: LISTING_LIMIT, showDisabled: true, }); - return answer.items + /* + * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The + * filter's question IS the name, so a row checked only once it had been kept would be a row + * sorted by a field nobody had read — see {@link madeHere} for what each of the two guesses + * costs. Every row therefore passes {@link configOf} first, including the ones that turn out to + * belong to an operator's own dashboard work. + */ + const items = itemsOf(answered); + if (items === null) { + throw new BrokerRefusalError( + `Composio answered this deployment's authorization configs for ${toolkit} with ${sent(answered)} where a list of them belongs, so whether one exists is not something this deployment can tell. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return items + .map((row, position) => configOf(row, position, toolkit)) .filter(madeHere) .sort((one, other) => one.id.localeCompare(other.id)); }; @@ -607,11 +988,25 @@ export function buildComposioClient( * catalogue is this deployment's own job, over the rows below. */ const fetchDirectory = async (): Promise => { - const toolkits = await vendor.toolkits.get({ + const answered: unknown = await vendor.toolkits.get({ limit: LISTING_LIMIT, sortBy: "usage", }); + /* + * THE CONTAINER IS SETTLED BEFORE ITS LENGTH IS MEASURED, which is why this sits above the + * truncation guard rather than beside the row checks below. `null.length` and `{}.length` are a + * crash and an `undefined` respectively, and the second of those is the worse one: it compares + * false against the ceiling, sails past the guard, and reaches a `.map` that throws a sentence + * naming a vendor method instead of naming the fault. + */ + const toolkits = Array.isArray(answered) ? answered : null; + if (toolkits === null) { + throw new BrokerRefusalError( + `Composio answered the app catalogue with ${sent(answered)} where a list of apps belongs, so there was no directory to show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (toolkits.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE CATALOGUE, and this deployment cannot find out which it is. @@ -638,30 +1033,24 @@ export function buildComposioClient( ); } - return toolkits.map((toolkit) => ({ - slug: toolkit.slug, - name: toolkit.name, - /* - * Each absence becomes the value that reads honestly on an administrator's screen. An empty - * description shows as no description; a null logo is the field's documented way of saying - * the vendor published none, which renders as a gap rather than as a broken image. - * - * The categories are the DISPLAY names rather than the slugs, because this list is read by a - * person choosing an app and "Productivity" is what they are choosing by. - * - * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a - * number and the shape offers no way to say "not published", so a toolkit that publishes no - * count reads as an app with no actions. It is the conservative direction — it understates - * the size of a change rather than overstating it — and Composio publishes a count for every - * toolkit measured, so this is a guard against the vendor rather than a routine case. - */ - description: toolkit.meta.description ?? "", - logo: toolkit.meta.logo ?? null, - categories: (toolkit.meta.categories ?? []).map( - (category) => category.name, - ), - actionCount: toolkit.meta.toolsCount ?? 0, - })); + /* + * Each absence becomes the value that reads honestly on an administrator's screen, and each + * PRESENT field that is not what it is declared to be becomes a refusal — see {@link appOf}, + * where both halves of that and every sentence live. An empty description shows as no + * description; a null logo is the field's documented way of saying the vendor published none, + * which renders as a gap rather than as a broken image. + * + * The categories are the DISPLAY names rather than the slugs, because this list is read by a + * person choosing an app and "Productivity" is what they are choosing by. + * + * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a + * number and the shape offers no way to say "not published", so a toolkit that publishes no + * count reads as an app with no actions. It is the conservative direction — it understates + * the size of a change rather than overstating it — and Composio publishes a count for every + * toolkit measured, so this is a guard against the vendor rather than a routine case. A count + * that arrives as something other than a number is the different case and is refused. + */ + return toolkits.map(appOf); }; const actions: ComposioActions = { @@ -671,22 +1060,25 @@ export function buildComposioClient( * `page` required precisely so that no layer could quietly supply one. See the module * comment on what an omitted limit does beyond truncating. */ - const tools = await vendor.tools.getRawComposioTools({ + const answered: unknown = await vendor.tools.getRawComposioTools({ toolkits: [toolkit], limit: page.limit, }); + + const tools = Array.isArray(answered) ? answered : null; + if (tools === null) { + throw new Error( + `Composio answered ${toolkit}'s action list with ${sent(answered)} where a list of actions belongs, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* * Mapped field by field rather than spread, so what crosses the seam is the four things * `./composio` documents and not whatever else the vendor's tool object happens to carry. - * `inputParameters` in particular goes straight in front of a model. + * `inputParameters` in particular goes straight in front of a model — which is also why + * {@link actionOf} checks it rather than passing it on as whatever arrived. */ - return tools.map((tool) => ({ - slug: tool.slug, - description: tool.description, - inputParameters: tool.inputParameters, - tags: tool.tags, - version: tool.version, - })); + return tools.map((row, position) => actionOf(row, position, toolkit)); }, async execute(call, args): Promise { @@ -917,6 +1309,35 @@ export function buildComposioClient( */ const config = ours.find((held) => held.status === "ENABLED"); if (!config) { + /* + * "DISABLED" IS A CLAIM, AND IT IS ONLY THIS DEPLOYMENT'S TO MAKE WHEN COMPOSIO MADE IT. + * + * The test above is `=== "ENABLED"`, so everything that is not that word fell through here + * — and that is three different states wearing one sentence. A config Composio calls + * DISABLED is genuinely disabled and the remedy below is genuinely the remedy. A config + * with no status at all, or one carrying a word this deployment's `@composio/core` has + * never heard of, is a config whose state is UNKNOWN, and telling an operator it is + * disabled sends them to a dashboard to enable something that may already be enabled — and + * where it is, they are left with a page insisting on a fact they can see is false and + * nothing else to try. + * + * BOTH ARE STILL A REFUSAL, WHICH IS THE PART THAT DOES NOT CHANGE. Nothing here mints a + * link against a config it cannot show is enabled: consent spent against a config that + * turns out to be disabled attaches nothing and cannot be spent again without asking the + * person to go round the loop a second time. What the status decides is which sentence a + * person reads, not whether they are sent. + * + * The status is quoted where there is one, for the reason {@link named} gives: it is a + * closed set of vendor enum names, it carries nobody's data, and it is the one fact an + * operator can search a dashboard and a changelog for. + */ + const unreadable = ours.filter((held) => held.status !== "DISABLED"); + const first = unreadable[0]; + if (first !== undefined) { + throw new BrokerRefusalError( + `Composio describes ${unreadable.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${named(first.status)}, which is neither ENABLED nor DISABLED, so whether a connection begun against one could complete is not something this deployment can tell. No link was made, because consent spent against a config that turns out to be disabled attaches nothing. ${VENDOR_SHAPE_REMEDY}`, + ); + } throw new BrokerRefusalError( `Every authorization config this deployment holds at Composio for ${toolkit} is disabled, so a connection begun against one could not complete. An administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, ); @@ -984,8 +1405,8 @@ export function buildComposioClient( * than the one behind `isConnected`. */ const accounts = await accountsFor(userId, toolkit, REVOCABLE); - const refused = await askForEach(accounts, (account) => - vendor.connectedAccounts.delete(account.id, { revoke_on_delete: true }), + const refused = await askForEach(accounts, (id) => + vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), ); if (refused.length > 0) { From dcc149884f8a2f6f576db757fe5f12d5fb3b4c82 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:49:43 +0200 Subject: [PATCH 136/215] Typecheck the key-diagnosis script, and stop it printing the one sentence it refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script ships inside the image and is the command to run when Composio misbehaves, and nothing compiled it: `server/tsconfig.json` includes `scripts`, which is `server/scripts`, and the repository-root `scripts/` is in no workspace at all — `bun run typecheck` is `bun run --filter '*' typecheck`, which never matches the root manifest. The one file that ships is named there now, the way `app/tsconfig.json` names the two `shared` files it reaches for, and for the reason the Dockerfile already gives for copying that file and no other. The directory is not taken wholesale: two of the laptop's scripts have no import or export, so they share one scope and redeclare each other's variables. The catalogue's full-page branch could never run. `broker.listApps` refuses a listing at its ceiling and throws, so no `apps` array reaching that line was ever as long as the limit, and the truncation the branch promised to report was the one thing it could not see. It is gone; the refusal's own sentence already says it, and stopping is the stronger answer than continuing under a warning. The action listing keeps its check, which is a different case: that call is a pass-through with no ceiling refusal in it. The thrown-failure fallback was `error.message`, which on the execute path is "Error executing the tool GMAIL_GET_PROFILE" — the placeholder the docblock directly above it calls the sentence never worth passing on. `VENDOR_PLACEHOLDER` and `unexplained` are imported and used at that fork, and at the resolved-failure fork beside it, so the script says what the transport says rather than a worse version of it. Findings and failures both went to stdout except the usage and the missing key, with no rule anywhere. The rule now is that a line explaining a non-zero exit goes to stderr and every other line goes to stdout, so a report can be redirected while every reason the command failed stays on the terminal. `stop` writes and exits together, so the stream cannot drift from the code. And the documentation said the opposite of the product about the state it most wants to describe. With no key a row really is drawn, under More apps on the admin Plugins page, naming the variable and going nowhere; `docs/configuration.md` and `.env.example` claimed no Composio surface appears anywhere. The setup table credited a screen with setting a key no screen sets, and sent grants to the directory rather than to the app's page. All three now say what `app/src/routes/_authed/admin/plugins/index.tsx` draws, and the script and its stream rule are written down where an operator will meet them. --- .env.example | 12 ++- docs/configuration.md | 2 +- docs/plugins/composio.md | 61 +++++++++++--- scripts/composio-smoke.ts | 172 +++++++++++++++++++++++++++----------- server/tsconfig.json | 15 +++- 5 files changed, 198 insertions(+), 64 deletions(-) diff --git a/.env.example b/.env.example index ca78cafed..ad56fe623 100644 --- a/.env.example +++ b/.env.example @@ -342,8 +342,16 @@ WORKER_SHARED_SECRET= # Composio, the broker that holds people's accounts for a few hundred apps, so a Bot can act in Gmail # or Slack or Notion without this deployment registering an OAuth client with each of them. # -# Optional. Without this key no Composio surface appears anywhere in the product: nothing to connect, -# nothing to grant, and no Composio tool for a Bot to call. +# Optional. Without this key there is nothing to connect, nothing to grant, and no Composio tool for +# a Bot to call. What does remain is one row that goes nowhere, under "More apps" on the admin +# Plugins page, saying that adding a Composio key enables a catalogue of tools and naming this +# variable — the feature is named rather than hidden, so an administrator who has heard of Composio +# can find out what it wants. There is no directory to browse, no picker, and no brokered app on +# anybody's connected-accounts page. +# +# This variable is the whole of the setting. There is no screen anywhere in the product that sets it: +# it is read from this environment at startup, and an administrator with every permission there is +# cannot turn Composio on from a page. # # One key for the whole deployment, not one per person. Composio keeps people apart by a user id sent # with every call, so one account's connections are never reachable from another's. diff --git a/docs/configuration.md b/docs/configuration.md index 90fd4a871..bd14a428d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,7 +59,7 @@ at `agent-langgraph` on a laptop. | `AUDIT_RETENTION_DAYS` | unset | Whole number of days to keep audit rows; older ones are removed. Unset keeps the trail forever. | | `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. | | `OPENBOT_GENERATIVE_UI` | unset (capability off) | `true` or `1` lets a Bot answer with an interface it wrote itself. | -| `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, no Composio surface appears anywhere in the product. | +| `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, there is nothing to connect, nothing to grant and no Composio tool for a Bot to call; what remains is one row that goes nowhere, under **More apps** on the admin Plugins page, naming this variable. See [Composio](plugins/composio.md). | **`OPENBOT_GENERATIVE_UI`** turns on generated interfaces. Set it, and a Bot may answer by writing the markup, styles and script for an interface and streaming it into the transcript, where it renders diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index eda5659fc..7d68cff0f 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -8,12 +8,18 @@ consents to Composio, and Composio holds what comes back. A Bot with a brokered reaches the app **as the person asking**, the same as every other per-person connector here, so two people asking the same question get the answers their own accounts can see. -Setting it up takes two people, and neither can do the other's half: +Setting it up takes three hands, and none of them can do another's: -| Who | Does | Where | -| ---------------- | ------------------------------------------------- | ------------------------------ | -| An administrator | Sets one key, enables an app, grants its actions | `/admin/plugins/composio` | -| Each person | Connects their own account to that app | `/settings/connected-accounts` | +| Who | Does | Where | +| ---------------- | -------------------------------------- | ---------------------------------------------------------------- | +| Whoever deploys | Sets `COMPOSIO_API_KEY` | The deployment's environment. There is no screen for it | +| An administrator | Enables an app | `/admin/plugins/composio` | +| An administrator | Grants its actions to a Bot | `/admin/plugins/composio-`, then that page's per-Bot screen | +| Each person | Connects their own account to that app | `/settings/connected-accounts` | + +The key is the row that is easiest to misread, so it is stated twice: it is an environment variable +and nothing else. No administrator, however permissioned, can turn Composio on from a page, and +`/admin/plugins/composio` is where a key that is already set gets used rather than where one is set. There is deliberately no endpoint for an administrator to connect an account on somebody's behalf. @@ -25,19 +31,52 @@ Composio keeps people apart by a user id sent with every call, so one person's c reachable from another's. Nothing validates the key at startup. There is no shape to check it against and no call worth making -at boot to find out, so the first real request is what says whether it works. +at boot to find out, so the first real request is what says whether it works. `composio:smoke` below +is how an operator asks that question deliberately rather than by watching somebody else fail. Unset is a supported, fully described state rather than a degraded one: it is what every deployment -is today. With no key, the only Composio surface anywhere in the product is one line where the app -directory would be, saying that adding a Composio key enables a catalogue of tools and naming -`COMPOSIO_API_KEY` as the setting to set. It names the setting rather than hiding the feature, so -somebody who has heard of Composio can find out what it wants. There is no directory to browse, no -picker, and no brokered app on anybody's connected-accounts page. +is today. What is on screen with no key is exactly one row, on `/admin/plugins` under the **More +apps** heading, titled *Composio* and reading *Add your Composio key to enable a catalogue of tools. +Set COMPOSIO_API_KEY on this deployment.* It has no chevron and it is not a link, because there is +nowhere to go until the key is set. It names the setting rather than hiding the feature, so an +administrator who has heard of Composio can find out what it wants — and because the heading above +it stays too, the handful of reviewed connectors does not read as the whole story. + +That row is the whole of it. There is no directory to browse, no picker, no brokered app on +anybody's connected-accounts page, and no Composio tool for a Bot to call. An app enabled while a key was set and then left without one keeps its row and its grants. Its page says the key is missing, and its calls refuse with a sentence saying the same — distinguished, as everywhere else here, from "the app advertises nothing". +### Asking what this key can see + +``` +COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] +``` + +`scripts/composio-smoke.ts` is the one command to run when Composio misbehaves and nothing on screen +says why. It ships inside the container image, so it is run where the key is — in the deployment, +not on a laptop — and it answers the question no page can: whether *this* key opens *this* project. A +key with no project behind it, a project with no authorization config for the app, and a person who +never finished the consent page all leave a product that looks configured and answers nothing, and +this is what tells the three apart. + +It is all reads. It mints no connect link and starts no session, because a link is a bearer +capability and a diagnostic that printed one would leave somebody's mailbox in a terminal scrollback. +The key is never printed either: every line goes out through a redactor, including the vendor's own +sentences, which are the only lines carrying text nobody here wrote. `--user` takes the id this +deployment sends Composio as the person a call is for — the same id `composio_connections` records. +`--call` additionally runs one read-only action, `GMAIL_GET_PROFILE`, and only after checking +Composio's own behaviour label at call time rather than trusting the name. + +**Which stream a line goes to is decided by the exit code it explains.** A line that explains a +non-zero exit is written to stderr; every other line is written to stdout. So `… > report.txt` keeps +a report of what the key can see while every reason the command failed is still on the terminal +beside it — and an action that ran and failed puts its outcome and its log id on stderr, because +those two lines are the whole explanation of the `1` it exits with. The exit codes are `0` for a run +that finished, `1` for a run that stopped, and `2` for a missing `--user`. + ## What an administrator does ### 1. Find the app, and read its action count diff --git a/scripts/composio-smoke.ts b/scripts/composio-smoke.ts index cb4930754..94898378c 100644 --- a/scripts/composio-smoke.ts +++ b/scripts/composio-smoke.ts @@ -18,17 +18,32 @@ * of `server/src/plugins/composio-adapter.ts` — and nothing here reaches past it. * * THE KEY IS NEVER PRINTED. It is read once, handed to {@link createComposioClient}, and after that - * every line this script writes goes through {@link say}, which redacts it. That is belt and braces - * on purpose: the adapter promises not to quote the key, but a vendor exception is a foreign object - * and "this SDK does not put the key in an error" is not a promise this file is in a position to - * make on the SDK's behalf. Which is why no vendor call below is awaited bare — an unhandled - * rejection is printed by the runtime rather than by this file, and that is the one way out past - * {@link say}. {@link ask} is what closes it. + * every line this script writes goes through {@link redact}, which takes it back out. That is belt + * and braces on purpose: the adapter promises not to quote the key, but a vendor exception is a + * foreign object and "this SDK does not put the key in an error" is not a promise this file is in a + * position to make on the SDK's behalf. Which is why no vendor call below is awaited bare — an + * unhandled rejection is printed by the runtime rather than by this file, and that is the one way + * out past the redactor. {@link ask} is what closes it. + * + * WHICH STREAM A LINE GOES TO IS DECIDED BY THE EXIT CODE IT EXPLAINS, and that is the whole rule: + * + * A line that explains a non-zero exit is written to STDERR. Every other line is written to + * STDOUT. + * + * So `smoke > report.txt` keeps a report of what this key can see, and every reason the command + * failed is still on the terminal beside it. {@link say} is stdout and {@link stop} is stderr, and + * because {@link stop} exits, no line can be on the wrong one by accident. The rule decides the last + * pair too: an action that ran and failed puts its own outcome and its log id on stderr, because + * those two lines are the whole explanation of the 1 this command exits with. The usage and the + * missing-key refusal are the same rule reached before there is a key to redact, which is why they + * are the only two that write to the stream directly. */ import { type ComposioResult, effectOf, LISTING_LIMIT, + unexplained, + VENDOR_PLACEHOLDER, vendorSentence, } from "../server/src/plugins/composio"; import { createComposioClient } from "../server/src/plugins/composio-adapter"; @@ -66,6 +81,12 @@ const given = userFlag === -1 ? undefined : args[userFlag + 1]; const user = given?.startsWith("--") ? undefined : given; const call = args.includes("--call"); +/* + * The two refusals below are the file's stream rule reached before there is a key to redact, which + * is the only reason they write to stderr themselves instead of through {@link stop}. Both explain + * a non-zero exit, so both belong there; neither can be carrying a credential, because one is a + * constant and the other is only reached when the variable is empty. + */ if (!user) { console.error( "Usage: COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call]\n\n" + @@ -84,7 +105,7 @@ if (!configured) { process.exit(1); } /* - * Rebound so that {@link say}, which is a closure and therefore outside the narrowing above, holds + * Rebound so that {@link redact}, which is a closure and therefore outside the narrowing above, holds * a `string` by declaration rather than by a cast. A cast would be the wrong tool twice over: it * asserts what the refusal above already proved, and this is the one variable in the file where * silencing the type checker is least welcome. @@ -99,17 +120,39 @@ const key: string = configured; * mismatch. Applied to the vendor's words as well as to this file's own: the only lines that carry * text nobody here wrote are the failure lines, which are exactly the ones worth guarding. */ +function redact(line: string): string { + return line.split(key).join(""); +} + +/** A finding: something this key can see. Stdout, per the rule at the top of this file. */ function say(line: string): void { - console.info(line.split(key).join("")); + console.info(redact(line)); } /** - * What a thrown failure is allowed to contribute to the output. + * The last line of a run that is ending unhappily, on stderr, with the code it is ending with. + * + * ONE FUNCTION SO THE RULE CANNOT DRIFT. The stream and the exit used to be chosen separately at + * every place a run can stop, and they disagreed: the usage and the missing key wrote to stderr and + * every other stopping point wrote to stdout, with nothing written down anywhere saying which the + * next one should pick. Tying the two together makes the rule at the top of this file true by + * construction rather than by everybody remembering it. + * + * `never` so the type checker knows the run is over here, which is what lets the callers below stop + * without a redundant `return` that would read as though the line were only advisory. + */ +function stop(line: string, code: number): never { + console.error(redact(line)); + process.exit(code); +} + +/** + * One failure, as the line this script prints about it. * * The MESSAGE, never the object. A caught value from an SDK carries a request, a config and * whatever else the vendor attached to it, and `console.error(error)` prints all of it — which is * the path by which a key ends up in a terminal and in whatever captured it. So the shape is - * discarded here and the one human sentence is kept, and even that goes out through {@link say}. + * discarded here and the one human sentence is kept, and even that goes out through {@link redact}. * * WHICH MESSAGE, THOUGH, IS NOT THE OUTER ONE. `error.message` on a Composio throw is "Error * executing the tool GMAIL_GET_PROFILE" — the placeholder `./composio` documents as the sentence @@ -120,14 +163,24 @@ function say(line: string): void { * takes that sentence and nothing else, and it is imported rather than rewritten here so that a * vendor changing the nesting breaks one place. * - * The thrown message stays as the fallback, because a failure that is not a Composio throw at all — - * DNS, a proxy, a TLS refusal — carries its whole diagnosis there. + * AND THE PLACEHOLDER IS REFUSED HERE TOO, which is the half this file was missing. The fallback + * used to be `error.message` unconditionally — so on the one path where the placeholder is what + * `error.message` holds, a docblock saying the sentence is never worth passing on sat directly + * above the code that passed it on. {@link VENDOR_PLACEHOLDER} and {@link unexplained} are the same + * two the transport uses at the same fork, imported rather than re-spelled so this file cannot + * drift from `callTool`'s wording about a failure Composio declined to explain. + * + * The thrown message is still the fallback where it says anything at all, because a failure that is + * not a Composio throw — DNS, a proxy, a TLS refusal — carries its whole diagnosis there. */ -function sentence(error: unknown): string { - return ( - vendorSentence(error) ?? - (error instanceof Error ? error.message : String(error)) - ); +function failed(subject: string, error: unknown): string { + const vendor = vendorSentence(error); + if (vendor !== null) return `${subject} failed: ${vendor}`; + const thrown = + error instanceof Error ? error.message.trim() : String(error).trim(); + return thrown === "" || VENDOR_PLACEHOLDER.test(thrown) + ? unexplained(subject) + : `${subject} failed: ${thrown}`; } /** @@ -147,8 +200,7 @@ async function ask(attempt: string, read: () => Promise): Promise { try { return await read(); } catch (error) { - say(`${attempt} failed: ${sentence(error)}`); - process.exit(1); + stop(failed(attempt, error), 1); } } @@ -158,28 +210,38 @@ const apps = await ask("Listing the apps this key can see", () => broker.listApps(), ); const app = apps.find((candidate) => candidate.slug === APP); +/* + * THE TRUNCATED CATALOGUE IS NOT CHECKED FOR HERE, BECAUSE IT CANNOT ARRIVE HERE. + * + * There used to be a branch below reporting that the catalogue had come back full at + * {@link LISTING_LIMIT}, and it could never run: `broker.listApps` refuses that answer at its own + * ceiling and throws, for the reason written down beside the throw — at the ceiling a whole + * catalogue and a cut-off one are the same array, so a partial directory is not shown at all. So + * every value of `apps` that reaches this line is shorter than the limit, and the one thing the + * branch promised to report was the one thing it could never see. + * + * WHICH DOES NOT LOSE THE REPORT, and that is why the branch went rather than the refusal being + * worked around. The refusal's own sentence says the catalogue came back at the largest page this + * deployment can ask for, and {@link ask} prints it: a run against a truncated catalogue stops on + * that sentence instead of continuing under a warning. It is the stronger of the two, because the + * branch would have gone on to report "gmail was not among them" about a listing it had just said + * it could not trust. + * + * The action listing further down keeps its own full-page check, which is NOT the same case: + * `actions.listActions` is a pass-through with no ceiling refusal in it, so there a full page + * really can arrive and really does need saying. + */ say(`Composio listed ${apps.length} apps for this key.`); -if (apps.length >= LISTING_LIMIT) { - /* - * Said because at the ceiling a whole catalogue and a cut-off one are the same array, and no - * second request can tell them apart — see {@link LISTING_LIMIT}, which is the largest page this - * SDK can express. So a full page is reported as a full page rather than as the catalogue, and an - * app missing from it may be past the cut rather than absent from the account. - */ - say( - `That is the whole page Composio will answer with (${LISTING_LIMIT}), so the catalogue came back full and anything missing below may be past the cut rather than absent.`, - ); -} if (!app) { /* * Stated rather than shrugged at. A catalogue that does not contain Gmail is a key pointed at * something other than what this script assumes, and reporting "0 actions" for it would read as * an empty app rather than as a listing that never included it. */ - say( + stop( `${APP} was not among them, so the action count and the connection below are about an app this key cannot see.`, + 1, ); - process.exit(1); } say(`${app.name} publishes ${app.actionCount} actions.`); @@ -199,10 +261,10 @@ if (!call) { } if (!connected) { - say( + stop( `--call was passed, but there is no connection to call through, so nothing was sent. Connect ${APP} for ${user} first.`, + 1, ); - process.exit(1); } /* @@ -228,10 +290,10 @@ say( ); const action = listed.find((candidate) => candidate.slug === READ_ACTION); if (!action) { - say( + stop( `Composio does not list ${READ_ACTION} for ${APP}, so nothing was called. Pick another read-only action rather than calling one of the writes.`, + 1, ); - process.exit(1); } const { effect, destructive } = effectOf(action.tags); if (effect !== "read" || destructive) { @@ -240,16 +302,16 @@ if (effect !== "read" || destructive) { * unlabelled as a write, so this also covers the case where Composio stops publishing labels * altogether — which would otherwise turn a smoke test into an unreviewed write. */ - say( + stop( `Composio no longer marks ${READ_ACTION} as read-only, so nothing was called. This script only ever runs a read.`, + 1, ); - process.exit(1); } if (!action.version) { - say( + stop( `Composio listed ${READ_ACTION} with no version, so no versioned call could be made and nothing was sent.`, + 1, ); - process.exit(1); } let result: ComposioResult; @@ -269,19 +331,31 @@ try { {}, ); } catch (error) { - say(`${READ_ACTION} threw: ${sentence(error)}`); - process.exit(1); + stop(failed(READ_ACTION, error), 1); } /* * A resolution is not a success. Composio reports most failures by answering with `successful: * false` rather than by throwing, and a smoke test that only watched for exceptions would report a * working key on top of a call that failed. + * + * THE REPORTED SENTENCE GOES THROUGH THE SAME TWO GUARDS A THROWN ONE DOES. `result.error` is the + * vendor's field and it carries the vendor's placeholder as readily as an exception message does — + * `callTool` refuses it there for exactly this reason — so "failed: Error executing the tool + * GMAIL_GET_PROFILE" is a line this script could otherwise print while its own docblock says that + * sentence is never worth passing on. {@link unexplained} is what is said instead, which is the + * wording the product uses for the same silence. */ -say( - result.successful - ? `${READ_ACTION} succeeded.` - : `${READ_ACTION} failed: ${result.error ?? "Composio said so without saying why."}`, -); -say(`Log id: ${result.logId ?? "none was returned."}`); -process.exit(result.successful ? 0 : 1); +const reported = result.error?.trim() ?? ""; +const outcome = result.successful + ? `${READ_ACTION} succeeded.` + : reported === "" || VENDOR_PLACEHOLDER.test(reported) + ? unexplained(READ_ACTION) + : `${READ_ACTION} failed: ${reported}`; +const log = `Log id: ${result.logId ?? "none was returned."}`; + +/* The rule at the top of this file: these two lines are the whole explanation of a non-zero exit. */ +if (!result.successful) stop(`${outcome}\n${log}`, 1); +say(outcome); +say(log); +process.exit(0); diff --git a/server/tsconfig.json b/server/tsconfig.json index 8858dd281..e6590fca3 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,9 +3,22 @@ // `scripts` too: the migration runner and the culler entrypoint ship in the image, so they are // product code that happens to live outside `src`. // + // `../scripts/composio-smoke.ts` FOR THE SAME REASON, one directory further out. It ships in the + // image — the Dockerfile copies that one file and no other — it is what `docs/plugins/composio.md` + // tells an operator to run when Composio misbehaves, and it imports `src/plugins/composio*` + // directly, so it is this workspace's code that happens to live at the repository root. Nothing + // else covered it: the root `scripts/` directory is in no workspace, and `bun run typecheck` is + // `bun run --filter '*' typecheck`, which matches workspaces only and never the root manifest. + // + // THE FILE AND NOT THE DIRECTORY, which is the line the Dockerfile already draws. The rest of + // `../scripts` is the laptop's, and two of those are global scripts with no import or export at + // all, so they share one scope and redeclare each other's variables the moment a project takes + // them in. Named individually the way `app/tsconfig.json` names the two `shared` files it reaches + // for. + // // `tests` is NOT here yet, and that is a known gap rather than a decision: the suite has never been // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let // a test pass an options object where a connection string belongs and hear nothing back. Its own // change, because it is a sweep and not a fix. - "include": ["src", "scripts"] + "include": ["src", "scripts", "../scripts/composio-smoke.ts"] } From 39e44325c1b472bd5b373815a2d2fbbe40cfb1de Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 17:55:54 +0200 Subject: [PATCH 137/215] Stop telling operators no Composio surface exists, when one row does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unconfigured deployment still draws a row on the admin Plugins page: under the "More apps" heading, titled Composio, reading "Add your Composio key to enable a catalogue of tools. Set COMPOSIO_API_KEY on this deployment." — no chevron and no link, because there is nowhere to go until the key is set. The heading stays too. The docs and .env.example already say so. BrokerUnconfiguredError's message, which routes send straight to whoever asked, ended by promising the opposite: "no Composio surface appears anywhere in the product". It now names what is actually absent — nothing to connect, nothing to grant, no Composio tool for a Bot to call — and says where the one remaining row is, so an operator who has seen it is not told it cannot exist. Two comments repeating the claim go with it: the class's own note that the brokered surface is absent by design, and config.ts on composioApiKey. The class's remedy paragraph said nothing else about this deployment names the variable; that row does, so it says so. No behaviour changes. No test asserted the corrected clause. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/config.ts | 6 ++++-- server/src/plugins/broker.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 90dcb1636..e85328a28 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -177,8 +177,10 @@ export type DeploymentConfig = { * deployment's own registered with each of them. * * Optional, and undefined is the ordinary state rather than a degraded one. A deployment that has - * not bought Composio is not a deployment missing something: no Composio surface appears anywhere - * in the product, and nothing else it does is any worse for that. + * not bought Composio is not a deployment missing something: there is nothing to connect, nothing + * to grant and no Composio tool for a Bot to call, what remains on screen is one row that goes + * nowhere under More apps on the admin Plugins page naming this variable, and nothing else it does + * is any worse for that. * * Nothing here validates the key. There is no shape to check it against and no call worth making * at boot to find out, so the first real request is what says whether it works — which is also diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 82039c409..acc7685a4 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -166,14 +166,18 @@ export class BrokerRefusalError extends Error { * The state a deployment with no Composio key is in, raised rather than returned. * * A STATE, NOT A FAULT, in the same sense `./composio`'s unconfigured listing is one: unset - * `COMPOSIO_API_KEY` is the documented default, and the whole brokered surface is absent by design - * where it is unset. It is a thrown class rather than a null answer because the broker's methods + * `COMPOSIO_API_KEY` is the documented default, and where it is unset there is nothing to connect, + * nothing to grant and no brokered tool for a Bot to call — what is left on screen is one row that + * goes nowhere, under More apps on the admin Plugins page, naming the setting rather than hiding + * the feature. It is a thrown class rather than a null answer because the broker's methods * answer apps, booleans and urls, and there is no value in any of those shapes that means "nobody * was asked" — an empty app list is indistinguishable from a catalogue outage, and `false` from * {@link ComposioBroker.isConnected} is a positive claim about somebody's account. * - * The setting is named in the message because the message is the whole remedy: an operator reading - * it needs the name of the variable to set, and nothing else about this deployment will tell them. + * The setting is named in the message because the message is usually the whole remedy: an operator + * reading it needs the name of the variable to set, and the one other place this deployment names + * it is that row on the admin Plugins page, which somebody meeting this error through the API may + * never have seen. * * A {@link BrokerRefusalError} BECAUSE IT IS THE ORIGINAL ONE. It was the only authored refusal a * route could recognise when this file had one class, and it is a refusal of exactly that kind: a @@ -184,7 +188,7 @@ export class BrokerRefusalError extends Error { export class BrokerUnconfiguredError extends BrokerRefusalError { constructor() { super( - "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set, no Composio surface appears anywhere in the product.", + "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set there is nothing to connect, nothing to grant and no Composio tool for a Bot to call, and the admin Plugins page shows one row under More apps that goes nowhere.", ); this.name = "BrokerUnconfiguredError"; } From 738aece45aa399bf756e32fe28eb8650842017df Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 18:07:08 +0200 Subject: [PATCH 138/215] Say what Composio said, instead of sending everybody to check the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `routes.ts` answers a thrown broker error by reaching for the vendor's own sentence and, finding none, telling the reader that Composio said nothing about why and that an administrator should check this deployment's Composio key. That is the right thing to say about a socket that hung up. It was also what a person heard when `@composio/core` had named the condition precisely: somebody who already had an account for an app was told to check an API key and try again, about a state that is settled at the vendor and answers identically for ever. Only one call in the file translated anything — `askForEach`'s partial-failure throw, reachable only after a listing had already succeeded — so six of the eight seam methods sat on the test table's outage allow-list. Every vendor call is now made through `askVendor`, which reads the condition off the error's own `name` and answers eight of them with a sentence written here, each naming what happened and the one step that fixes it, and no two sharing a remedy. `name` rather than `instanceof` because the SDK assigns it in every constructor and works around the same dual-package hazard itself in `isRequestAbortError`. A condition whose own server message `vendorSentence` can reach is passed on untouched instead, because `routes.ts` reads the authored sentence first and ours would hide a more specific one. Translated, with the call that raises each: ComposioMultipleConnectedAccountsError connectedAccounts.link ComposioAclOnlyForSharedError connectedAccounts.link ComposioFailedToCreateConnectedAccountLink connectedAccounts.link ValidationError authConfigs.create, connectedAccounts.list, connectedAccounts.link, tools.getRawComposioTools, tools.execute ComposioRequestCancelledError toolkits.get, connectedAccounts.link, tools.getRawComposioToolBySlug, tools.execute ComposioConnectedAccountNotFoundError tools.execute (API code 1803) ComposioToolNotFoundError tools.getRawComposioToolBySlug ComposioToolVersionRequiredError tools.execute Left unwrapped, because the key and the status page genuinely are the remedy: ComposioToolkitFetchError, which `Toolkits.getToolkits` wraps around every catalogue failure including its own validation one and whose message is the bare "Failed to fetch toolkits"; and ComposioToolExecutionError, whose cause carries the server's own words for `vendorSentence` and whose own message `./composio`'s VENDOR_PLACEHOLDER already refuses to pass on. Both allow-list entries for them stand. The ten vendor calls audited for this pattern, all of them now wrapped: toolkits.get, authConfigs.list, authConfigs.create, authConfigs.delete, connectedAccounts.list, connectedAccounts.link, connectedAccounts.delete, tools.getRawComposioTools, tools.getRawComposioToolBySlug, tools.execute. Read and found unreachable from this seam, so deliberately untranslated: ComposioLegacyConnectedAccountsEndpointRetiredError and ConnectionRequestTimeoutError/ConnectionRequestFailedError (connectedAccounts .initiate and waitForConnection, neither called); ComposioAuthConfigNotFoundError and ComposioToolkitNotFoundError (Toolkits.getAuthConfigForToolkit, toolkits.authorize and getToolkitBySlug, none called); ComposioSharedConnectionNotAccessibleError and the tool-router session errors (no session is ever created here); ComposioSharedAccessDeniedError (no throw site in 0.18.1); ComposioNoAPIKeyError (raised constructing the client, before any seam method); ComposioInvalidModifierError, ComposioProviderNotDefinedError, ComposioInvalidToolArgumentsError, ComposioGlobalExecuteToolFnNotSetError, ComposioInvalidExecuteFunctionError, JsonSchemaToZodError and JsonSchemaRefResolutionError (provider, modifier and custom-tool paths this file does not use); and the Trigger, webhook, MCP and file-mount errors, whose models are never touched. `askForEach` is fixed alongside it, for the same class of reason. It kept only the first refusal and both callers throw a COUNT, which left the reasons as the only place the detail lived and then dropped all but one of them; every refusal now travels as `cause`, aggregated where there is more than one, carried rather than logged because a vendor error out of a link or a delete carries the request it was made for. And it caught everything, so a TypeError out of this adapter's own code came back to a person as "Composio refused the rest, press disconnect again" — advice about a fault that will do the same thing every time, wearing the vendor's name. The three classes only a program produces now leave the loop as themselves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 434 ++++++++++++++++++++++--- server/tests/composio-adapter.test.ts | 318 ++++++++++++++++++ 2 files changed, 710 insertions(+), 42 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 1956ed3cd..060f3b789 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -9,6 +9,7 @@ import { type ComposioActions, type ComposioResult, LISTING_LIMIT, + vendorSentence, } from "./composio"; /** @@ -509,6 +510,278 @@ function actionOf( return { slug, description, inputParameters, tags, version }; } +/** + * WHAT THIS DEPLOYMENT WAS DOING WHEN A VENDOR CALL REFUSED, so a translated refusal can say. + * + * A CONDITION AND AN OUTCOME ARE TWO DIFFERENT HALVES OF A SENTENCE, and only one of them is the + * vendor's. "This person already holds an account for gmail" is what Composio determined; "so their + * connection to gmail was not begun" is what this deployment did about it, and a reader needs both + * — the first to know why asking again will not help, the second to know what state they are in + * now. The vendor cannot supply the second, because it does not know which of this file's ten calls + * it was answering. + * + * WRITTEN AT THE CALL SITE AS A FINISHED CLAUSE, in the past tense, so that it reads after "so" + * without any sentence below having to conjugate it. That is also what keeps the outcomes honest: + * each one is composed beside the call it describes, where whether anything was sent is a fact + * rather than a guess. + */ +type VendorCall = { + /** What did not happen, as a clause following "so": "the app catalogue was not read". */ + outcome: string; + /** The app the call is about, or null where the question names no app at all. */ + app: string | null; +}; + +/** + * The vendor's own name for the condition it raised, or null where it raised something anonymous. + * + * READ OFF `name` RATHER THAN ASKED WITH `instanceof`, which is a deliberate choice and not a + * shortcut. Every error class in `@composio/core` 0.18.1 ends its constructor by assigning its own + * `name` (`src/errors/*.ts`), so the name is the vendor's published discriminator and is stable + * across the package boundary; `instanceof` is not, because it is identity on a constructor and + * therefore hostage to a second copy of the package anywhere in the tree. The SDK makes the same + * judgement about its own classes — `isRequestAbortError` falls back to `constructor.name` and + * `name` and says why: "dual-package-hazard cases" (`src/errors/SDKErrors.ts`). `./composio`'s + * `isSchemaMismatch` reaches for a shape rather than a class for the same reason. + * + * AND IT IS WHAT LETS THE TABLE BE A TABLE. Naming eight classes as values would mean importing + * eight symbols from the vendor into the one file whose whole argument is that the vendor's surface + * is confined — and a version that renames one would then be a compile error in a switch that is + * meant to degrade to "not a condition this file knows" rather than to fail the build. + */ +function conditionOf(error: unknown): string | null { + const name = (error as { name?: unknown } | null | undefined)?.name; + return typeof name === "string" && name.trim() !== "" ? name : null; +} + +/** + * A VENDOR CONDITION AS A SENTENCE THIS DEPLOYMENT WROTE, or null where there is none to write. + * + * THE DEFECT THIS CLOSES IS THAT ALMOST NOTHING WAS TRANSLATED. `routes.ts` answers a thrown broker + * error by reaching for the vendor's own sentence and, finding none, telling the reader that + * Composio said nothing about why and that an administrator should check this deployment's Composio + * key. That is exactly right about a socket that hung up. It is wrong twice over about a condition + * `@composio/core` raised by name: the key is fine — the call that failed usually went out through + * a listing that had just succeeded on the same key — and several of these states are settled at + * the vendor, so the "and try again" half of the advice is an instruction to repeat something that + * will answer identically for ever. The worst of them is the first row below: a person who already + * has an account for an app was told to check an API key and retry. + * + * ONE SENTENCE PER CONDITION AND NO SENTENCE SHARED, which is the property its test asserts in both + * directions. A translation that gave two conditions one wording would be worse than leaving both + * alone: the reader would be handed a remedy that is right for somebody else's failure and would + * have no way to tell, where an untranslated failure at least says plainly that nothing is known. + * + * NULL WHERE COMPOSIO'S OWN SERVER EXPLAINED ITSELF, WHICH IS THE LIMIT ON DOING THIS AT ALL. + * `routes.ts` reads `brokerSentence` first and `vendorSentence` second, so a refusal authored here + * HIDES the vendor's message rather than joining it. Several of the SDK's classes are wrappers + * around whatever the API returned — `ComposioFailedToCreateConnectedAccountLink` keeps the + * `BadRequestError` as its `cause` (`src/models/ConnectedAccounts.ts`), and `vendorSentence` reaches + * through exactly that nesting — so translating one of those unconditionally would replace a + * specific server sentence with this deployment's general one. Where the vendor said something a + * reader can use, the error goes on untouched and the vendor gets the last word. + * + * THE ORIGINAL IS KEPT AS `cause` on every refusal, for whoever is reading a log rather than a page. + * It is never quoted into the message: the file's promise about {@link BrokerRefusalError} is that + * its sentence is safe to show anybody who could have made the request, and a vendor error object + * out of `connectedAccounts.link` carries the request that was being minted. + * + * WHAT IS DELIBERATELY NOT HERE, because the route's default answer is the correct one for it: + * `ComposioToolkitFetchError`, which `Toolkits.getToolkits` wraps around EVERY catalogue failure + * including its own validation one, and whose message is the bare "Failed to fetch toolkits" — the + * key and the status page genuinely are the remedy; and `ComposioToolExecutionError`, the same + * wrapper one call further on, whose `cause` carries the server's own words for `vendorSentence` to + * find and whose own message `./composio`'s {@link VENDOR_PLACEHOLDER} already refuses to pass on. + */ +function vendorRefusal( + error: unknown, + call: VendorCall, +): BrokerRefusalError | null { + if (vendorSentence(error) !== null) return null; + + const app = call.app ?? "the app"; + const outcome = call.outcome; + const refusal = (message: string): BrokerRefusalError => + new BrokerRefusalError(message, { cause: error }); + + switch (conditionOf(error)) { + /* + * THE ONE THAT WAS DOING THE MOST DAMAGE. `connectedAccounts.link` lists this person's active + * accounts for the config before it mints anything and refuses where it finds one + * (`@composio/core` 0.18.1, `src/models/ConnectedAccounts.ts`), which is this deployment's own + * rule met one layer down — one person holds one account per app, because the call that runs an + * action names the person and not the account. So it is a settled fact rather than a moment, + * and "check the key and try again" is advice that cannot ever come true. + */ + case "ComposioMultipleConnectedAccountsError": + return refusal( + `Composio answered that this person already holds a connected account for ${app}, so ${outcome}. That is a settled state at Composio rather than a moment that passes — asking again meets the same answer — and what clears it is disconnecting the account they already hold, on this deployment's Connected accounts page, before another is attached.`, + ); + + /* + * ACCESS RULES ARE NOT SOMETHING THIS FILE SENDS, which is the whole of why this one is worth a + * sentence. The SDK raises it when the server rejects ACL fields on an account that is not + * shared, and nothing here asks for sharing — so the reader must not go looking through this + * deployment's settings for a field it does not have. The config in Composio's dashboard is + * where the sharing is decided and where an operator can change it. + */ + case "ComposioAclOnlyForSharedError": + return refusal( + `Composio refused account-sharing rules on an account that is not a shared one, so ${outcome}. This deployment attaches every account to one person and asks for no sharing, so the rules are on the authorization config rather than on anything sent from here: an operator changing how ${app} is shared in Composio's own dashboard is what clears it.`, + ); + + /* + * REACHED ONLY WHERE THE SERVER SAID NOTHING, by the guard at the top of this function. What is + * left when it is reached is still worth far more than the route's default, because of what has + * already happened by the time this call is made: the auth configs were listed through the same + * key moments earlier and one of them was found enabled. So the two things the default sends an + * operator to check are both already proven, and the two things a person needs to know — that + * they were not sent anywhere, and that their consent is unspent — are facts about this + * particular call that no general sentence carries. + */ + case "ComposioFailedToCreateConnectedAccountLink": + return refusal( + `Composio would not mint a connect link for ${app} and said nothing about why, so ${outcome}. Nobody was sent to a consent screen and no consent was spent. This deployment's key and its authorization config for the app were both read through successfully moments earlier, so neither of those is what to check; Composio's status page is.`, + ); + + /* + * THE SDK'S OWN SCHEMA REFUSING, ON EITHER SIDE OF THE WIRE. `ValidationError` is raised by + * nearly every model here — the auth-config create, the connected-account listing and link, the + * tool listing and the execute all `safeParse` what they are handed and what comes back — and in + * both directions it means the same thing: this deployment's copy of `@composio/core` and + * Composio's API no longer agree. It is the one condition below whose remedy is a package + * rather than a page, which is why it shares its wording with the shape refusals above. + */ + case "ValidationError": + return refusal( + `This deployment's @composio/core refused the request or Composio's answer against its own schema, so ${outcome} — either before Composio was asked or after it had replied. Nothing an operator can set corrects that and the key is not what to check: upgrading this deployment's @composio/core is what fixes it.`, + ); + + /* + * NOTHING IS WRONG AT COMPOSIO, WHICH IS THE ENTIRE MESSAGE. A cancelled request is a caller's + * own abort, so sending somebody to a key or a status page is sending them to look at two things + * that are working. The one honest thing to add is the ambiguity: a call cancelled in flight may + * or may not have been acted on at the vendor, and this deployment cannot tell which. + */ + case "ComposioRequestCancelledError": + return refusal( + `The request was cancelled before Composio answered, so ${outcome} as far as this deployment can tell — and how far it had got when the cancellation landed is exactly what it cannot tell. Nothing is wrong at Composio and nothing needs setting here; asking for it again is what settles which of the two it was.`, + ); + + /* + * THE ACCOUNT IS GONE AT THE VENDOR, WHICH THIS DEPLOYMENT'S ROWS DO NOT KNOW. `tools.execute` + * maps API error code 1803 onto this class (`src/errors/ToolErrors.ts`), and what it reports is + * a person whose `composio_connections` row still stands over an account Composio no longer + * holds — a grant withdrawn at Google, or an account removed in the dashboard. Retrying reaches + * neither; connecting again is what puts an account back under the row. + */ + case "ComposioConnectedAccountNotFoundError": + return refusal( + `Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`, + ); + + /* + * THE ACTION THIS DEPLOYMENT HOLDS IS NO LONGER ONE COMPOSIO PUBLISHES. Raised by the resolve + * that `execute` makes before it runs anything (`src/models/Tools.ts`), so nothing ran. The + * tools this deployment stores are a listing taken at some earlier moment, and the remedy is + * therefore the same one the app-mismatch refusal below already names: re-read the listing. + */ + case "ComposioToolNotFoundError": + return refusal( + `Composio no longer publishes that action at the version this deployment recorded for it, so ${outcome}. An action withdrawn from an app and a toolkit version retired both read like this, and a retry reaches neither: refreshing ${app}'s tools on its Plugins page records what Composio publishes now.`, + ); + + /* + * A RECORDED VERSION OF "latest" IS A ROW THAT NEEDS REWRITING, not a call that needs repeating. + * The SDK refuses `latest` for a tool executed one at a time (`src/models/Tools.ts`), and the + * version travelling with a call is whatever the listing wrote down for the action, so the fix + * is on the row rather than at Composio. + */ + case "ComposioToolVersionRequiredError": + return refusal( + `Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so ${outcome}. A dated version is recorded when an app's actions are listed, so refreshing ${app}'s tools on its Plugins page replaces "latest" with a version Composio will accept.`, + ); + + default: + return null; + } +} + +/** + * One vendor call, with whatever it refuses with translated on the way out. + * + * WRAPPED AROUND THE `await vendor.*` AND NOTHING ELSE, which is the discipline that makes this + * safe to apply everywhere. Everything else inside these methods is this file's own reading and + * refusing, and those already carry authored sentences; passing one back through + * {@link vendorRefusal} could only find a name it does not know, but the narrower scope is what + * makes that true by construction rather than by inspection. + * + * A CALL WITH NO ENTRY HERE IS THE FAILURE MODE THE TABLE IN THE TESTS EXISTS FOR. TypeScript has + * no checked exceptions, so nothing enumerates the calls that translate and nothing notices a new + * one that does not; the seam's own test walks every method of both projections and fails the + * method that forgot. + */ +async function askVendor( + call: VendorCall, + ask: () => Promise, +): Promise { + try { + return await ask(); + } catch (error) { + const refusal = vendorRefusal(error, call); + if (refusal !== null) throw refusal; + throw error; + } +} + +/** + * Whether a thrown failure is this deployment's own bug rather than anybody's answer to a request. + * + * ONLY A PROGRAM PRODUCES THESE. A `TypeError`, a `ReferenceError` and a `RangeError` are what a + * mistake in this file or in the SDK looks like — a field read off `undefined`, a name that is not + * there — and none of them is a thing Composio can reply. The distinction matters in exactly one + * place, {@link buildComposioClient}'s delete loop, whose whole job is to keep going after a + * refusal: a loop that absorbs a bug of ours reports it as "Composio refused the rest" and tells an + * operator to press the button again about a fault that will do the same thing every time. + * + * NAMED AS A SMALL CLOSED LIST RATHER THAN GUESSED AT, because the cost of the two mistakes is not + * symmetric. A vendor error wrongly treated as our bug escapes the loop early and is reported as + * itself, which is loud and recoverable; our bug wrongly treated as a vendor refusal is swallowed + * into a count and a retry instruction, which is the state that hides. + */ +function isOurFault(error: unknown): boolean { + return ( + error instanceof TypeError || + error instanceof ReferenceError || + error instanceof RangeError + ); +} + +/** + * Every reason a set-wide refusal collected, in one value a `cause` can hold. + * + * ALL OF THEM, WHICH IS THE CORRECTION. The two loops below used to throw with `cause: refused[0]`, + * so a person with five accounts of which three refused left one reason attached and two discarded + * — and the sentence those throws carry is a COUNT, deliberately, because a count is the thing a + * reader can act on. The reasons were therefore the only place the detail existed at all, and two + * thirds of it was being dropped on the floor. + * + * CARRIED RATHER THAN LOGGED, and that is this file's rule rather than a preference. A vendor error + * out of `connectedAccounts.link` or an account delete carries the request it was made for, and a + * console line is a line in an aggregator; nothing in this module logs a vendor object, for the same + * reason nothing in it logs the key. A `cause` travels to whoever is already holding the failure. + * + * ONE REFUSAL STAYS ITSELF. Wrapping a single error in an `AggregateError` would make every reader + * unwrap a list to find one thing, and `store.ts` already reads `error.cause` directly. + */ +function everyRefusal(refused: unknown[]): unknown { + if (refused.length === 1) return refused[0]; + return new AggregateError( + refused, + `Composio refused ${refused.length} of the requests this call made.`, + ); +} + /** * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. * @@ -866,13 +1139,20 @@ export function buildComposioClient( toolkit: string, statuses: VendorAccountStatus[], ): Promise => { - const answered: unknown = await vendor.connectedAccounts.list({ - userIds: [userId], - toolkitSlugs: [toolkit], - statuses, - accountType: "ALL", - limit: LISTING_LIMIT, - }); + const answered: unknown = await askVendor( + { + outcome: `this person's ${toolkit} accounts were not read`, + app: toolkit, + }, + () => + vendor.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses, + accountType: "ALL", + limit: LISTING_LIMIT, + }), + ); /* * CHECKED HERE RATHER THAN AT EITHER CALLER, so that the two questions cannot drift on this. * `isConnected` answers a boolean and `revoke` deletes by id, and an unreadable listing is the @@ -906,11 +1186,18 @@ export function buildComposioClient( const configsMadeHere = async ( toolkit: string, ): Promise => { - const answered: unknown = await vendor.authConfigs.list({ - toolkit, - limit: LISTING_LIMIT, - showDisabled: true, - }); + const answered: unknown = await askVendor( + { + outcome: `this deployment's authorization configs for ${toolkit} were not read`, + app: toolkit, + }, + () => + vendor.authConfigs.list({ + toolkit, + limit: LISTING_LIMIT, + showDisabled: true, + }), + ); /* * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The * filter's question IS the name, so a row checked only once it had been kept would be a row @@ -942,6 +1229,20 @@ export function buildComposioClient( * SERIALLY RATHER THAN TOGETHER, for the same reason every other call here goes out one at a * time: the vendor rate-limits, and a person with several accounts is not a reason to open * several connections. The order is the listing's, which is sorted. + * + * EVERY REFUSAL IS ANSWERED AND NOT ONLY THE FIRST. What the callers do with this list is throw a + * COUNT — "two of three were withdrawn and the rest refused" — because a count is what a reader + * can act on, which makes the reasons the only place the detail lives. Returning them all is what + * lets {@link everyRefusal} put all of them on the failure the caller raises; the previous version + * collected them and both callers then read `refused[0]`, so the second and third reason existed + * for the length of one expression and were then dropped. + * + * AND A BUG OF OURS IS NOT A REFUSAL BY COMPOSIO, which is the other half. The catch used to take + * everything, so a `TypeError` out of this file's own code arrived in the same list as a vendor's + * 502 and came back to a person as "Composio refused the rest, press disconnect again" — advice + * about a fault that will do exactly the same thing the second time, wearing the vendor's name. + * {@link isOurFault} names the three classes only a program produces, and one of those comes + * straight back out of the loop as itself. */ const askForEach = async ( items: T[], @@ -952,6 +1253,7 @@ export function buildComposioClient( try { await ask(item); } catch (error) { + if (isOurFault(error)) throw error; refused.push(error); } } @@ -988,10 +1290,10 @@ export function buildComposioClient( * catalogue is this deployment's own job, over the rows below. */ const fetchDirectory = async (): Promise => { - const answered: unknown = await vendor.toolkits.get({ - limit: LISTING_LIMIT, - sortBy: "usage", - }); + const answered: unknown = await askVendor( + { outcome: "the app catalogue was not read", app: null }, + () => vendor.toolkits.get({ limit: LISTING_LIMIT, sortBy: "usage" }), + ); /* * THE CONTAINER IS SETTLED BEFORE ITS LENGTH IS MEASURED, which is why this sits above the @@ -1060,10 +1362,17 @@ export function buildComposioClient( * `page` required precisely so that no layer could quietly supply one. See the module * comment on what an omitted limit does beyond truncating. */ - const answered: unknown = await vendor.tools.getRawComposioTools({ - toolkits: [toolkit], - limit: page.limit, - }); + const answered: unknown = await askVendor( + { + outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + app: toolkit, + }, + () => + vendor.tools.getRawComposioTools({ + toolkits: [toolkit], + limit: page.limit, + }), + ); const tools = Array.isArray(answered) ? answered : null; if (tools === null) { @@ -1095,9 +1404,16 @@ export function buildComposioClient( * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is * refused before anything runs, rather than discovered in an audit row afterwards. */ - const resolved = await vendor.tools.getRawComposioToolBySlug(call.slug, { - version: call.version, - }); + const resolved = await askVendor( + { + outcome: `${call.slug} was not resolved and nothing was run`, + app: call.toolkit, + }, + () => + vendor.tools.getRawComposioToolBySlug(call.slug, { + version: call.version, + }), + ); const ran = resolved.toolkit?.slug; if (ran !== call.toolkit) { /* @@ -1118,11 +1434,15 @@ export function buildComposioClient( ); } - return vendor.tools.execute(call.slug, { - arguments: args, - userId: call.userId, - version: call.version, - }); + return askVendor( + { outcome: `${call.slug} was not run`, app: call.toolkit }, + () => + vendor.tools.execute(call.slug, { + arguments: args, + userId: call.userId, + version: call.version, + }), + ); }, }; @@ -1213,10 +1533,17 @@ export function buildComposioClient( const existing = await configsMadeHere(toolkit); if (existing.length > 0) return; - await vendor.authConfigs.create(toolkit, { - type: "use_composio_managed_auth", - name: `${name} ${CONFIG_SUFFIX}`, - }); + await askVendor( + { + outcome: `no authorization config was created for ${toolkit} and the app is not enabled`, + app: toolkit, + }, + () => + vendor.authConfigs.create(toolkit, { + type: "use_composio_managed_auth", + name: `${name} ${CONFIG_SUFFIX}`, + }), + ); }, async deleteAuthConfig(toolkit): Promise { @@ -1240,7 +1567,14 @@ export function buildComposioClient( */ const ours = await configsMadeHere(toolkit); const refused = await askForEach(ours, (config) => - vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), + askVendor( + { + outcome: `one of this deployment's authorization configs for ${toolkit} was not removed`, + app: toolkit, + }, + () => + vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), + ), ); if (refused.length > 0) { /* @@ -1249,11 +1583,13 @@ export function buildComposioClient( * supposed to end, and the app's row is deleted after this returns — so a swallowed failure * here is the one state nothing in this deployment can find again. The count is the whole * message: an operator who can see that one of two configs went knows that pressing remove - * again finishes the job rather than repeating it. + * again finishes the job rather than repeating it. Every refusal the loop met travels as + * `cause` — see {@link everyRefusal} — because the count is deliberately all the sentence + * says, which leaves the reasons nowhere else to live. */ throw new BrokerRefusalError( `Composio removed ${ours.length - refused.length} of this deployment's ${ours.length} authorization configs for ${toolkit} and refused the rest, so the app has not been fully withdrawn. Removing it again asks only for what is left.`, - { cause: refused[0] }, + { cause: everyRefusal(refused) }, ); } }, @@ -1360,9 +1696,16 @@ export function buildComposioClient( * calls it a "magic function" for exactly that — which is the opposite of what this * deployment wants. */ - const request = await vendor.connectedAccounts.link(userId, config.id, { - callbackUrl: returnUrl, - }); + const request = await askVendor( + { + outcome: `this person's connection to ${toolkit} was not begun`, + app: toolkit, + }, + () => + vendor.connectedAccounts.link(userId, config.id, { + callbackUrl: returnUrl, + }), + ); const redirectUrl = request.redirectUrl; if (!redirectUrl) { /* @@ -1406,7 +1749,13 @@ export function buildComposioClient( */ const accounts = await accountsFor(userId, toolkit, REVOCABLE); const refused = await askForEach(accounts, (id) => - vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), + askVendor( + { + outcome: `one of this person's ${toolkit} accounts was not withdrawn`, + app: toolkit, + }, + () => vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), + ), ); if (refused.length > 0) { @@ -1424,12 +1773,13 @@ export function buildComposioClient( * WHICH IS ALSO WHY IT IS NOT A `true` WITH A GRUMBLE. Nothing was disconnected in the * sense the person asked about: their app still answers. The count is in the sentence * because "some of your accounts were withdrawn" is the one thing a reader cannot work out - * for themselves, and the vendor's own error is kept as `cause` for whoever is reading a - * log rather than a page. + * for themselves, and EVERY refusal the loop met is kept as `cause` for whoever is reading + * a log rather than a page — see {@link everyRefusal} for why all of them rather than the + * first, which is what this used to keep. */ throw new BrokerRefusalError( `Composio withdrew ${accounts.length - refused.length} of this person's ${accounts.length} accounts for ${toolkit} and refused the rest, so their access to it has not ended. Disconnecting again asks only for the accounts that are left.`, - { cause: refused[0] }, + { cause: everyRefusal(refused) }, ); } diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index de3f41980..76cc1a557 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1363,6 +1363,324 @@ describe("what a vendor failure becomes on its way out of the seam", () => { } }); +/** + * ONE REMEDY PER VENDOR CONDITION, WHICH IS THE HALF THE TABLE ABOVE CANNOT ASSERT. + * + * That table asks whether a reader is told ANYTHING, and a seam that answered every named condition + * with one sentence would satisfy it completely. This file has already been bitten by exactly that: + * `rejects.toThrow(/linear/)` matched three authored refusals prescribing three different acts by + * three different people, so deleting a whole branch left the suite green. The app name is the part + * every sentence shares; the remedy is the part that makes a sentence worth writing. + * + * SO EACH ROW BELOW IS ASSERTED IN BOTH DIRECTIONS: the sentence a condition produces must carry ITS + * remedy and must carry NO OTHER ROW'S. Two conditions collapsed into one wording fail here twice + * over — the row that lost its remedy, and the row that acquired a second one. + * + * THE CALL SITE OF EACH ROW IS ONE THAT ACTUALLY RAISES IT, read off `@composio/core` 0.18.1 rather + * than chosen for convenience, so a row is also a record of where its condition comes from. The + * table above already establishes that the translation is not call-site-specific; this one + * establishes that the sentences are distinguishable, which is what stops the translation being a + * fallback with a vendor's name on it. + */ +describe("each vendor condition reaches the reader as its own remedy", () => { + /** The vendor's error as it arrives: a name, and a message nothing here reads. */ + function raising(name: string): () => Promise { + return async (): Promise => { + throw Object.assign(new Error(`${name} came out of Composio.`), { name }); + }; + } + + /** Minting this person's connect link, which is where three of the rows below come from. */ + function whileLinking(raise: () => Promise): Promise { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { link: raise }, + }), + () => 1_000_000, + ); + return broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + } + + /** Creating this deployment's auth config, where the SDK parses what it is handed. */ + function whileCreatingConfig(raise: () => Promise): Promise { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [] }), create: raise }, + }), + () => 1_000_000, + ); + return broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + } + + const CALL = { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }; + + /** Resolving the tool before it is run, which is the call that reports a withdrawn action. */ + function whileResolving(raise: () => Promise): Promise { + const { actions } = buildComposioClient( + fakeVendor({ tools: { getRawComposioToolBySlug: raise } }), + () => 1_000_000, + ); + return actions.execute(CALL, {}); + } + + /** Running it, once the resolve has already agreed about which app it belongs to. */ + function whileRunning(raise: () => Promise): Promise { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: { slug: "gmail" }, + }), + execute: raise, + }, + }), + () => 1_000_000, + ); + return actions.execute(CALL, {}); + } + + const CONDITIONS: { + name: string; + raisedBy: string; + remedy: RegExp; + ask: (raise: () => Promise) => Promise; + }[] = [ + { + name: "ComposioMultipleConnectedAccountsError", + raisedBy: "connectedAccounts.link", + remedy: /disconnecting the account they already hold/, + ask: whileLinking, + }, + { + name: "ComposioAclOnlyForSharedError", + raisedBy: "connectedAccounts.link", + remedy: /changing how linear is shared in Composio's own dashboard/, + ask: whileLinking, + }, + { + name: "ComposioFailedToCreateConnectedAccountLink", + raisedBy: "connectedAccounts.link", + remedy: /no consent was spent/, + ask: whileLinking, + }, + { + name: "ValidationError", + raisedBy: "authConfigs.create", + remedy: /upgrading this deployment's @composio\/core/, + ask: whileCreatingConfig, + }, + { + name: "ComposioRequestCancelledError", + raisedBy: "tools.execute", + remedy: /asking for it again is what settles/, + ask: whileRunning, + }, + { + name: "ComposioConnectedAccountNotFoundError", + raisedBy: "tools.execute", + remedy: + /connecting gmail again on this deployment's Connected accounts page/, + ask: whileRunning, + }, + { + name: "ComposioToolNotFoundError", + raisedBy: "tools.getRawComposioToolBySlug", + remedy: /records what Composio publishes now/, + ask: whileResolving, + }, + { + name: "ComposioToolVersionRequiredError", + raisedBy: "tools.execute", + remedy: /replaces "latest" with a version Composio will accept/, + ask: whileRunning, + }, + ]; + + for (const condition of CONDITIONS) { + test(`${condition.name} out of ${condition.raisedBy} prescribes its own step`, async () => { + const failure = await failureOf(condition.ask(raising(condition.name))); + const sentence = brokerSentence(failure) ?? vendorSentence(failure); + + expect(sentence).not.toBeNull(); + expect(sentence).toMatch(condition.remedy); + + // The other direction: a sentence that also prescribes somebody else's step is a sentence two + // conditions are sharing, which is the state this whole table exists to catch. + for (const other of CONDITIONS) { + if (other.name === condition.name) continue; + expect(sentence).not.toMatch(other.remedy); + } + }); + } + + /** + * The vendor's own words win where there are any, which is the limit on translating at all. + * + * `routes.ts` reads {@link brokerSentence} first and {@link vendorSentence} second, so a refusal + * authored here HIDES whatever Composio's own server said. Several of the SDK's classes are + * wrappers that carry the server's explanation underneath — `ComposioFailedToCreateConnectedAccountLink` + * is one, and it is on the table above — so translating one of those unconditionally would replace + * a specific server message with this deployment's general one. Where the vendor explained itself, + * the error is passed on untouched and the reader gets the vendor's sentence. + */ + test("a condition whose vendor message is reachable is passed on rather than reworded", async () => { + const failure = await failureOf( + whileLinking(async (): Promise => { + throw Object.assign( + new Error("Failed to create connected account link"), + { + name: "ComposioFailedToCreateConnectedAccountLink", + cause: { + error: { + error: { + message: + "The auth config linear (OpenBot) has no redirect URI registered.", + }, + }, + }, + }, + ); + }), + ); + + expect(brokerSentence(failure)).toBeNull(); + expect(vendorSentence(failure)).toBe( + "The auth config linear (OpenBot) has no redirect URI registered.", + ); + }); +}); + +/** + * WHAT THE LOOP THAT DELETES A SET OF THINGS DOES WITH WHAT IT CATCHES. + * + * `deleteAuthConfig` and `revoke` each ask Composio to end several objects that independently hold + * somebody's access, and both attempt all of them rather than stopping at the first refusal — which + * is the right shape and was reporting almost none of what it learned. Two things were wrong with + * it, and they are different failures rather than one. + * + * EVERY REASON AFTER THE FIRST WAS DISCARDED. The throw carried `cause: refused[0]` and nothing + * else, so a person with five accounts of which three refused left one reason behind and two gone — + * and the sentence a reader gets is a count, deliberately, so the reasons were the only place the + * detail lived at all. + * + * AND A BUG OF OURS WAS COUNTED AS A REFUSAL BY COMPOSIO. The catch took everything, so a + * `TypeError` out of this adapter's own code became one more "Composio refused the rest" — a + * sentence telling an operator to press disconnect again, about a fault that will do the same thing + * every time and that no amount of retrying reaches. + */ +describe("what the delete loop keeps of the failures it meets", () => { + test("every account Composio refused is carried, not only the first", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), + delete: async (id: string) => { + if (id !== "ca_1") throw new Error(`Composio refused ${id}.`); + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // The sentence stays the count, which is what a reader can act on. The reasons are what a log + // reader needs, and there are two of them. + expect(brokerSentence(failure)).toMatch(/1 of this person's 3 accounts/); + const cause = failure.cause; + expect(cause).toBeInstanceOf(AggregateError); + expect( + (cause as AggregateError).errors.map((one) => (one as Error).message), + ).toEqual(["Composio refused ca_2.", "Composio refused ca_3."]); + }); + + test("a single refusal is still carried as itself rather than wrapped", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused that one."); + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + expect((failure.cause as Error).message).toBe("Composio refused that one."); + }); + + test("a fault in this adapter escapes rather than being counted as Composio refusing", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + delete: async (id: string) => { + if (id === "ca_2") { + throw new TypeError("held.slug is not a function"); + } + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Out as itself, and NOT as a count sentence telling somebody to press disconnect again about a + // fault that will do exactly the same thing the second time. + expect(failure).toBeInstanceOf(TypeError); + expect(failure.message).toBe("held.slug is not a function"); + expect(brokerSentence(failure)).toBeNull(); + }); + + test("the same two promises hold for the loop that removes an app's configs", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [OURS, OURS_SPARE] }), + delete: async (id: string) => { + throw new Error(`Composio refused ${id}.`); + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf(broker.deleteAuthConfig("linear")); + + expect(brokerSentence(failure)).toMatch( + /0 of this deployment's 2 authorization configs/, + ); + expect( + (failure.cause as AggregateError).errors.map( + (one) => (one as Error).message, + ), + ).toEqual(["Composio refused ac_ours.", "Composio refused ac_ours_spare."]); + }); +}); + /** * WHAT EACH VENDOR LISTING IS ALLOWED TO BE, given that this file's types only assert its shape. * From c7cb5d488f89cd8b0280c52f314ee48d4b304e19 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 18:09:18 +0200 Subject: [PATCH 139/215] Stop the store's own docblock repeating the surface-is-absent claim The other three copies of this sentence were corrected when a reviewer found that a deployment with no COMPOSIO_API_KEY does render something: one row that goes nowhere, under a More apps heading on the admin Plugins page. This was the fourth, reported by the agent that fixed the operator-facing message and correctly left out of its scope. Comment only; no behaviour, no types, no call sites. --- server/src/plugins/store.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 4b6d904e5..a30e3addb 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -897,11 +897,12 @@ export type PluginStoreOptions = { * Composio the broker, absent on a deployment that has not configured one. * * OPTIONAL BECAUSE ITS ABSENCE IS A STATE RATHER THAN A MISCONFIGURATION. An unset - * `COMPOSIO_API_KEY` is the documented default and the whole brokered surface is meant to be - * missing where it is unset, so the store is constructible without one and every path that needs - * one says so by raising {@link BrokerUnconfiguredError}. A required field would make every - * caller that never enables an app — the routes, the tests above — invent a broker to get a - * store. + * `COMPOSIO_API_KEY` is the documented default: where it is unset there is nothing to connect, + * nothing to grant and no brokered tool for a Bot to call, and what is left on screen is one row + * that goes nowhere under More apps on the admin Plugins page. So the store is constructible + * without one and every path that needs one says so by raising {@link BrokerUnconfiguredError}. + * A required field would make every caller that never enables an app — the routes, the tests + * above — invent a broker to get a store. */ broker?: ComposioBroker; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ From 30efbe62ffa8244c8471e36fa1b016fd47d7f0d0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 18:25:46 +0200 Subject: [PATCH 140/215] Read the rest of a Composio listing, instead of calling one page the whole answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nextCursor` was declared on the auth-config and connected-account listings and nothing read it, so a page at the ceiling was treated as the set. `revoke` resolved `true` — "this person's access has ended" — with a cursor still outstanding, and `store.ts` wrote that into the audit trail and then deleted the one row naming which app they had connected. Removing an app is the same shape one level up: a config on page two is left standing, holding every grant made against it, after `removeServer` has deleted the app's row. Paged rather than refused, which is the opposite of what the catalogue does two functions away. That refusal says why in its own words — the SDK offers no cursor to ask for a second page with, so an answer at the ceiling and one past it are indistinguishable — and both listings here do carry one: `AuthConfigListParamsSchema` and `ConnectedAccountListParamsSchema` name a `cursor`, both models forward it, and both transformers fill `nextCursor` in. Refusing a truncated listing would also mean the person it happened to could never disconnect, meeting the same page and the same refusal with their grants standing throughout. Paging cannot report a partial answer as complete either: an unreadable cursor, a cursor that does not advance and a fiftieth page all refuse rather than answer with what arrived. The two vendor answers that are single objects were unchecked, and neither could have failed to compile: the tool resolve inside `execute` read `resolved.toolkit?.slug` off an answer that could be null, and the account link inside `authorize` read `redirectUrl` off the same. Both are read out of `unknown` now. A toolkit the vendor sent unreadably is no longer folded into "no app at all", which carries a remedy — refresh this app's tools — that cannot work for it; a `redirectUrl` that is present and not a string no longer travels into a `Location` header. Call sites audited, all ten vendor calls this file makes: - tools.getRawComposioTools — listing already read out of `unknown`, per-row checks unchanged - tools.getRawComposioToolBySlug — UNCHECKED, now guarded for the answer and for its toolkit - tools.execute — answer crosses the seam as `ComposioResult` and is read by `./composio` - toolkits.get — bare array, full page still refused, rows checked by `appOf` - authConfigs.list — now paged, rows checked by `configOf` - authConfigs.create — answer deliberately unread - authConfigs.delete — answer deliberately unread - connectedAccounts.list — now paged, rows checked by `accountIdOf` - connectedAccounts.link — UNCHECKED, now guarded for the answer and for its redirect - connectedAccounts.delete — answer deliberately unread, per `revoke`'s "asked for, not done" `VendorToolkit.slug` and `VendorAuthConfig.id` are declared as wide as their siblings. Both were left narrow with the sweep's own note saying widening belongs beside the guard rather than ahead of it; `appOf` and `configOf` make those checks, so the declarations no longer claim more than the wire promises. Twelve behaviours that had been guarded with nothing holding them now have tests, and every one was checked by deleting the guard it covers and watching it redden: the nameless and id-less auth-config rows, the four catalogue fields, the four action fields, and the two unsettled statuses. One of the new tests passed against its own mutation — an unreadable cursor coerced to "" was caught by the same-page guard next door, with a sentence blaming Composio for repeating a page this deployment had asked twice for — so it asserts the request count and the sentence. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 330 ++++++++++--- server/tests/composio-adapter.test.ts | 631 ++++++++++++++++++++++++- 2 files changed, 904 insertions(+), 57 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 060f3b789..00f74c2a4 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -103,9 +103,16 @@ type VendorTool = { * `description` AND `toolsCount` ARE `unknown` RATHER THAN A WIDER UNION, because the wire can put * anything in them and a union would be another guess. `unknown` is the type that forces the * reader to say what it does with a value it has not checked, which is the whole point. + * + * `slug` IS AS WIDE AS `name` NOW, AND FOR THE SAME REASON. It was left narrow while the other + * fields were widened, on no argument that distinguishes it: `ToolKitItemSchema` spells it required + * and the warn-only `transform()` above copies it across whatever it turns out to be, exactly as it + * does the name. A slug is the only name this deployment has for an app — it is what enabling one + * records and what every later call names — so the one it must not quietly become is `undefined` + * read as a string. {@link appOf} has refused that since the sweep; the declaration says so now. */ type VendorToolkit = { - slug: string; + slug?: string | null; name?: string | null; meta: { description?: unknown; @@ -137,11 +144,14 @@ type VendorToolkit = { * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and * "disabled" are different facts and only one of them is worth telling an operator. * - * `id` IS LEFT ALONE deliberately: it is as unvalidated as the other two, and widening it belongs - * with the guard that would then be written for it rather than ahead of one. + * `id` IS NOW DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. It was left narrow + * on the argument that widening belongs beside the check rather than ahead of one; {@link configOf} + * makes that check, so the declaration no longer claims more than the wire promises. It is the one + * of the three whose absence sends a request: a delete named with `undefined` asks Composio to + * remove whatever it cares to, and this deployment then records that the app was withdrawn. */ type VendorAuthConfig = { - id: string; + id?: string | null; name?: string | null; status?: string; }; @@ -277,6 +287,120 @@ function isTextList(value: unknown): value is string[] { ); } +/** + * How many pages of one listing this deployment will read before it stops and says so. + * + * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. Both listings paged + * below are narrow — one app's authorization configs, or one person's accounts for one app — at + * {@link LISTING_LIMIT} rows a page, so a second page is already extraordinary and a fiftieth is + * not a data set. What it is is a cursor that keeps being handed back, which without a ceiling is + * a request that never returns: a person waiting on a page they pressed disconnect from, and a + * process holding every row it has read so far. + * + * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for + * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling + * again, one order of magnitude further out and harder to notice. + */ +const PAGE_CEILING = 50; + +/** + * The listing a refusal is about, as the two clauses every sentence below is built from. + * + * WRITTEN AT THE CALL SITE for the same reason {@link VendorCall}'s outcome is: what could not be + * told is a fact about the question being asked — "whether this person is connected" is not + * something {@link everyRowOf} can know — and composing it beside the call keeps it true. + */ +type Listing = { + /** The listing as a noun phrase: "this person's gmail accounts". */ + noun: string; + /** What could not be told, as a clause following "so": "whether one exists could not be read". */ + consequence: string; +}; + +/** + * EVERY ROW OF A LISTING THE VENDOR PAGES, or a refusal rather than a fragment read as the whole. + * + * WHY THIS PAGES WHERE THE CATALOGUE REFUSES, which is the one decision worth writing down here. + * `fetchDirectory` below meets a full page and refuses, and `./composio` does the same with a full + * action listing, and both say why in the same words: the SDK offers no cursor to ask for a second + * page with, so an answer at the ceiling and an answer past it are indistinguishable and no second + * request could tell them apart. That is a refusal born of an inexpressible request rather than a + * house style. Here the request IS expressible: `AuthConfigListParamsSchema` and + * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`, `src/types/connectedAccounts.types.ts:259-266`), both + * models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`) and + * both transformers fill `nextCursor` in from the response's `next_cursor` + * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). + * + * AND THE CALLER THAT DECIDES IT IS `revoke`. Refusing a truncated listing would be honest and + * would also mean that the person it happened to could never disconnect: every attempt would meet + * the same page and the same refusal, with their grants standing the whole time. The removal of an + * app is the same shape one level up. Reading the rest is the answer that finishes the job, and + * refusing is what is left for the cases where reading the rest is not possible — which is what the + * three refusals below are, and why none of them can be reached by a caller carrying a partial + * answer that reports itself complete. + * + * THE FIRST REQUEST CARRIES NO CURSOR FIELD AT ALL rather than an undefined one, which is what + * {@link everyRowOf}'s callers spread for: a `cursor: undefined` would reach the vendor's `parse` + * as a key, and an explicit undefined is not something this file needs to make the SDK have an + * opinion about. + */ +async function everyRowOf( + listing: Listing, + page: (cursor: string | undefined) => Promise, +): Promise { + const rows: unknown[] = []; + const followed = new Set(); + let cursor: string | undefined; + + for (;;) { + const answered: unknown = await page(cursor); + const items = itemsOf(answered); + if (items === null) { + throw new BrokerRefusalError( + `Composio answered ${listing.noun} with ${sent(answered)} where a list of them belongs, so ${listing.consequence}. ${VENDOR_SHAPE_REMEDY}`, + ); + } + rows.push(...items); + + /* + * ABSENT AND NULL BOTH MEAN THE END, and they are the two the vendor actually sends: the + * auth-config schema spells the field nullable and the connected-account one spells it nullish, + * and a transformer that met no `next_cursor` writes null. Anything else is a field this + * deployment cannot follow, and reading it as the end would be the exact mistake this function + * exists to prevent — one page treated as the whole answer, by a reader that had been told + * otherwise in a way it did not understand. + */ + const next = hasFields(answered) ? answered.nextCursor : undefined; + if (next === undefined || next === null) return rows; + + const follow = textOf(next); + if (follow === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(next)} where the cursor to the next page of ${listing.noun} belongs, so ${listing.consequence}: there are more of them than arrived and no cursor this deployment can ask for the rest with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * A CURSOR ALREADY FOLLOWED IS A LOOP AND NOT A PAGE. Nothing here can tell a vendor bug from a + * proxy answering from a cache, and both end the same way: the same rows for ever. Refusing on + * the second sight of one is what keeps a person's disconnect a request that returns. + */ + if (followed.has(follow)) { + throw new BrokerRefusalError( + `Composio answered the same page of ${listing.noun} twice, so ${listing.consequence}: following its cursor did not advance, so the rest of them cannot be reached. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (followed.size >= PAGE_CEILING) { + throw new BrokerRefusalError( + `Composio has answered ${PAGE_CEILING} pages of ${listing.noun} at ${LISTING_LIMIT} rows each and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, + ); + } + followed.add(follow); + cursor = follow; + } +} + /** * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. * @@ -845,6 +969,16 @@ export type ComposioVendor = { * it rather than mint a link that cannot work. */ showDisabled: true; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `AuthConfigListParamsSchema` names it and `AuthConfigs.list` forwards it + * (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:124-131`, + * `src/models/AuthConfigs.ts:95`), which is the fact that decides how a truncated answer is + * handled here: the catalogue refuses a full page because it has no way to ask for the next + * one, and this listing does. See {@link everyRowOf}. + */ + cursor?: string; }): Promise<{ items: VendorAuthConfig[]; /** @@ -855,9 +989,9 @@ export type ComposioVendor = { * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer * (`src/utils/transformers/authConfigs.ts:72-80`). Omitting it here did not make the * truncation go away; it made it unobservable, because a field a projection does not name is - * a field no caller and no test of a caller can ask about. Declared before anything reads it - * for exactly that reason: the listings below take one page at {@link LISTING_LIMIT} and - * treat it as the whole answer, and the guard that ends that has to have something to read. + * a field no caller and no test of a caller can ask about. {@link everyRowOf} reads it, and + * follows it until the vendor stops offering one — so a listing at {@link LISTING_LIMIT} is + * no longer a fragment this file can mistake for the whole answer. */ nextCursor?: string | null; }>; @@ -907,6 +1041,16 @@ export type ComposioVendor = { */ accountType: "ALL"; limit: number; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `ConnectedAccountListParamsSchema` names it and `ConnectedAccounts.list` forwards it + * (`@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:259-266`, + * `src/models/ConnectedAccounts.ts:118`). This is the listing the paging matters most for: + * {@link ComposioBroker.revoke} answers `true` for "this person's access has ended", and one + * page of their accounts is not the set of their accounts. See {@link everyRowOf}. + */ + cursor?: string; }): Promise<{ /** * The id is the only field read off an account, and it is read off the wire unchecked. @@ -925,8 +1069,8 @@ export type ComposioVendor = { * (`src/types/connectedAccounts.types.ts:297-303`) and the transformer sets it on every * answer (`src/utils/transformers/connectedAccounts.ts:109-117`). It matters more here than * anywhere else in this file: {@link ComposioBroker.revoke} answers `true` for "this - * person's access has ended", and it has only ever seen one page of the accounts it would - * have to end. + * person's access has ended", and it used to have seen only one page of the accounts it + * would have to end. {@link everyRowOf} follows it until there is none left. */ nextCursor?: string | null; }>; @@ -1139,34 +1283,38 @@ export function buildComposioClient( toolkit: string, statuses: VendorAccountStatus[], ): Promise => { - const answered: unknown = await askVendor( + /* + * EVERY PAGE, AND THE SHAPE OF EACH ONE CHECKED HERE RATHER THAN AT EITHER CALLER, so that the + * two questions cannot drift on either. `isConnected` answers a boolean and `revoke` deletes by + * id, and both a truncated listing and an unreadable one are the wrong answer to both for the + * same reason — `false` claims somebody has no account for an app when nobody looked at all of + * them, and a delete without an id claims a withdrawal that never went out. The ids are all + * either caller takes, so this hands back ids and nothing else. + */ + const rows = await everyRowOf( { - outcome: `this person's ${toolkit} accounts were not read`, - app: toolkit, + noun: `this person's ${toolkit} accounts`, + consequence: + "neither whether they are connected nor what there is to withdraw could be read", }, - () => - vendor.connectedAccounts.list({ - userIds: [userId], - toolkitSlugs: [toolkit], - statuses, - accountType: "ALL", - limit: LISTING_LIMIT, - }), + (cursor) => + askVendor( + { + outcome: `this person's ${toolkit} accounts were not read`, + app: toolkit, + }, + () => + vendor.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses, + accountType: "ALL", + limit: LISTING_LIMIT, + ...(cursor === undefined ? {} : { cursor }), + }), + ), ); - /* - * CHECKED HERE RATHER THAN AT EITHER CALLER, so that the two questions cannot drift on this. - * `isConnected` answers a boolean and `revoke` deletes by id, and an unreadable listing is the - * wrong answer to both of them for the same reason — `false` claims somebody has no account for - * an app when nobody looked, and a delete without an id claims a withdrawal that never went - * out. The ids are all either caller takes, so the listing hands back ids and nothing else. - */ - const items = itemsOf(answered); - if (items === null) { - throw new BrokerRefusalError( - `Composio answered this person's ${toolkit} accounts with ${sent(answered)} where a list of them belongs, so neither whether they are connected nor what there is to withdraw could be read. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return items.map((row, position) => accountIdOf(row, position, toolkit)); + return rows.map((row, position) => accountIdOf(row, position, toolkit)); }; /** @@ -1186,17 +1334,33 @@ export function buildComposioClient( const configsMadeHere = async ( toolkit: string, ): Promise => { - const answered: unknown = await askVendor( + /* + * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two + * callers below are wrong in the two opposite directions {@link madeHere} describes: + * `ensureAuthConfig` finds none and creates the second config it exists to prevent, and + * `deleteAuthConfig` leaves one standing, reports a clean removal, and lets `removeServer` + * delete the app's row over the top of a live grant. + */ + const rows = await everyRowOf( { - outcome: `this deployment's authorization configs for ${toolkit} were not read`, - app: toolkit, + noun: `this deployment's authorization configs for ${toolkit}`, + consequence: + "whether one exists is not something this deployment can tell", }, - () => - vendor.authConfigs.list({ - toolkit, - limit: LISTING_LIMIT, - showDisabled: true, - }), + (cursor) => + askVendor( + { + outcome: `this deployment's authorization configs for ${toolkit} were not read`, + app: toolkit, + }, + () => + vendor.authConfigs.list({ + toolkit, + limit: LISTING_LIMIT, + showDisabled: true, + ...(cursor === undefined ? {} : { cursor }), + }), + ), ); /* * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The @@ -1205,13 +1369,7 @@ export function buildComposioClient( * costs. Every row therefore passes {@link configOf} first, including the ones that turn out to * belong to an operator's own dashboard work. */ - const items = itemsOf(answered); - if (items === null) { - throw new BrokerRefusalError( - `Composio answered this deployment's authorization configs for ${toolkit} with ${sent(answered)} where a list of them belongs, so whether one exists is not something this deployment can tell. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return items + return rows .map((row, position) => configOf(row, position, toolkit)) .filter(madeHere) .sort((one, other) => one.id.localeCompare(other.id)); @@ -1404,7 +1562,16 @@ export function buildComposioClient( * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is * refused before anything runs, rather than discovered in an audit row afterwards. */ - const resolved = await askVendor( + /* + * READ OUT OF `unknown`, LIKE EVERY LISTING, AND FOR THE REASON THE LISTINGS GIVE. This is + * one of the two answers in this file that is a single object rather than a page of them, + * and that is the whole of why it went unchecked for as long as it did: a row type widened to + * what the wire can send makes an unchecked read fail to build, and an object read straight + * off an `await` has a declared type that looks settled. It is not — `transformToolCases`' + * throwing parse is an argument about the SDK's code rather than about this deployment's, and + * the same one {@link actionOf} declines to rest on. + */ + const resolved: unknown = await askVendor( { outcome: `${call.slug} was not resolved and nothing was run`, app: call.toolkit, @@ -1414,7 +1581,37 @@ export function buildComposioClient( version: call.version, }), ); - const ran = resolved.toolkit?.slug; + if (!hasFields(resolved)) { + throw new Error( + `Composio answered ${sent(resolved)} where the ${call.slug} action belongs, so nothing was run: this deployment cannot show that the call it was about to make is for ${call.toolkit} rather than for some other app. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * AN UNREADABLE APP IS NOT THE SAME FACT AS NO APP, AND THEIR REMEDIES DIFFER. The mismatch + * refusal below ends by telling an administrator to refresh this app's tools, which is right + * for a slug recorded against a url that has since changed and useless for an SDK that has + * begun answering a different shape. So a `toolkit` that is present and not an object, or + * whose slug is not a usable name, is refused as what it is rather than folded into "no app + * at all" — where it would arrive wearing a remedy that cannot work. + */ + const answeredApp = resolved.toolkit; + let ran: string | undefined; + if (answeredApp !== undefined) { + if (!hasFields(answeredApp)) { + throw new Error( + `Composio sent ${sent(answeredApp)} where the app ${call.slug} belongs to should be, so nothing was run: this deployment cannot show that the call is for ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const named = textOf(answeredApp.slug); + if (named === null) { + throw new Error( + `Composio sent ${sent(answeredApp.slug)} where the slug of the app ${call.slug} belongs to should be, so nothing was run: a name this deployment cannot read is not one it can compare with ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, + ); + } + ran = named; + } + if (ran !== call.toolkit) { /* * REFUSED RATHER THAN FORWARDED, and both apps are named. @@ -1696,7 +1893,7 @@ export function buildComposioClient( * calls it a "magic function" for exactly that — which is the opposite of what this * deployment wants. */ - const request = await askVendor( + const request: unknown = await askVendor( { outcome: `this person's connection to ${toolkit} was not begun`, app: toolkit, @@ -1706,7 +1903,34 @@ export function buildComposioClient( callbackUrl: returnUrl, }), ); + /* + * THE OTHER SINGLE OBJECT, AND THE ONE WITH A PERSON WAITING ON IT. Read out of `unknown` for + * the reason the resolve in `execute` is: nothing about this answer is validated any harder + * than a listing row, and a field read off a null answer is a `TypeError` that reaches + * somebody who has just pressed Connect as though Composio were down. + */ + if (!hasFields(request)) { + throw new BrokerRefusalError( + `Composio answered ${sent(request)} where the connection it was asked to begin to ${toolkit} belongs, so there is nothing to send this person to — and whether anything was begun at Composio is not something this deployment can tell from that. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const redirectUrl = request.redirectUrl; + /* + * PRESENT AND NOT A URL IS THE SHAPE THE ABSENCE GUARD BELOW CANNOT SEE. `!redirectUrl` is + * false for an object, a number and a list alike, so each of those would be returned as the + * `redirectUrl: string` this method promises and put in a `Location` header — a page nobody + * can visit, handed to a person as the consent screen they were sent to. + */ + if ( + redirectUrl !== undefined && + redirectUrl !== null && + typeof redirectUrl !== "string" + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(redirectUrl)} where the page to send this person to for ${toolkit} belongs, so nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } if (!redirectUrl) { /* * The SDK spells `redirectUrl` nullable because not every auth scheme has one — an API-key diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 76cc1a557..0645d005c 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -149,6 +149,23 @@ async function failureOf(work: Promise): Promise { return raised as Error; } +/** + * What a failure must never read like: the name of a method that was not there. + * + * AT MODULE SCOPE BECAUSE IT IS THE SAME QUESTION EVERYWHERE, and it is asked of every refusal this + * file added after the shape sweep: a guard that is missing does not answer politely, it reads a + * field off `undefined` and hands an administrator a sentence naming a vendor method. A message + * matching this is a crash wearing a refusal's place in the code. + * + * "IS NOT AN OBJECT" IS ANCHORED TO `undefined` AND `null` RATHER THAN LEFT BARE, because the + * adapter's own sentence for a malformed input schema says "a thing that is not an object cannot be + * shown as one" — the correct refusal, flagged as a crash by a pattern looking for a fragment of + * one. The runtime's phrasings are "undefined is not an object (evaluating ...)" and the same with + * null, so the anchor keeps every crash this caught and stops it catching an authored sentence. + */ +const A_CRASH = + /is not a function|(?:undefined|null) is not an object|is not iterable|cannot read propert/i; + describe("the page size this file is written against", () => { test("the module's ceiling is still the number every assertion here spells out", () => { // The one place the imported constant is read. Changing `LISTING_LIMIT` reddens exactly this @@ -1832,10 +1849,6 @@ describe("a vendor listing that is not the shape it is declared to be", () => { }, ]; - /** What a failure must never read like: the name of a method that was not there. */ - const A_CRASH = - /is not a function|is not an object|is not iterable|undefined is not|cannot read propert/i; - for (const listing of MALFORMED_LISTINGS) { for (const { shape, answer } of listing.answers) { test(`${listing.listing} answered with ${shape} is refused in a sentence`, async () => { @@ -1860,3 +1873,613 @@ describe("a vendor listing that is not the shape it is declared to be", () => { } } }); + +/** + * A listing that came back at the page ceiling, and what a caller may conclude from it. + * + * THE TWO LISTINGS HERE CARRY A CURSOR AND THE CATALOGUE DOES NOT, which is why they are answered + * differently from the fragment refusal next door. `AuthConfigListParamsSchema` and + * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131` and `src/types/connectedAccounts.types.ts:259-266`), + * both models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`), + * and both transformers fill `nextCursor` in from the response + * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). The toolkit listing has + * none of that — its response is a bare array with the cursor dropped before any caller sees it — + * so there the only honest answer is to refuse, and here it is to go and read the rest. + * + * WHAT IS ACTUALLY BEING PROTECTED IS `revoke`'s `true`. It means "this person's access has ended", + * and `store.ts` writes that into the audit trail and then deletes the one row naming which app they + * had connected. One page of their accounts is not the set of their accounts, so the assertions + * below are on WHAT WENT OUT — every account, from every page — rather than on what came back: an + * implementation that read one page answers `true` just as confidently. + */ +describe("a listing that arrived with a cursor still outstanding", () => { + /** The statuses the revoke asks about, spelled once for the two queries asserted below. */ + const REVOCABLE_STATUSES = [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", + ]; + + test("every page of this person's accounts is read, and every account on them withdrawn", async () => { + const asked: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } + : { items: [{ id: "ca_2" }], nextCursor: null }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + + // `ca_2` is the whole test. It is on the second page, so a reader that stopped at the first + // deletes `ca_1`, answers `true`, and leaves a live grant behind an audit row saying this + // person's access ended. + expect(deleted).toEqual(["ca_1", "ca_2"]); + // The first request carries no cursor at all, and the second carries the vendor's own word for + // where it left off — which is the half a reader cannot infer from the rows that came back. + expect(asked).toEqual([ + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: REVOCABLE_STATUSES, + accountType: "ALL", + limit: WHOLE_LISTING, + }, + { + userIds: ["user_1"], + toolkitSlugs: ["gmail"], + statuses: REVOCABLE_STATUSES, + accountType: "ALL", + limit: WHOLE_LISTING, + cursor: "page_2", + }, + ]); + }); + + test("an account on a later page still decides whether somebody is connected", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [], nextCursor: "page_2" } + : { items: [{ id: "ca_2" }], nextCursor: null }, + }, + }), + ); + + // A first page that is empty with a cursor still outstanding is exactly the shape that reads as + // "this person has no account", which then tells them to connect an app they already hold — and + // tells the gate in `./access` that they may not act through one they can. + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + }); + + test("every page of this app's configs is read, so none is left standing", async () => { + const asked: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [OURS], nextCursor: "page_2" } + : { items: [OURS_SPARE], nextCursor: null }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // The spare from a lost enable race is on page two. Left behind, it is a config the removal was + // supposed to drop, holding every grant made against it, with the app's row deleted after this + // returns and nothing left in this deployment pointing at it. + expect(deleted).toEqual(["ac_ours", "ac_ours_spare"]); + expect(asked).toEqual([ + { toolkit: "linear", limit: WHOLE_LISTING, showDisabled: true }, + { + toolkit: "linear", + limit: WHOLE_LISTING, + showDisabled: true, + cursor: "page_2", + }, + ]); + }); + + test("a config of ours on a later page is the one a connection is begun against", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [BY_HAND], nextCursor: "page_2" } + : { items: [OURS], nextCursor: null }, + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + // Reading one page here answers "this deployment has no config for linear" and sends an + // administrator to remove and re-add an app whose config is sitting on page two. + expect(linked).toEqual([ + ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + ]); + }); + + test("a cursor that is not a cursor is refused rather than read as the end of the list", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => { + calls += 1; + return { items: [{ id: "ca_1" }], nextCursor: 42 }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Read as absent, a cursor this deployment cannot follow is a truncated page wearing the + // clothes of a complete answer — which is the one thing this guard exists to make impossible. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(deleted).toEqual([]); + + /* + * THE REFUSAL HAS TO BE THIS ONE AND NOT THE LOOP GUARD NEXT DOOR, which is what these two + * assertions are for and what a mutation run proved they had to be. Coerce the unreadable + * cursor to "" and the paging sends a SECOND request carrying it, gets the same page back, and + * refuses — with a sentence saying Composio answered the same page twice, which is a complaint + * about the vendor for something this deployment did. One request went out, and the sentence + * names what arrived where a cursor belongs. + */ + expect(calls).toBe(1); + expect(refusal.message).toMatch(/where the cursor to the next page/); + }); + + test("a cursor that never advances is refused rather than followed for ever", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => { + calls += 1; + if (calls > 20) throw new Error("The paging did not terminate."); + return { items: [{ id: "ca_1" }], nextCursor: "page_2" }; + }, + // The delete ANSWERS rather than refusing, which is what makes this test able to fail: a + // reader that follows no cursor withdraws `ca_1`, reports a completed disconnection, and + // would satisfy any assertion that only asked for a refusal of some kind. + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // A vendor answering the same cursor for ever is a hung request rather than a long one, and the + // caller here is a person waiting on a page they pressed disconnect from. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(calls).toBeLessThanOrEqual(3); + expect(deleted).toEqual([]); + }); + + test("a cursor that advances for ever is stopped at a ceiling, and stopping is a refusal", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => { + calls += 1; + // A cursor that is different every time defeats the same-page guard above, so this is + // the one shape only the ceiling catches. The throw is the test's own stop: without a + // ceiling in the adapter this listing has no end at all. + if (calls > 200) throw new Error("The paging did not terminate."); + return { items: [], nextCursor: `page_${calls}` }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Stopping is the easy half; the half that matters is that stopping is not answering. A ceiling + // that returned the rows it had would be the page ceiling again, further out and harder to see. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/pages/); + expect(calls).toBeLessThan(200); + expect(deleted).toEqual([]); + }); +}); + +/** + * The two vendor answers that are single objects, and were therefore never checked. + * + * NEITHER PRODUCED A COMPILE ERROR AND NEITHER HAD A RED TEST, which is the same fact twice over. + * Every other read in the adapter goes through a listing whose element type was widened to what the + * wire can send, so an unchecked read of one fails to build; a single object read straight off an + * `await` has a declared type that looks settled. It is not: `transform()` is warn-only on both of + * these paths as well, so the declaration says what Composio MEANS to send here exactly as it does + * everywhere else in this file. + */ +describe("a vendor answer that is one object rather than a listing", () => { + /** The call `execute` is made with in this section, which is a mismatch test's whole setup. */ + const GMAIL_CALL = { + slug: "GMAIL_FETCH_EMAILS", + toolkit: "gmail", + userId: "user_1", + version: "20260903_00", + }; + + test("an unreadable resolve is refused as one, and nothing is run", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => null, + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {} }; + }, + }, + }), + ); + + const refusal = await failureOf(actions.execute(GMAIL_CALL, {})); + + // `resolved.toolkit?.slug` off a null answer is a `TypeError` wearing the vendor's name, and + // `./composio` puts whatever comes out of here into an app's `lastError` for an administrator + // to read. + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/GMAIL_FETCH_EMAILS/); + expect(ran).toEqual([]); + }); + + test("an app the vendor sent unreadably is not reported as no app at all", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: "gmail", + }), + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {} }; + }, + }, + }), + ); + + const refusal = await failureOf(actions.execute(GMAIL_CALL, {})); + + /* + * THE TWO FACTS ARE NOT THE SAME AND THEIR REMEDIES ARE NOT EITHER. "Composio resolves that + * action to no app at all" is a statement about the action, and the sentence carrying it tells + * an administrator to refresh the app's tools — right for a slug recorded against a url that + * has since changed, and useless for an SDK that has begun answering a different shape. The + * toolkit here is PRESENT: it arrived as a bare string where `{ slug }` belongs. + */ + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(/no app at all/); + expect(ran).toEqual([]); + }); + + test("a link answered with something other than an object is refused in a sentence", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { link: async () => null }, + }), + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + // A route answers this to a person who has just pressed Connect, so it has to be a refusal this + // deployment authored rather than a `TypeError` that reads to them as Composio being down. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/linear/); + }); + + test("a redirect that is not a url is refused rather than handed to a browser", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + link: async () => ({ + redirectUrl: { href: "https://backend.composio.dev/s/a-link" }, + }), + }, + }), + ); + + /* + * PRESENT, TRUTHY AND NOT A URL, which is the one shape the `if (!redirectUrl)` guard beside it + * cannot see. What is on the other side of that return is a `Location` header and a person's + * browser, so `[object Object]` would be a page nobody can visit, reported as the consent + * screen they were sent to. + */ + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + }); +}); + +/** + * The field guards, held to the behaviour each was written for. + * + * EVERY TEST HERE COVERS A GUARD THAT WAS ALREADY IN THE FILE AND HAD NOTHING HOLDING IT. That is + * worse than an untested new behaviour rather than better: a guard nothing reddens for is read by + * the next person as ceremony over a field the SDK's own types already promise, and deleting it + * leaves a green suite. Each one below was therefore checked by removing the guard it covers and + * watching this test fail. + */ +describe("what a malformed field of a row actually costs", () => { + const CATALOGUE_ROWS: { fault: string; row: unknown; names: RegExp }[] = [ + { + fault: "a name that arrived as null", + // `?? ""` here is an app in an administrator's picker with nothing written on it, and `gmail` + // is a slug rather than a title. + row: { slug: "gmail", name: null, meta: {} }, + names: /name/, + }, + { + fault: "a description that is not text", + row: { slug: "gmail", name: "Gmail", meta: { description: 12 } }, + names: /description/, + }, + { + fault: "a category with no name", + // The categories are the words a person chooses an app by, so a blank one is a filter nobody + // can use rather than a cosmetic gap. + row: { + slug: "gmail", + name: "Gmail", + meta: { categories: [{ slug: "productivity" }] }, + }, + names: /categor/, + }, + { + fault: "an action count that arrived as a string", + // `Number("63")` is the defect this whole sweep is about: a vendor change turned into a + // plausible figure, shown BEFORE anybody enables an app, that nobody would think to question. + row: { slug: "gmail", name: "Gmail", meta: { toolsCount: "63" } }, + names: /count/, + }, + ]; + + for (const { fault, row, names } of CATALOGUE_ROWS) { + test(`a catalogue row with ${fault} stops the directory`, async () => { + const { broker } = buildComposioClient( + fakeVendor({ toolkits: { get: async () => [row] } }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(names); + }); + } + + const ACTION_ROWS: { fault: string; row: unknown; names: RegExp }[] = [ + { + fault: "a description that is not text", + // What a model reads to decide whether to call the action at all. + row: { slug: "GMAIL_FETCH_EMAILS", description: 12 }, + names: /description/, + }, + { + fault: "input parameters that are not an object", + /* + * THE ONE THAT MATTERS MOST. This value goes in front of a model as Composio's own JSON + * Schema for the action, and a string of JSON is not a schema. What a model does with a + * parameter list it cannot read is invent one, and the call that follows is made with + * arguments nobody wrote. + */ + row: { slug: "GMAIL_FETCH_EMAILS", inputParameters: '{"type":"object"}' }, + names: /schema/, + }, + { + fault: "tags that are not all labels", + // The tags are the only thing that says whether an action reads or writes. + row: { slug: "GMAIL_FETCH_EMAILS", tags: ["readOnlyHint", 7] }, + names: /tags/, + }, + { + fault: "a version that is not text", + // The version travels with every call made to the action. + row: { slug: "GMAIL_FETCH_EMAILS", version: 20260903 }, + names: /version/, + }, + ]; + + for (const { fault, row, names } of ACTION_ROWS) { + test(`an action row with ${fault} stops the listing`, async () => { + const { actions } = buildComposioClient( + fakeVendor({ tools: { getRawComposioTools: async () => [row] } }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(names); + expect(refusal.message).toMatch(/GMAIL_FETCH_EMAILS/); + }); + } + + test("a nameless config stops the removal rather than being left standing", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [OURS, { id: "ac_nameless", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * WITHOUT THE GUARD THIS IS A REPORTED SUCCESS. A name read as "" fails the suffix test, so the + * row is sorted into somebody else's dashboard work and left standing — and `deleteAuthConfig` + * returns quietly, after which `removeServer` deletes the app's row. The config and every grant + * made against it outlive the removal with nothing in this deployment naming them. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/name/); + // Not even the row that WAS readable: a listing this deployment cannot sort is not a listing to + // act on half of, because a half-done removal reported as done is the state being avoided. + expect(deleted).toEqual([]); + }); + + test("a config with no id stops the removal, and no delete is sent", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ name: "Linear (OpenBot)", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * A DELETE WITHOUT AN ID IS A REQUEST COMPOSIO IS FREE TO READ AS ANYTHING, answered however it + * likes, after which this deployment records that the app was withdrawn. The assertion is on + * what went out rather than on what came back for exactly that reason. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/id/); + expect(deleted).toEqual([]); + }); + + const UNSETTLED: { fault: string; status: unknown }[] = [ + { fault: "a status this deployment has never heard of", status: "PENDING" }, + { fault: "no status at all", status: undefined }, + ]; + + for (const { fault, status } of UNSETTLED) { + test(`a config of ours with ${fault} refuses in its own words`, async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_ours", name: "Linear (OpenBot)", status }], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + /* + * NEITHER OF THE OTHER TWO REMEDIES, which is the whole of what this asserts. Telling an + * operator the config is disabled sends them to a dashboard to enable something that may + * already be enabled, and leaves them with a page insisting on a fact they can see is false; + * telling them there is no config sends them to remove and re-add an app whose config is + * sitting right there. The state is UNKNOWN, and only a sentence saying so is honest. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + // And still a refusal: consent spent against a config this deployment cannot show is enabled + // attaches nothing, and cannot be spent again without sending the person round a second time. + expect(linked).toEqual([]); + }); + } +}); From f2bf71aba5243d9b9e33dc7f2fc011c7f8361aa5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 19:08:33 +0200 Subject: [PATCH 141/215] Fail when the vendor refuses the withdrawal, and when the sweep reaches past this run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several of the properties this file names were not held by it. Each repair below was proved by mutating the behaviour it claims and confirming the suite reddened. THE BROKER STUB NEVER REFUSED ANYTHING, so "the row is deleted only after the vendor confirms the withdrawal" was never asserted: wrapping either revoke in a `try`/`catch` left the suite green. `vendorRefuses` is a seam of its own beside `vendorFinds`, because an answer of "no account" is a fact the vendor asserts and a throw asserts nothing. Two tests use it, one per act, and each asks that the act failed, that the connection still stands and still passes the gate, that no `mcp.account_disconnected` row claims an account ended — and, for `removeServer`, that the app row and the auth config survive too. The app row is the load-bearing extra: the toolkit is readable only off its url, so an app deleted with its connections standing is live grants nothing here can name. THE PER-PERSON REVOCATION FLAG WAS PINNED ON ONE PATH. `removeServer` had a two-person fixture; `retireConnectionsFor` had none, so hardcoding the field `true` there survived, as did deleting that path's `order by`. An offboarding is one person by definition, so the fixture is one person with TWO apps and a vendor that finds one of them. The rows are inserted in the reverse of the expected order, so an unordered read cannot agree with the assertion by accident. REMOVING AN APP NOBODY CONNECTED had no test at all. It now asserts the whole list of asks: no revoke, because there is nobody to name, and the auth config dropped all the same, because it belongs to the app rather than to an account and this is the only act that takes it. THE ANONYMOUS-ROW SWEEP asserted only that this run's rows were gone, which the wider `user_id = ''` delete it exists to forbid satisfies equally well. It now also holds a row that the correct sweep must LEAVE BEHIND, at an app outside this run's own, which is the only thing that tells the two deletes apart. That row is taken back by `cleanForeign` rather than by the sweep under test. THE AUDIT ROWS CANNOT BE SWEPT, and the docblock now says so rather than leaving it looking like an oversight. `audit_events` is append-only by trigger, and its one exemption — a session setting `openbot.audit_retention_days` — still refuses any row younger than that many days. These rows are seconds old when a cleanup would run, so no setting makes them deletable; a delete here would be a statement that always throws. What the rows are is bounded rather than unbounded: every one is keyed on an id carrying the run's suffix, and the retention sweep takes them on its ordinary schedule. And three comments that were wrong or redundant: the file described two acts that can end a brokered connection when `disconnectBrokered` is a third; a `not.toBe("deployment")` restated more weakly what the `toMatchObject` three lines above already asserted; and the vault stub claimed a missing method would fail here, in a directory `tsc` never reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connections.test.ts | 347 ++++++++++++++++++++-- 1 file changed, 314 insertions(+), 33 deletions(-) diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 8af4474b8..fc65ad488 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -29,8 +29,13 @@ import { TEST_POOL } from "./support/database"; * whole of the permission, it points at no vault secret, and it references neither `users` nor * `mcp_servers`. Nothing therefore cascades it away, which is deliberate — the row has to outlive * the person so offboarding can still find it — and it means an explicit retirement is the ONLY - * thing that can ever end one. This file is about the two acts that must perform that retirement - * and about the trail they leave. + * thing that can ever end one. Three store methods perform that retirement: `retireConnectionsFor` + * when somebody is offboarded, `removeServer` when the app itself is taken away, and + * `disconnectBrokered` when a person ends their own account. This file is about the two an + * administrator performs on somebody else's behalf, and about the trail those two leave; + * `disconnectBrokered` has its own coverage in `plugin-store.integration.test.ts`, which is why the + * two here are described throughout as the two ACTS AN ADMINISTRATOR PERFORMS and never as all the + * ways a connection can end. * * WHY THIS FILE OWNS ITS IDS OUTRIGHT, AND SO NEEDS NO REFUSE-TO-RUN GUARD. * `plugin-store.integration.test.ts` inserts at `gmail`, `notion` and `bot_helper` and refuses to @@ -71,6 +76,26 @@ const leaverId = `user_leaver_${suite}`; * spells them the same — which is exactly why a defect that only shows when they differ survived. */ const renamedId = `renamed-${suite}`; +/** + * A SECOND app the same person connected, which is what makes an offboarding's answer per-app. + * + * Spelled as an extension of {@link toolkit} rather than as an independent name, so that `toolkit` + * sorts before it under every collation a database might be running: one string is a strict prefix + * of the other, and no locale reorders that pair. The offboarding path reads its apps + * `order by toolkit`, and an assertion about that order is worth nothing if the order it expects is + * itself a guess about the server's locale. + */ +const secondToolkit = `${toolkit}-more`; +/** Every app this run owns, which is the scope of every read and every delete below. */ +const ownedToolkits = [toolkit, secondToolkit]; +/** + * An app this file does NOT own, standing in for another run's fixture — or another file's. + * + * It carries this run's suffix so it cannot collide with a real row, and it is deliberately absent + * from {@link ownedToolkits} so {@link clean} cannot reach it. Its whole purpose is to be the row + * that a sweep keyed on `user_id` alone would take by mistake. + */ +const foreignToolkit = `foreign-${suite}`; const admin = "admin@openbot.local"; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -84,10 +109,12 @@ const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; * any of these means this file has started exercising something it does not claim to, and a silent * stub would hide that. * - * Typed as the interface rather than left to inference, so a method added to the vault fails here - * instead of at the assignment further down. This file is not covered by `tsc` today — `tests` is - * outside `server/tsconfig.json`'s `include` — which is exactly why the shape is stated rather than - * assumed. + * Typed as the interface rather than left to inference, so the shape being stood in for is stated + * where a reader meets it instead of being inferred from the methods below. That annotation is + * documentation TODAY AND NOT A CHECK: `tests` is outside `server/tsconfig.json`'s `include`, so + * `tsc` never reads this file and a method added to the vault goes unremarked here — nothing in + * this directory would fail, and neither would the assignment further down. It is written anyway so + * that the day that directory is type-checked, this is already right. */ const credentialsStub: CredentialSecretReader & CredentialStore = { readSecret: async () => { @@ -119,6 +146,23 @@ const credentialsStub: CredentialSecretReader & CredentialStore = { * Recorded ALONGSIDE the real insert rather than instead of it: the payloads are what these tests * assert about, and a store whose audit insert never touched the database would not be exercising * the one it has. + * + * AND THOSE ROWS OUTLIVE THE RUN, WHICH {@link clean} CANNOT CHANGE. Every other table this file + * touches is swept on the way out; `audit_events` is not, and the omission is the database's rule + * rather than an oversight here. The trail is append-only, enforced by a trigger rather than by the + * application (`0007_audit_retention_window.sql`): a plain `delete` raises "Audit events are + * append-only", and the one exemption — a session that sets `openbot.audit_retention_days` to a + * positive whole number — still refuses any row younger than that many days. The rows this file + * writes are seconds old at the moment it would sweep them, so NO setting makes them deletable; + * `3650` is refused for the same reason `1` is. A cleanup here would be a statement that always + * throws. + * + * What that leaves is bounded rather than unbounded. Every row this file writes is keyed on an id + * carrying this run's suffix — `targetId` is {@link toolkit}, {@link secondToolkit}, + * {@link renamedId} or {@link ref} on every one of them — so they are findable, they belong to no + * other run, and the retention sweep removes them on its ordinary schedule once they age past the + * deployment's window. That is the same treatment every audit row in the product gets, and the + * guarantee that forbids the shortcut is the one the product sells. */ const events: Parameters["insert"]>[0][] = []; @@ -133,15 +177,15 @@ const auditStore = { /** * Every connection row THIS RUN owns, as `/`, in a fixed order. * - * SCOPED TO THE APP AND ORDERED, both load-bearing. {@link toolkit} carries this run's suffix, so - * this reads nothing another run inserted — which matters most for the anonymous actor, whose half - * of the key names nobody and is therefore the one pair another run legitimately holds too. A read - * filtered on the person alone would take in every app's anonymous row at once, and a run that died - * before its cleanup would leave one standing that no cleanup here can reach: these tests run - * against the shared development database, so that row would redden this file for everybody until - * somebody edited the database by hand. The ordering is the same argument one step down — Postgres - * promises none without one, so an unordered read of two rows is compared against whichever order - * the plan happened to produce. + * SCOPED TO THIS RUN'S APPS AND ORDERED, both load-bearing. Every name in {@link ownedToolkits} + * carries this run's suffix, so this reads nothing another run inserted — which matters most for + * the anonymous actor, whose half of the key names nobody and is therefore the one pair another run + * legitimately holds too. A read filtered on the person alone would take in every app's anonymous + * row at once, and a run that died before its cleanup would leave one standing that no cleanup here + * can reach: these tests run against the shared development database, so that row would redden this + * file for everybody until somebody edited the database by hand. The ordering is the same argument + * one step down — Postgres promises none without one, so an unordered read of several rows is + * compared against whichever order the plan happened to produce. */ async function connectionsHeld(): Promise { const rows = await database @@ -150,8 +194,8 @@ async function connectionsHeld(): Promise { userId: composioConnections.userId, }) .from(composioConnections) - .where(eq(composioConnections.toolkit, toolkit)) - .orderBy(asc(composioConnections.userId)); + .where(inArray(composioConnections.toolkit, ownedToolkits)) + .orderBy(asc(composioConnections.toolkit), asc(composioConnections.userId)); return rows.map((row) => `${row.toolkit}/${row.userId}`); } @@ -203,6 +247,18 @@ function asksMade(): string[] { let vendorFinds: (request: { userId: string; toolkit: string }) => boolean = () => true; +/** + * Whether the vendor REFUSES TO ANSWER AT ALL, which is a different event from answering "none". + * + * `false` from {@link ComposioBroker.revoke} is a fact the vendor asserts — it looked and there was + * no account — and a retirement may finish on it. A throw asserts nothing: the account may be alive + * and untouched. The two must therefore end the act differently, and a seam that could only vary + * the boolean could never say so. Separate from {@link vendorFinds} for exactly that reason: one + * knob spelling both would read as though a refusal were a shade of "no". + */ +let vendorRefuses: (request: { userId: string; toolkit: string }) => boolean = + () => false; + const broker: ComposioBroker = { listApps: unasked("list the catalogue"), ensureAuthConfig: unasked("create an auth config"), @@ -215,6 +271,13 @@ const broker: ComposioBroker = { ask: `revoke:${request.toolkit}/${request.userId}`, held: await connectionsHeld(), }); + // Recorded before it throws, so a refusal is still an ask that was made: the assertions about a + // refused act are about what reached the vendor before it stopped, and what did not. + if (vendorRefuses(request)) { + throw new Error( + `the vendor would not withdraw ${request.toolkit}/${request.userId}`, + ); + } return vendorFinds(request); }, deleteAuthConfig: async (forToolkit) => { @@ -271,10 +334,35 @@ async function clean() { .where(inArray(mcpServers.id, [toolkit, renamedId])); await database .delete(composioConnections) - .where(eq(composioConnections.toolkit, toolkit)); + .where(inArray(composioConnections.toolkit, ownedToolkits)); await database.delete(users).where(inArray(users.id, [askerId, leaverId])); } +/** + * The stand-in for somebody else's fixture, taken back by hand. + * + * Deliberately NOT part of {@link clean}, because a test below asserts that `clean` leaves this row + * standing: folding it in would make that assertion agree with itself. Run beside `clean` from + * `beforeEach` and `afterAll` instead, so the row cannot outlive the run even if the test that + * inserts it dies partway — the same shared database that makes the row worth protecting makes a + * leaked one everybody's problem. + */ +async function cleanForeign() { + await database + .delete(composioConnections) + .where(eq(composioConnections.toolkit, foreignToolkit)); +} + +/** Which of this run's app rows the deployment still holds, so "the app survived" is an assertion. */ +async function appsHeld(): Promise { + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, [toolkit, renamedId])) + .orderBy(asc(mcpServers.id)); + return rows.map((row) => row.id); +} + /** The app's row and its one granted action. Separated from the Bot, so a re-add can reuse the Bot. */ async function addApp() { await database.insert(mcpServers).values({ @@ -313,23 +401,30 @@ async function seedApp(options: { connect?: boolean } = {}) { /** * Which of THIS RUN'S apps this deployment still believes somebody has connected. * - * Narrowed to {@link toolkit} and ordered for the reason {@link connectionsHeld} gives, which is - * the same reason and matters for the same row: the anonymous actor. `notNull` admits the empty - * string, so `(toolkit, "")` is a legal pair and every run of this file inserts one — and the only - * half of it that is this run's is the app. Asking what `""` has connected across the whole table - * therefore reads every other run's anonymous row too, including one left behind by a run that was + * Narrowed to named apps and ordered for the reason {@link connectionsHeld} gives, which is the + * same reason and matters for the same row: the anonymous actor. `notNull` admits the empty string, + * so `(toolkit, "")` is a legal pair and every run of this file inserts one — and the only half of + * it that is this run's is the app. Asking what `""` has connected across the whole table therefore + * reads every other run's anonymous row too, including one left behind by a run that was * interrupted before its cleanup; against the shared development database that row is permanent, * unreachable by the cleanup here, and reddens this file for everybody until the database is edited - * by hand. Narrowing to this run's app is what makes the assertion about this run. + * by hand. Narrowing to named apps is what makes the assertion about this run. + * + * `within` DEFAULTS TO THIS RUN'S OWN APPS and is passed explicitly only to ask about + * {@link foreignToolkit} — the one row this file holds that it deliberately does not own, and + * therefore the one it has to be able to ask about separately. */ -async function connectedToolkitsFor(userId: string): Promise { +async function connectedToolkitsFor( + userId: string, + within: string[] = ownedToolkits, +): Promise { const rows = await database .select({ toolkit: composioConnections.toolkit }) .from(composioConnections) .where( and( eq(composioConnections.userId, userId), - eq(composioConnections.toolkit, toolkit), + inArray(composioConnections.toolkit, within), ), ) .orderBy(asc(composioConnections.toolkit)); @@ -344,18 +439,23 @@ function recordedOfType(eventType: string) { // one nothing to trip over. beforeEach(async () => { await clean(); + await cleanForeign(); events.length = 0; reached.length = 0; asks.length = 0; // The vendor finding an account is the ordinary case — somebody connected, so there is a grant to // withdraw. The one test about the answer itself says otherwise for itself. vendorFinds = () => true; + // And answering at all is the ordinary case too. A vendor that will not answer is the subject of + // its own two tests and of nothing else. + vendorRefuses = () => false; }); afterEach(() => useComposioClient(null)); afterAll(async () => { await clean(); + await cleanForeign(); }); /** @@ -462,6 +562,110 @@ test("retiring the same person twice retires nothing the second time", async () expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); }); +/** + * A REFUSAL AT THE VENDOR MUST NOT BECOME A RETIREMENT HERE. + * + * CRITERION. When the broker will not withdraw the grant, `retireConnectionsFor` fails, the row + * stands, the gate still passes, and nothing is written to the trail. + * + * REASON. The row is the only thing in this deployment naming which app this person connected. A + * retirement that swallowed the refusal would delete it and report success, and what is left is the + * worst state the design admits: a live grant on a departed person's mailbox that no operation here + * can reach any more, under an administrator who has been told their access was removed. Dead and + * reachable beats live and unreachable, so the failure has to be loud and the row has to survive it. + * Repeating the act is the recovery, and repeating it is only possible while the row is there. + * + * THE GATE IS ASKED AFTERWARDS, not merely the table. "The row exists" and "the row still works" + * come apart if a retirement ever clears part of the state before failing, and it is the second + * that describes the person's access. + */ +test("an offboarding the vendor refuses leaves the connection standing", async () => { + await seedApp(); + vendorRefuses = () => true; + + await expect(store.retireConnectionsFor(askerId, admin)).rejects.toThrow( + /would not withdraw/i, + ); + + // The ask was made and the answer never came, which is the state the row has to survive. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(await connectedToolkitsFor(askerId)).toEqual([toolkit]); + // No trail row either: `mcp.account_disconnected` says an account ended, and none did. + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + + useAnsweringClient(); + await store.callTool({ ref, args: {}, botId, actorId: askerId }); + expect(reached).toEqual([actionName]); +}); + +/** + * THE OFFBOARDING TRAIL CARRIES THE VENDOR'S ANSWER PER APP, AND IN A FIXED ORDER. + * + * CRITERION. One act, two of this person's apps, the vendor finding an account for one and none for + * the other: each row's `vendorRevocationRequested` is the answer about ITS app, and both the asks + * and the rows come out in `toolkit` order. + * + * REASON. This is the same criterion `removeServer` already has a two-person fixture for, on the + * other act that ends a brokered connection — and the two paths are separate code with separate + * maps, so a fixture on one says nothing about the other. Until now this one was only ever run with + * a single connection, which a hardcoded `true` satisfies exactly as well as a passed-through + * answer; the field then reads as evidence about every row while describing none of them, which is + * what it was renamed away from. + * + * TWO APPS RATHER THAN TWO PEOPLE, because an offboarding is one person by definition. The map this + * path keeps is keyed on the app for the same reason, so the app is where a constant would show. + * + * INSERTED IN THE WRONG ORDER DELIBERATELY. The expected order is the sorted one, and a read with no + * `order by` most often hands back what was inserted — so a fixture inserted in sorted order agrees + * with an unordered read by accident and the ordering assertion proves nothing. Inserting the later + * name first is what makes the sort the only thing that could have produced the expected answer. + */ +test("an offboarding carries the vendor's answer per app, in a fixed order", async () => { + await seedApp({ connect: false }); + await database + .insert(composioConnections) + .values({ toolkit: secondToolkit, userId: askerId }); + await database + .insert(composioConnections) + .values({ toolkit, userId: askerId }); + vendorFinds = ({ toolkit: asked }) => asked === toolkit; + + expect((await store.retireConnectionsFor(askerId, admin)).retired).toBe(2); + + expect(asksMade()).toEqual([ + `revoke:${toolkit}/${askerId}`, + `revoke:${secondToolkit}/${askerId}`, + ]); + // Both asks made while both rows still stood: the apps are read off the rows, so a delete between + // the two would leave the second revoke with nothing to name. + const bothHeld = [`${toolkit}/${askerId}`, `${secondToolkit}/${askerId}`]; + expect(asks[0].held).toEqual(bothHeld); + expect(asks[1].held).toEqual(bothHeld); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + const disconnected = recordedOfType("mcp.account_disconnected"); + expect(disconnected).toHaveLength(2); + // Compared in order rather than as a set, because the order is half the criterion. Not sorted + // here either: sorting the answer before comparing it is how an ordering assertion stops being one. + expect( + disconnected + .map( + (event) => + event.payload as { + server: string; + vendorRevocationRequested: boolean; + }, + ) + .map(({ server, vendorRevocationRequested }) => ({ + server, + vendorRevocationRequested, + })), + ).toEqual([ + { server: toolkit, vendorRevocationRequested: true }, + { server: secondToolkit, vendorRevocationRequested: false }, + ]); +}); + /** * THE ANONYMOUS ACTOR OWNS NOTHING, and `notNull` does not exclude the empty string, so a row at * `(toolkit, "")` is legal. Retiring "nobody" must not be what deletes it — that would be an @@ -488,23 +692,41 @@ test("retiring nobody retires nothing and leaves the anonymous row alone", async /** * The fixture above is taken back by the same sweep every other row here is, and by nothing wider. * - * CRITERION. After the sweep, this run holds no `composio_connections` row at all — the one at the - * anonymous actor included, which none of the person ids that sweep names would reach. + * CRITERION. Two halves, and the second is the one that has teeth. After the sweep this run holds + * no `composio_connections` row at all — the one at the anonymous actor included, which none of the + * person ids that sweep names would reach — AND an anonymous row belonging to somebody else is + * still standing. * * REASON. Brokered connections are removed here by toolkit, so the anonymous row is already * covered and needs no second, broader delete to reach it. Asserted rather than read off the code, * because the tempting spelling for "take the anonymous row too" is `user_id = ''`, which is every - * app at once: the sweep that lands on another file's fixture. A test that reddens the moment this - * file needs a wider delete is what keeps that spelling out. + * app at once: the sweep that lands on another file's fixture. + * + * WHY THE SECOND HALF IS NOT OPTIONAL. "This run's rows are gone" is satisfied just as well by the + * wider delete as by the narrow one — a `user_id = ''` sweep takes this run's anonymous row too, + * and every assertion about absence goes on passing while the defect it forbids is present. Only a + * row the correct sweep must LEAVE BEHIND can tell the two deletes apart, so {@link foreignToolkit} + * stands in for one: a row this file inserted, deliberately outside {@link ownedToolkits}, at the + * pair another run legitimately holds. It is cleaned up by {@link cleanForeign} rather than by the + * sweep under test, for the reason given there. */ test("the sweep takes this run's anonymous row without reaching by actor", async () => { await seedApp({ connect: false }); await database.insert(composioConnections).values({ toolkit, userId: "" }); + // Somebody else's anonymous row, at an app this file's sweep does not name. + await database + .insert(composioConnections) + .values({ toolkit: foreignToolkit, userId: "" }); expect(await connectedToolkitsFor("")).toEqual([toolkit]); await clean(); expect(await connectionsHeld()).toEqual([]); + // And the row that was never this sweep's to take is exactly where it was. This is the assertion + // a delete keyed on `user_id = ''` fails, and the only one here that it fails. + expect(await connectedToolkitsFor("", [foreignToolkit])).toEqual([ + foreignToolkit, + ]); }); /** @@ -553,6 +775,63 @@ test("removing the app takes every brokered connection to it", async () => { }); }); +/** + * THE SAME REFUSAL, ON THE OTHER ACT, WHERE MORE IS AT STAKE. + * + * CRITERION. When the broker will not withdraw a grant, `removeServer` fails, the connection rows + * stand, the auth config is not dropped, and the app's own row is still there. + * + * REASON. The app row is the load-bearing extra. The toolkit is readable in exactly one place — the + * slug in `mcp_servers.url` — so an app deleted with its connections still standing is a set of live + * grants that nothing in this deployment can name, let alone end. A removal that swallowed the + * refusal would do precisely that and report the connector gone. Failing with everything in place + * costs a repeat of an administrative act nobody minds repeating. + * + * AND THE CONFIG STAYS, which is the ordering argument from the other side. The auth config is + * dropped last because a live account whose config has already been deleted is access nothing left + * here can end; a refusal partway through must not reach that step either. + */ +test("an app removal the vendor refuses leaves the app and its connections standing", async () => { + await seedApp(); + vendorRefuses = () => true; + + await expect(store.removeServer(toolkit, admin)).rejects.toThrow( + /would not withdraw/i, + ); + + // The revoke was attempted; nothing after it ran. Asserted as the whole list, because what makes + // this pass is as much the `deleteAuthConfig:` that is absent as the `revoke:` that is present. + expect(asksMade()).toEqual([`revoke:${toolkit}/${askerId}`]); + expect(await connectedToolkitsFor(askerId)).toEqual([toolkit]); + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + expect(await appsHeld()).toEqual([toolkit]); +}); + +/** + * REMOVING AN APP NOBODY EVER CONNECTED. + * + * CRITERION. No revoke reaches the vendor, and the auth config is dropped all the same. + * + * REASON. The two halves fail in opposite directions and neither had a test. A revoke sent with + * nobody to name would be this deployment asking Composio about a person who never connected — the + * same defect the anonymous-actor tests forbid on the other act, reached from the other end. And + * skipping the vendor entirely because the connection table happened to be empty would strand the + * auth config: it is a shape this deployment created at Composio when the app was added, it belongs + * to the app and not to anybody's account, and this is the only act that takes it. An app added and + * removed without a single person connecting is an ordinary sequence — a trial, a mistake, a + * rename — so the config it leaves behind is the ordinary case and not the rare one. + */ +test("removing an app nobody connected asks about nobody and still drops the config", async () => { + await seedApp({ connect: false }); + + await store.removeServer(toolkit, admin); + + expect(asksMade()).toEqual([`deleteAuthConfig:${toolkit}`]); + // Nobody's account ended, so nothing claims one did. + expect(recordedOfType("mcp.account_disconnected")).toHaveLength(0); + expect(await appsHeld()).toEqual([]); +}); + /** * WHAT WAS ASKED OF THE VENDOR, NOT THAT A CALL WAS MADE. * @@ -740,13 +1019,15 @@ test("an unattributed run is recorded as unattributed rather than as a blank", a const failed = recordedOfType("mcp.call_failed"); expect(failed).toHaveLength(1); + // Both fields, exactly. `reachedAs` is "unattributed" and so by that very assertion is not + // "deployment": this call did not go out on a shared credential, it did not go out at all, and + // saying the deployment reached the app would assert an attribution that never happened. A + // separate `not.toBe("deployment")` below this would be that same claim restated more weakly, + // green for every wrong value but one. expect(failed[0].payload).toMatchObject({ actor: "unattributed", reachedAs: "unattributed", }); - // Not "deployment" either: this call did not go out on a shared credential, it did not go out at - // all, and saying the deployment reached the app would assert an attribution that never happened. - expect(failed[0].payload.reachedAs).not.toBe("deployment"); }); /** From 37fff329f33ab520513c0250512cf7b347c2a993 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 19:13:14 +0200 Subject: [PATCH 142/215] Refuse an answer whose success this deployment cannot read, rather than auditing it as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes on the path a Composio answer takes from the vendor to the model, all of them ending in the same place: a failure handed over as content and written to the audit trail as `mcp.call_succeeded`. `reportedFailure` asked `successful !== false` of a field with more than two answers. `"false"`, `0` and `null` are none of them literally `false`, so each passed as a success. Falsiness is not the repair — a non-empty string is truthy, so it reads the vendor's own word "false" as a success too. A flag that is not a boolean is the third kind of failure this file already names, and it now says so in this file's words, below the vendor's own sentence so that a real complaint still reaches the reader ahead of a remark about the flag's shape. The envelope guard asked whether an object had arrived when the refusal it raises has always claimed the `{ data, error, successful }` envelope was checked for. That is the third hole found in this one guard, and the shape of the question was the defect rather than its reach: a one-level-unwrapped envelope and a bare `{}` both cleared it, reported nothing, and came back `isError: false` saying "The action returned nothing." It now asks for the envelope and names which field was missing. And `callTool` preferred the vendor's nested sentence over a refusal the adapter authored, which is the reverse of the order `routes.ts` uses on the same throws. The wrapper's `cause` is the original error, so reading it there lands one level shallow — and `vendorRefusal` writes a sentence only where the vendor had none, so what won was a string the adapter had already judged worthless. The fourth finding is refuted, and the evidence is written down where it will be read next time: an empty listing is not committed as an app with nothing on it, because `refreshTools` already keeps every recorded action, effect and version when an app that holds actions lists none. `@composio/core` really does manufacture `[]` out of a response it could not read, so the hazard is real, but it is answered at the layer that does the deleting. Refusing here as well would buy nothing and would cost the case that guard is careful to allow: an app that genuinely advertises nothing stays recordable rather than reading as broken for good. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 174 +++++++++++++++--- server/tests/composio-transport.test.ts | 229 +++++++++++++++++++++++- 2 files changed, 371 insertions(+), 32 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 460849839..1d18f5071 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -1,3 +1,4 @@ +import { brokerSentence } from "./broker"; import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; /** @@ -386,6 +387,16 @@ function stagesAFile(schema: unknown): boolean { * `callTool` refuses to run without and which only a listing can put back. So the two "asked * nobody" cases throw, and they throw SEPARATELY, because one sends an operator to this * deployment's configuration and the other to the row's url. + * + * WHAT IS DELIBERATELY NOT REFUSED IS THE VENDOR'S OWN EMPTY PAGE, and it is worth saying why, + * because `@composio/core` does manufacture one: `getRawComposioTools` ends `if (!tools) { return + * []; }` (0.18.1, `src/models/Tools.ts:553-557`), so a response it could not read arrives here as + * the same `[]` an app with no actions would send. That is a real hazard and it is answered a + * layer down rather than here — `store.ts`'s own empty-listing guard keeps every recorded action, + * its `effect`, its `destructive` and its `version` whenever an app that HAS actions lists none, + * writes the sentence saying so, and stamps no refresh. Refusing here as well would buy nothing + * that guard does not already hold, and would cost the case it is careful to allow: an app that + * genuinely advertises nothing stays recordable, rather than reading as broken for good. */ export async function listTools(connection: { url: string; @@ -544,6 +555,11 @@ export async function listTools(connection: { * with more of them answer identically here. Committed as complete, the second one has every * action past the cut deleted from `mcp_tools` under a refresh that reported success — the same * loss the empty answer used to cause, arriving by a different route. + * + * AND THE ONE `store.ts` CANNOT CATCH FOR US, which is why this refusal is not redundant with + * the empty-listing guard there. That guard keys on a listing with NOTHING in it; a full page + * is a listing with a thousand things in it, indistinguishable from a complete one, and it + * commits as the whole truth about the app. */ throw new Error( `Composio answered with ${actions.length} actions for ${toolkit}, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. The actions already recorded are kept rather than replaced by a listing that might be a fragment.`, @@ -589,7 +605,13 @@ export async function listTools(connection: { * full page above all refuse; arriving through this filter does not make it a different event. * * A vendor answer that was genuinely empty is left alone, because that one IS the vendor - * advertising nothing and is the sentence `refreshTools` should record. + * advertising nothing and is the sentence `refreshTools` should record. Which is also the one + * case `store.ts` settles rather than this file: its own empty-listing guard keeps what is held + * whenever an app that HAS actions recorded answers with none, and lets the empty answer commit + * where there is nothing to lose. What that guard cannot do is tell an app that listed nothing + * from an app whose every action this deployment dropped, and the sentence it writes says the + * first. So the emptying that happens HERE has to be refused HERE, where the count that makes + * it true is still in hand. */ if (actions.length > 0 && offered.length === 0) { throw new Error( @@ -771,6 +793,53 @@ const failure = (message: string): McpCallResult => ({ isError: true, }); +/** + * The three fields a resolved answer has to carry to be the envelope at all. + * + * Named as a list because the refusal below quotes it: the sentence tells a reader that a + * `{ data, error, successful }` envelope was required, and the only way that claim stays true as + * the check changes is if the claim and the check read the same names. + */ +const ENVELOPE_FIELDS = ["data", "error", "successful"] as const; + +/** + * What is missing before a resolved value can be read as Composio's envelope, or null for one. + * + * ASKED AS "IS IT THE ENVELOPE" RATHER THAN "IS IT AN OBJECT", which is the correction, and the + * third one this guard has needed. Written as a bare `typeof` it admitted arrays; written as + * {@link schemaNode} it admitted every other object in the world. Both fixes widened the coverage + * of a question that was the wrong question: the refusal has always told the reader that the + * `{ data, error, successful }` envelope was required, and nothing anywhere was checking for one. + * So a one-level-unwrapped envelope — the action's own `data` in the envelope's place, which is + * what a client that reaches one field too far resolves — and a bare `{}` both cleared it, read + * `error` and `successful` as absent, reported nothing, and came back `isError: false` saying "The + * action returned nothing." `store.ts` wrote `mcp.call_succeeded` beside each one. A shape this + * deployment could not read reaching a model as a call that worked and found nothing is the exact + * outcome the third kind of failure exists to keep off the audit trail, and it survived two fixes + * because each of them asked for a wider class of the wrong thing. + * + * ABSENCE IS THE QUESTION HERE AND TYPE IS NOT, which is the line between this and + * {@link reportedFailure}. This one settles whether the right OBJECT arrived — whether what + * resolved is the envelope or something else entirely. What the vendor put IN each field, and + * whether it is readable, is a separate question asked once the envelope is in hand, and it is + * asked there because the answer differs per field: an unreadable `error` and an unreadable + * `successful` produce different sentences, and neither is "this is not an envelope". + * + * A FIELD PRESENT AS `undefined` COUNTS AS ABSENT, because no reader downstream can tell the two + * apart and neither can the schema: `ToolExecuteResponseSchema` spells all three REQUIRED + * (`@composio/core` 0.18.1), so a key holding nothing is as far from that shape as no key at all. + * `error` is nullable and `null` is therefore present, which is the one distinction that matters. + */ +function envelopeGap(answer: unknown): string | null { + const node = schemaNode(answer); + if (node === null) return "what came back was not one"; + + const absent = ENVELOPE_FIELDS.filter((field) => node[field] === undefined); + return absent.length === 0 + ? null + : `what came back carried no ${absent.join(" and no ")}`; +} + /** * The serializations that mean the action had nothing to say. * @@ -843,9 +912,28 @@ function resultOf(data: ComposioResult["data"] | undefined): McpCallResult { * it matches {@link vendorSentence}, because a blank sentence beside an explicit `successful: true` * would otherwise become a refusal saying only that the call failed and nobody said why. * - * AND `successful !== false` RATHER THAN A FALSY `successful`, because an absent field is not the - * vendor reporting anything: the schema makes it impossible from the real client, and reading it as - * a failure would turn a projection looser than the schema into a refusal of a call that worked. + * AND THE FLAG IS READ FOR ITS SHAPE BEFORE IT IS READ FOR ITS VALUE, which is the half this + * function was missing for as long as the `error` beside it had it. `successful !== false` asked + * one question of a field with three answers: `"false"`, `0` and `null` are none of them `false`, + * so each one passed as a success, and a reported failure was handed to the model as content and + * written to the audit trail as `mcp.call_succeeded`. Falsiness is not the repair either — it + * answers `"false"` correctly by accident, since a non-empty string is truthy, and would still + * take `0` for a considered "no" rather than for a field nobody here can read. + * + * SO A NON-BOOLEAN IS THE THIRD KIND OF FAILURE, exactly as an unreadable `error` is, and it gets + * the wording that kind is owed: nothing was reported, so nothing can be passed on as the vendor's + * report, and what this deployment has to say is that it cannot tell whether the action ran. The + * old comment here argued that an ABSENT flag must not be read as a failure, which was right and + * is now settled one step earlier — {@link envelopeGap} refuses an answer that carries no + * `successful` at all, as not being the envelope. What is left to this function is a field that + * arrived, and a field that arrived saying something unreadable is the vendor speaking, not the + * vendor silent. + * + * THE VENDOR'S OWN SENTENCE STILL COMES FIRST, which is why the shape check sits below the + * sentence rather than above it. `{ error: "Gmail rejected the query", successful: "false" }` is a + * failure whichever way the flag is read, and the reader is better served by what Composio said + * about it than by this file's remark that the flag was malformed. The check is reached only where + * the alternative would be calling the answer a success. * * AN `error` THAT IS NOT A SENTENCE IS NOT SILENCE, and reading the field through a `typeof` that * collapsed everything else to `""` made the two indistinguishable. `{ message: … }`, or the list @@ -863,25 +951,29 @@ function reportedFailure( answer: ComposioResult, toolName: string, ): string | null { - // Read as `unknown` because the type is this module's projection and the value is the vendor's: - // `ToolExecuteResponseSchema` spells this field a nullable string, and a field that is neither is - // exactly the case below. + // Both fields are read as `unknown` because the types are this module's projection and the values + // are the vendor's: `ToolExecuteResponseSchema` spells `error` a nullable string and `successful` + // a boolean, and a field that is neither is exactly what the two shape checks below are for. + // Neither can be absent — {@link envelopeGap} settled that before this was called. const reported: unknown = answer.error; - if ( - reported !== null && - reported !== undefined && - typeof reported !== "string" - ) { - return answer.successful === false + const outcome: unknown = answer.successful; + + if (reported !== null && typeof reported !== "string") { + return outcome === false ? unexplained(toolName) : `${toolName} was sent to Composio and Composio answered, but this deployment could not read what it said about the call: the answer's error was neither a sentence nor null, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; } - const sentence = typeof reported === "string" ? reported.trim() : ""; - if (answer.successful !== false && sentence === "") return null; - return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) - ? unexplained(toolName) - : sentence; + const sentence = reported === null ? "" : reported.trim(); + if (sentence !== "") { + return VENDOR_PLACEHOLDER.test(sentence) ? unexplained(toolName) : sentence; + } + + if (typeof outcome !== "boolean") { + return `${toolName} was sent to Composio and Composio answered, but this deployment could not read whether the call worked: the answer's successful was neither true nor false, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; + } + + return outcome === false ? unexplained(toolName) : null; } /** @@ -967,6 +1059,35 @@ export async function callTool( rest, ); } catch (error) { + /* + * A SENTENCE THIS DEPLOYMENT AUTHORED BEATS ANYTHING THE VENDOR SAID, and the order was + * inverted here against the one `routes.ts` uses on the identical class of error. + * + * `brokerRefusal` in that file reads `brokerSentence` first and falls back to `vendorSentence` + * (`routes.ts:89-91`); this catch read `vendorSentence` first and reached `error.message` only + * where that found nothing. Both see the same throws — `./composio-adapter`'s `askVendor` + * raises a {@link BrokerRefusalError} out of the execute path as readily as out of a listing — + * so one vendor condition was being answered with two different sentences depending on which + * door the reader came through, and on this door the authored one lost. + * + * WHAT IT LOST TO IS WORSE THAN A TIE. `vendorRefusal` authors a refusal only where + * `vendorSentence(error)` was null — that is its documented limit, so the vendor gets the last + * word wherever it had one. Reading `vendorSentence` again on the WRAPPER is therefore not + * reading the same thing twice: the wrapper's `cause` is the original error, so the reach for + * `cause.error.error.message` lands one level shallower than it did on the original and can + * come back with a string the adapter had already judged not to be the vendor's explanation. + * A remedy written for the exact condition — "disconnect the account they already hold", "a + * dated version is recorded when an app's actions are listed" — was being replaced by whatever + * that shallower read happened to find. + * + * THE CLASS IS THE PROMISE, which is what makes preferring it safe. `./broker` raises one only + * where the sentence names the step that fixes it and is safe to show anybody who could have + * made the request; a failure it cannot explain stays a plain `Error` and falls through to the + * vendor's own words below, exactly as before. + */ + const authored = brokerSentence(error); + if (authored !== null) return failure(authored); + /* * THE SDK'S OWN PARSE THROWS THROUGH HERE, and its message is not a sentence. * @@ -1015,18 +1136,15 @@ export async function callTool( * A vendor fault it is not, so it does not get the vendor's words. This is the third kind of * failure the comment above names: Composio answered and this deployment could not read it. * - * THE QUESTION IS ASKED THROUGH {@link schemaNode} RATHER THAN WRITTEN OUT AGAIN HERE, and what - * the difference costs is an array. A hand-written `typeof answer === "object"` admits one: - * `[]`, or an envelope unwrapped one level too far, read `error` and `successful` as undefined, - * reported nothing, and came back `isError: false` saying "The action returned nothing." So the - * one shape this guard exists to refuse was the one shape that reached the model as a call that - * worked and found nothing, and `store.ts` wrote a success beside it. The listing above asks its - * elements this identical question through that predicate; there is one right answer to it and - * one place to keep it. + * THE QUESTION IS ASKED THROUGH {@link envelopeGap}, which is the one that names what is wrong + * as well as that something is. Two earlier versions of this guard asked only whether an object + * had arrived and let every object through, including the two this refusal was written about; + * see that function for why the shape of the question was the defect rather than its reach. */ - if (schemaNode(answer) === null) { + const gap = envelopeGap(answer); + if (gap !== null) { return failure( - `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: it was not the { data, error, successful } envelope Composio's own schema requires.`, + `${toolName} was sent to Composio and its client resolved, but this deployment could not read what it resolved with: Composio's own schema requires a { data, error, successful } envelope and ${gap}, so nothing here can tell whether the action ran. That is a change in what the vendor or this deployment's @composio/core answers with rather than a setting on this connection — upgrading the package is the fix.`, ); } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 35295bee2..201296c9d 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { BrokerRefusalError } from "../src/plugins/broker"; import { type ComposioAction, type ComposioActions, @@ -1069,11 +1070,28 @@ describe("listing an app's actions", () => { expect(outcome).not.toContain("committed"); expect(outcome).toContain("gmail"); expect(outcome).toMatch(/file/i); + // The COUNT, which is the only thing separating this refusal from the empty answer asserted + // below. Both are listings with nothing left in them; a sentence that did not say how many + // actions the app really published would send an operator looking for the wrong fault. + expect(outcome).toContain("2"); - // AND THE VENDOR'S OWN EMPTY ANSWER IS STILL AN ANSWER. `[]` from a vendor that was asked and - // advertises nothing is what every other transport means by it, and `refreshTools` is right to - // commit it. The refusal above is about a listing this deployment emptied, not one that - // arrived empty. + /* + * AND THE VENDOR'S OWN EMPTY ANSWER IS STILL AN ANSWER, which is a decision this file shares + * with `store.ts` rather than one it makes alone. + * + * `@composio/core` does manufacture an empty listing out of a response it could not read — + * `getRawComposioTools` ends `if (!tools) { return []; }` (0.18.1, + * `src/models/Tools.ts:553-557`) — so the hazard of committing `[]` is real. It is answered at + * the layer that does the committing: `refreshTools` has its own empty-listing guard, which + * keeps every recorded action with its `effect`, `destructive` and `version` whenever an app + * that holds actions lists none, and stamps no refresh + * (`plugin-store.integration.test.ts`, "a refresh the vendor answered with no actions at all"). + * + * Refusing here as well would buy nothing that guard does not hold, and would cost the case it + * is careful to allow: an app that genuinely advertises nothing stays recordable instead of + * reading as broken for good. The refusal above is about a listing this deployment emptied, + * not one that arrived empty. + */ useComposioClient(recording({ listActions: async () => [] }).client); expect(await listTools({ url: "composio://gmail" })).toEqual([]); }); @@ -1621,6 +1639,61 @@ describe("calling one action", () => { } }); + test("an object that is not the envelope is refused, not called an empty success", async () => { + /* + * THE HOLE THE ARRAY FIX LEFT, WHICH IS EVERY OTHER OBJECT. The guard above this one asked + * whether an object had arrived, so it caught `[]` and admitted the whole of the rest of the + * world — and the two shapes it was written about walked straight through it. + * + * An envelope unwrapped ONE level, which is what a client reaching one field too far resolves, + * is the action's own `data` standing where the envelope belongs: `error` and `successful` + * come off it as absent, so nothing is reported, and `data` comes off it as absent, so + * `resultOf` answers "The action returned nothing." A bare `{}` does the same by having + * nothing on it at all. Both reached the model as a call that worked and found nothing, and + * `store.ts` wrote `mcp.call_succeeded` beside each — while the refusal that never fired + * claimed the `{ data, error, successful }` envelope had been checked for. + * + * THE PARTIAL ONES ARE HERE FOR THE SAME REASON THE WHOLE ONES ARE. An answer carrying two of + * the three fields is not an envelope either, and each of them lands on the identical false + * success: no `data` serializes to nothing, and no `successful` reports nothing. + */ + const shapes: Record = { + "an envelope unwrapped one level": { messages: [{ id: "m1" }] }, + "a bare object": {}, + "an envelope with no data": { error: null, successful: true }, + "an envelope with no successful": { + data: { messages: [{ id: "m1" }] }, + error: null, + }, + "an envelope whose successful is present as undefined": { + data: {}, + error: null, + successful: undefined, + }, + }; + + for (const [shape, answer] of Object.entries(shapes)) { + useComposioClient( + recording({ + execute: async () => answer as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(`${shape}: ${result.isError}`).toBe(`${shape}: true`); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // The two halves of the false success this guard exists to stop: the sentence that reads as + // a call that worked, and the vendor's data handed over as content beside it. + expect(result.text).not.toContain("returned nothing"); + expect(result.text).not.toContain("m1"); + } + }); + test("an error that is not a sentence is not read as silence", async () => { /* * `ToolExecuteResponseSchema` spells `error` a nullable string, so an object — or the issue @@ -1679,6 +1752,80 @@ describe("calling one action", () => { expect(reported.text).toContain("Plugins page"); }); + test("a successful flag that is not a boolean is not read as a success", async () => { + /* + * `successful !== false` ASKED ONE QUESTION OF A FIELD WITH THREE ANSWERS. The schema spells + * it a required boolean, but the value is the vendor's and the type is this module's + * projection — and every shape below is none of them literally `false`, so each one passed as + * a success: the reported failure was handed to the model as content and `store.ts` audited + * `mcp.call_succeeded` beside it. This is the same defect the `error` field beside it was + * fixed for, on the field that decides the outcome outright. + * + * `"false"` IS THE ONE THAT PROVES FALSINESS IS NOT THE REPAIR. A non-empty string is truthy, + * so a plain `!answer.successful` reads the vendor's literal word "false" as a success just as + * the old check did, and `0` — which no schema permits and no reader can interpret — it would + * read as a considered no. What is wanted is neither: a field this deployment cannot read is + * the third kind of failure, and it says so in this file's own words rather than guessing a + * value for it. + */ + for (const successful of ["false", 0, null, "true", {}]) { + useComposioClient( + recording({ + execute: async () => + ({ + data: { messages: [{ id: "m1" }] }, + error: null, + successful, + }) as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const named = JSON.stringify(successful); + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + // Not the vendor's words: nobody reported this failure, so the sentence must not read as + // though Composio had. And the data must not travel beside it as content. + expect(result.text).toMatch(/could not read/i); + expect(result.text).not.toContain("m1"); + } + }); + + test("an unreadable flag loses to the sentence Composio did send", async () => { + /* + * THE SHAPE CHECK ABOVE MUST NOT COST A READER THE ONE USEFUL SENTENCE. A malformed flag + * beside a real complaint is a failure either way, and "this deployment could not read the + * flag" is the less actionable of the two things that could be said about it. So the vendor's + * own words still win, and the check is reached only where the alternative would be calling + * the answer a success. + */ + useComposioClient( + recording({ + execute: async () => + ({ + data: {}, + error: "Gmail rejected the query: invalid search syntax.", + successful: "false", + }) as unknown as ComposioResult, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("invalid search syntax"); + expect(result.text).not.toMatch(/could not read/i); + }); + test("an answer that is not an envelope refuses rather than throwing", async () => { /* * THE NEVER-THROW CONTRACT, asserted against the shape that broke it. This module documents a @@ -1789,6 +1936,80 @@ describe("calling one action", () => { expect(result.text).toMatch(/Plugins page/); }); + test("a refusal this deployment authored beats whatever the vendor said", async () => { + /* + * THE ORDER `routes.ts` USES, WHICH THIS PATH HAD BACKWARDS. `brokerRefusal` there reads + * `brokerSentence` first and falls back to `vendorSentence`; this catch read `vendorSentence` + * first. Both meet the same throws — `./composio-adapter`'s `askVendor` raises a + * `BrokerRefusalError` out of the execute path as readily as out of a listing — so one vendor + * condition was answered with two different sentences depending on which door the reader came + * through, and on this one the authored remedy lost. + * + * AND IT LOST TO A READ ONE LEVEL SHALLOW. `vendorRefusal` authors a refusal only where + * `vendorSentence` of the ORIGINAL error was null, so the vendor keeps the last word wherever + * it had one. Asking the same question of the WRAPPER is a different question: its `cause` is + * the original, so the reach for `cause.error.error.message` lands one level in from where it + * landed before and finds whatever sits there — below, a bare "Invalid request" that the + * adapter had already judged not to be an explanation. + * + * What that costs is the whole point of translating the condition: a sentence naming the step + * that clears it, replaced by three words naming nothing. + */ + const authored = + 'Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so GMAIL_FETCH_EMAILS was not run. A dated version is recorded when an app\'s actions are listed, so refreshing gmail\'s tools on its Plugins page replaces "latest" with a version Composio will accept.'; + + useComposioClient( + recording({ + execute: async () => { + throw new BrokerRefusalError(authored, { + cause: { error: { error: { message: "Invalid request" } } }, + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + // Pinned whole rather than by a fragment: what this asserts is that the authored sentence + // arrives as its author wrote it, and a substring check would pass on a sentence that had + // been joined to the vendor's or cut short of the remedy. + expect(result.text).toBe(authored); + expect(result.text).not.toContain("Invalid request"); + }); + + test("a failure this deployment cannot explain still reaches for the vendor's words", async () => { + // The other half of the precedence, and the reason it is `brokerSentence` rather than + // `error.message`. `./broker` raises its class only where the sentence names the step that + // fixes it; a failure it knows nothing about stays a plain `Error`, and for those the vendor's + // own nested sentence is still worth far more than a generic top-level message. + useComposioClient( + recording({ + execute: async () => { + throw Object.assign( + new Error("Error executing the tool GMAIL_FETCH_EMAILS"), + nested( + "No connected account found for user ID u1 for toolkit gmail", + ), + ); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toContain("No connected account found"); + }); + test("an answer that serializes to nothing at all is refused in this file's own words", async () => { /* * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a function, a symbol, or From 72d00a1b6aa26a1d207e131fe745e976c9758978 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 19:26:37 +0200 Subject: [PATCH 143/215] Read a Composio answer the vendor's own package could not, instead of guarding where it never arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rounds of review added shape guards to this adapter. Running @composio/core 0.18.1 against malformed wire answers shows most of them standing where no answer can reach: every list transformer dereferences the reply before returning it, so a bad container dies inside the vendor and arrives as a bare TypeError — which `isOurFault` then classified as a bug of ours, the opposite of the truth, escaping the delete loop and abandoning grants nobody was told about. So the classification moves to `askVendor`, which wraps the `await vendor.*` and nothing else and can therefore tell "inside the vendor's code" as a fact rather than a guess. A TypeError out of a vendor call is now a vendor-shape refusal naming what did not happen and the one act that changes it — a package upgrade, since the request went out and was answered. That one row replaces nine unreachable branches and covers shapes nobody enumerated. The tool row is the one the SDK really validates — transformToolCases ends in a throwing ToolSchema.parse — so its five hand-written field guards are deleted in favour of the schema the vendor already enforces. What the schema leaves open is kept: z.string() admits "", and an action slug is mcp_tools.name. A person whose account Composio described with no id could not disconnect at all: the id check ran over every row before the first delete, and the bad row was still there next time. Their readable grants are now withdrawn first and the rest reported, with the dashboard named rather than the button they just pressed. `isConnected` is a count and stops reading an id it never used. Tests: a successful tool call now asserts that the caller's user id, the version and the arguments reach the vendor — raised in all three review rounds and dropped from the fix list every time. The malformed-listing probe's `sent` was a literal nobody wrote to in 8 of 16 cases; it records every vendor call now and each listing names the one it may make. The outage allow-list's reasons for the action listing and the tool execution were false — listingSentence and callTool pass the thrown message on verbatim, bare and naming no app — so they say that, and the allowed branch asserts it. Vendor call sites audited against @composio/core 0.18.1, each fed malformed answers through the real models: tools.getRawComposioTools → [] | TypeError | ZodError tools.getRawComposioToolBySlug → TypeError | ZodError tools.execute → unchanged; attribution now asserted toolkits.get → ComposioToolkitFetchError, always authConfigs.list → TypeError on container and row authConfigs.create → unchanged authConfigs.delete → unchanged connectedAccounts.list → TypeError on container and row connectedAccounts.link → own object or one of two named classes connectedAccounts.delete → unchanged Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 630 ++++++++++++++----------- server/tests/composio-adapter.test.ts | 624 ++++++++++++++++++------ 2 files changed, 822 insertions(+), 432 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 00f74c2a4..4bd28fd49 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -104,20 +104,29 @@ type VendorTool = { * anything in them and a union would be another guess. `unknown` is the type that forces the * reader to say what it does with a value it has not checked, which is the whole point. * - * `slug` IS AS WIDE AS `name` NOW, AND FOR THE SAME REASON. It was left narrow while the other - * fields were widened, on no argument that distinguishes it: `ToolKitItemSchema` spells it required - * and the warn-only `transform()` above copies it across whatever it turns out to be, exactly as it - * does the name. A slug is the only name this deployment has for an app — it is what enabling one - * records and what every later call names — so the one it must not quietly become is `undefined` - * read as a string. {@link appOf} has refused that since the sweep; the declaration says so now. + * `slug` IS AS WIDE AS `name`, AND FOR THE SAME REASON. `ToolKitItemSchema` spells it required and + * the warn-only `transform()` above copies it across whatever it turns out to be, exactly as it does + * the name. A slug is the only name this deployment has for an app — it is what enabling one records + * and what every later call names — so the one it must not quietly become is `undefined` read as a + * string. {@link appOf} refuses that. + * + * EVERY WIRE-VALUED FIELD IS `unknown` AND EVERY STRUCTURAL ONE IS NOT, which is the split the whole + * file now turns on. `meta`, its `categories` array and each entry in it are built by + * `transformToolkitListResponse` itself (`src/utils/transformers/toolkits.ts:21-34`) — it reads + * `item.meta.categories` and maps each category into `{ slug, name }` — so a row with no meta, a + * `categories` that is not a list, and a category that is not an object all fail INSIDE that + * function and reach nothing here. Those three are guarantees. What the same lines copy across + * verbatim — the name, the slug, the description, the logo, each category's name and the count — is + * worth exactly what the wire is worth, so all of it is `unknown` or nullable and {@link appOf} + * says what it does with each. */ type VendorToolkit = { - slug?: string | null; - name?: string | null; + slug?: unknown; + name?: unknown; meta: { description?: unknown; - logo?: string | null; - categories?: { name?: string | null }[]; + logo?: unknown; + categories?: { name?: unknown }[]; toolsCount?: unknown; }; }; @@ -144,16 +153,21 @@ type VendorToolkit = { * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and * "disabled" are different facts and only one of them is worth telling an operator. * - * `id` IS NOW DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. It was left narrow - * on the argument that widening belongs beside the check rather than ahead of one; {@link configOf} - * makes that check, so the declaration no longer claims more than the wire promises. It is the one - * of the three whose absence sends a request: a delete named with `undefined` asks Composio to - * remove whatever it cares to, and this deployment then records that the app was withdrawn. + * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link configOf} makes + * that check, so the declaration no longer claims more than the wire promises. It is the one of the + * three whose absence sends a request: a delete named with `undefined` asks Composio to remove + * whatever it cares to, and this deployment then records that the app was withdrawn. + * + * THE ROW BEING AN OBJECT AT ALL IS THE ONE THING THAT IS NOT IN DOUBT. + * `transformAuthConfigRetrieveResponse` reads `authConfig.toolkit.logo` while building every row + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:41`), so a row that is not an + * object raises a `TypeError` inside the vendor's own code and never arrives. All three fields + * below are `unknown` for the opposite reason: the same function copies them across verbatim. */ type VendorAuthConfig = { - id?: string | null; - name?: string | null; - status?: string; + id?: unknown; + name?: unknown; + status?: unknown; }; /** @@ -176,20 +190,32 @@ type VendorAccountStatus = | "REVOKED"; /* - * WHY EVERY LISTING BELOW IS READ OUT OF `unknown` AND NOT OUT OF THE TYPES ABOVE. - * - * The declarations on {@link ComposioVendor} say what the SDK MEANS to send, and each row type says - * at length why that is not the same as what arrives: `transform()` validates with `safeParse`, - * logs a warning where it fails, and returns the unvalidated object anyway (`@composio/core` - * 0.18.1, `src/utils/transform.ts:26-36`). So the declarations are kept — they are the contract a - * version bump is read against, and they are what makes an unchecked read fail to compile — and the - * value itself is checked here before any field is taken off it. - * - * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT. A `?? ""`, a `String(x)` or - * a cast does not make a malformed answer safe; it converts a fault this deployment could have - * reported into an answer it gives wrongly, and the wrong answers are not small ones — an app with - * no name in an administrator's picker, an action with no slug put in front of a model, and the one - * that was actually happening: a delete sent with `undefined` where an account id belongs, + * WHERE THE LINE BETWEEN "READ AT ITS TYPE" AND "READ OUT OF `unknown`" IS DRAWN, AND WHY IT MOVED. + * + * Everything below used to be read out of `unknown` — containers and fields alike — on one argument: + * `transform()` validates with `safeParse`, logs a warning where it fails, and returns the + * unvalidated object anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`), so a + * TypeScript declaration over a wire value is an assertion and not a check. + * + * THAT ARGUMENT IS TRUE OF THE FIELDS AND FALSE OF THE SHAPES AROUND THEM, which a round of running + * the SDK against malformed answers established rather than reasoned about. `transform()` returns + * whatever its TRANSFORMER built, and every one of these transformers builds its result by + * dereferencing the raw answer — `response.items.map(...)`, `item.meta.categories`, + * `authConfig.toolkit.logo`, `response.auth_config.id`. So the containers and the rows inside them + * are the vendor's own construction and cannot arrive malformed; only the values copied ACROSS + * those lines can, and those are exactly the fields declared `unknown` above. Guards were written + * for both halves, and the ones covering the shapes were branches no answer could reach, standing + * where the next reader would take them for what was keeping them safe. + * + * WHAT KEEPS THEM SAFE IS `askVendor`. A shape the SDK could not read raises inside the SDK, and + * the `TypeError` row in {@link vendorRefusal} turns that into a sentence — one guard, at the layer + * where the fault actually surfaces, covering every shape rather than the four somebody listed. + * + * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT FOR THE FIELDS. A `?? ""`, a + * `String(x)` or a cast does not make a malformed answer safe; it converts a fault this deployment + * could have reported into an answer it gives wrongly, and the wrong answers are not small ones — + * an app with no name in an administrator's picker, an action with no slug put in front of a model, + * and the one that was actually happening: a delete sent with `undefined` where an account id belongs, * answered by Composio however it likes, after which the audit trail records that a person's access * was withdrawn and nothing had been. Every reader below therefore answers null on anything it * cannot read, and every caller turns that null into a sentence naming what Composio sent. @@ -242,32 +268,32 @@ function named(value: unknown): string { return text === "" ? sent(value) : `"${text}"`; } -/** - * One listing row as its fields, and a type predicate so that nothing below needs a cast. - * - * Arrays are excluded deliberately. `typeof [] === "object"` and every field read off one answers - * `undefined`, so a listing of lists would otherwise arrive here as a listing of rows that are - * missing every field — which is a worse sentence for the same fault. - */ -function hasFields(row: unknown): row is Record { - return typeof row === "object" && row !== null && !Array.isArray(row); -} - -/** - * The rows inside an envelope the vendor answers with, or null where it answered something else. - * - * THE TWO CONTAINER SHAPES ARE NOT INTERCHANGEABLE AND COMPOSIO USES BOTH. Toolkits and tools come - * back as bare arrays; auth configs and connected accounts come back as `{ items, nextCursor }`. So - * one where the other belongs is a vendor change rather than a quirk to absorb — and it is the - * change that hides best, because `[].items` is `undefined` rather than an error and a bare list - * where an envelope belongs therefore used to read as an envelope with nothing in it: no configs to - * connect against, no accounts to withdraw, and a confident answer either way. +/* + * THE CONTAINER OF A LISTING IS THE ONE THING BELOW THAT IS NOT READ OUT OF `unknown`, AND THE + * REASON IS THAT THE SDK PROVES IT. + * + * There used to be a `hasFields` predicate and an `itemsOf` reader here, and every listing passed + * its answer through them before touching a field — on the argument the row types give at length, + * that a TypeScript interface over a wire value is an assertion rather than a check. That argument + * is correct about the FIELDS and wrong about the CONTAINER, which running `@composio/core` 0.18.1 + * settles rather than reasons about. Every list transformer dereferences the answer before + * returning it: `response.items.map(...)` in `transformAuthConfigListResponse` + * (`src/utils/transformers/authConfigs.ts:79`), in `transformConnectedAccountListResponse` + * (`connectedAccounts.ts:113`) and in `getRawComposioTools` (`models/Tools.ts:561`), and + * `item.meta.categories` inside the catalogue's own map (`toolkits.ts:27`). So a container of the + * wrong shape — null, a bare list where an envelope belongs, an `items` that is a string — dies + * inside the vendor's code and NEVER arrives here. Each of those guards was therefore a branch no + * input could reach, sitting where the next reader would take it for the thing keeping them safe. + * + * WHAT KEEPS THEM SAFE IS ONE LAYER DOWN NOW. The vendor's crash is a bare `TypeError`, and + * {@link vendorRefusal} translates it into a sentence naming what did not happen and the one act + * that changes it — which catches every malformed container, including the shapes nobody here + * thought to enumerate. + * + * SO THE DECLARATIONS BELOW ARE READ AT THEIR TYPES, and each one says which vendor line makes it + * true. The fields inside them stay `unknown`, because the warn-only `transform()` really does copy + * those across whatever they turn out to be. */ -function itemsOf(answer: unknown): unknown[] | null { - if (!hasFields(answer)) return null; - const items = answer.items; - return Array.isArray(items) ? items : null; -} /** * One field as the non-empty string it has to be, or null where the vendor sent anything else. @@ -280,13 +306,6 @@ function textOf(value: unknown): string | null { return typeof value === "string" && value.trim() !== "" ? value : null; } -/** Whether a field is the list of strings it is declared to be, all the way through. */ -function isTextList(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every((entry) => typeof entry === "string") - ); -} - /** * How many pages of one listing this deployment will read before it stops and says so. * @@ -344,24 +363,30 @@ type Listing = { * {@link everyRowOf}'s callers spread for: a `cursor: undefined` would reach the vendor's `parse` * as a key, and an explicit undefined is not something this file needs to make the SDK have an * opinion about. + * + * THERE WAS A FOURTH REFUSAL AND IT IS GONE, which is worth saying because it was the one that read + * as the most important. It stood at the top of the loop for a page that is not an envelope at all, + * and no answer could reach it: both transformers begin `response.items.map(...)` + * (`src/utils/transformers/authConfigs.ts:79`, `connectedAccounts.ts:113`), so a null, a bare list + * and an `items` that is not one all raise a `TypeError` inside the vendor's own code. That is now + * answered where it happens — see the `TypeError` row in {@link vendorRefusal} — which is both a + * sentence the old branch could never have produced and a check that covers shapes nobody here + * enumerated. What is left below are the three faults the vendor CAN hand over, all of them about + * the cursor, because the cursor is the one field these transformers copy off the wire unchecked. */ -async function everyRowOf( +async function everyRowOf( listing: Listing, - page: (cursor: string | undefined) => Promise, -): Promise { - const rows: unknown[] = []; + page: ( + cursor: string | undefined, + ) => Promise<{ items: Row[]; nextCursor?: unknown }>, +): Promise { + const rows: Row[] = []; const followed = new Set(); let cursor: string | undefined; for (;;) { - const answered: unknown = await page(cursor); - const items = itemsOf(answered); - if (items === null) { - throw new BrokerRefusalError( - `Composio answered ${listing.noun} with ${sent(answered)} where a list of them belongs, so ${listing.consequence}. ${VENDOR_SHAPE_REMEDY}`, - ); - } - rows.push(...items); + const answered = await page(cursor); + rows.push(...answered.items); /* * ABSENT AND NULL BOTH MEAN THE END, and they are the two the vendor actually sends: the @@ -371,7 +396,7 @@ async function everyRowOf( * exists to prevent — one page treated as the whole answer, by a reader that had been told * otherwise in a way it did not understand. */ - const next = hasFields(answered) ? answered.nextCursor : undefined; + const next = answered.nextCursor; if (next === undefined || next === null) return rows; const follow = textOf(next); @@ -416,14 +441,18 @@ async function everyRowOf( * screen, exactly as it did before. What is refused is the other thing: a field that is PRESENT and * is not what it is declared to be. A count that arrived as the string "63" is not a count, and * `Number(x)` over it would turn a vendor change into a plausible figure nobody would question. + * + * WHAT IS NOT CHECKED IS THE ROW'S SHAPE, AND THAT IS THE SDK RATHER THAN AN OVERSIGHT. Two + * refusals used to open this function — one for a row that is not an object, one for a row with no + * meta — and neither could be reached: `transformToolkitListResponse` reads `item.meta.categories` + * while building each row (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:27`), and + * `Toolkits.getToolkits` wraps the whole thing in a try that rethrows everything as + * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`). So a catalogue this deployment + * cannot read never becomes a row here at all; it becomes that vendor class, whose bare "Failed to + * fetch toolkits" and whose key-or-status-page remedy are what the route's default already says. */ -function appOf(row: unknown, position: number): BrokerApp { +function appOf(row: VendorToolkit, position: number): BrokerApp { const at = `row ${position + 1} of Composio's app catalogue`; - if (!hasFields(row)) { - throw new BrokerRefusalError( - `Composio sent ${sent(row)} as ${at}, where an app belongs, so the directory was not shown. ${VENDOR_SHAPE_REMEDY}`, - ); - } const slug = textOf(row.slug); if (slug === null) { @@ -439,12 +468,14 @@ function appOf(row: unknown, position: number): BrokerApp { ); } + /* + * `meta` IS TAKEN AS AN OBJECT RATHER THAN CHECKED FOR ONE, and the reason is one line of vendor + * code: `transformToolkitListResponse` reads `item.meta.categories` while it builds this row + * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:27`), so a row whose meta is + * absent or null raises there and is answered as the vendor-shape fault it is. The refusal that + * used to stand here could not be reached by any answer Composio can send. + */ const meta = row.meta; - if (!hasFields(meta)) { - throw new BrokerRefusalError( - `Composio sent ${sent(meta)} where ${slug}'s meta belongs, which is where its description, logo, categories and action count all live. ${VENDOR_SHAPE_REMEDY}`, - ); - } const description = meta.description ?? ""; if (typeof description !== "string") { @@ -460,17 +491,19 @@ function appOf(row: unknown, position: number): BrokerApp { ); } - const published = meta.categories ?? []; - if (!Array.isArray(published)) { - throw new BrokerRefusalError( - `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong. ${VENDOR_SHAPE_REMEDY}`, - ); - } - const categories = published.map((entry: unknown, index: number) => { - const label = hasFields(entry) ? textOf(entry.name) : null; + /* + * THE LIST AND EACH ENTRY IN IT ARE THE TRANSFORMER'S OWN CONSTRUCTION, so neither is checked and + * both used to be. `item.meta.categories?.map(category => ({ slug: category.id, name: + * category.name }))` is what fills this field (`src/utils/transformers/toolkits.ts:27-30`): a + * `categories` that is not a list has no `.map` and dies there, a category that is null throws on + * `.id`, and everything that survives is an object this file did not have to hope for. What the + * line copies verbatim is the NAME, which is why that is the one thing still read. + */ + const categories = (meta.categories ?? []).map((entry, index) => { + const label = textOf(entry.name); if (label === null) { throw new BrokerRefusalError( - `Composio sent ${sent(hasFields(entry) ? entry.name : entry)} where the name of ${slug}'s category ${index + 1} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, + `Composio sent ${sent(entry.name)} where the name of ${slug}'s category ${index + 1} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, ); } return label; @@ -513,16 +546,11 @@ type CheckedAuthConfig = { * anybody had connected against it. */ function configOf( - row: unknown, + row: VendorAuthConfig, position: number, toolkit: string, ): CheckedAuthConfig { const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; - if (!hasFields(row)) { - throw new BrokerRefusalError( - `Composio sent ${sent(row)} as ${at}, where a config belongs. This listing is what decides whether a config is created, connected against or deleted, so a row it cannot read is not a row it may pass over. ${VENDOR_SHAPE_REMEDY}`, - ); - } const id = textOf(row.id); if (id === null) { @@ -542,59 +570,91 @@ function configOf( } /** - * One connected-account row checked down to the only field anything here reads. + * This person's accounts split into the ones a withdrawal can name and the ones it cannot. * - * THIS IS THE MOST LOAD-BEARING OF THESE READERS AND THE ONE WHOSE ABSENCE WAS DOING REAL DAMAGE. + * THE ID IS THE WHOLE OF WHAT A WITHDRAWAL NAMES, which is why it is read at all. * {@link ComposioBroker.revoke} deletes by id and then answers `true`, and `store.ts` writes that * answer into the audit trail as this person's access having been withdrawn before deleting the one - * row in this deployment that names which app they had connected. An id-less account reaching the + * row in this deployment naming which app they had connected. An id-less account reaching the * delete is a request to withdraw `undefined` — which the vendor is free to read as anything at all * — followed by a `true`, a trail entry, and a live grant with nothing left pointing at it. + * + * PARTITIONED RATHER THAN THROWN, AND THAT IS THE CORRECTION. This reader used to refuse, and the + * caller mapped EVERY row through it before sending a single delete — so one row whose id Composio + * omitted threw ahead of the first withdrawal, and the next attempt met the same row and threw in + * the same place. A person with three grants and one unreadable row could not withdraw any of them, + * ever, while being told to try again. That replaced a false success with a permanent block, which + * is the same defect pointing the other way. + * + * WHAT A PERSON IS OWED IS BOTH HALVES: every grant this deployment CAN name withdrawn, and a + * sentence counting the ones it cannot. The second half is not a thing they can retry — the row + * will be unreadable next time too — so the refusal names the dashboard rather than the button + * they just pressed. + * + * A ROW THAT IS NOT AN OBJECT IS NOT A CASE HERE, and that is the SDK's doing rather than an + * omission. `transformConnectedAccountResponse` reads `response.auth_config.id` while building + * every row (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:60`), so a + * non-object row raises a `TypeError` inside the vendor's own code and never reaches this function + * — see the `TypeError` row in {@link vendorRefusal}, which is where that answer is now given. */ -function accountIdOf(row: unknown, position: number, toolkit: string): string { - const at = `row ${position + 1} of Composio's ${toolkit} accounts for this person`; - if (!hasFields(row)) { - throw new BrokerRefusalError( - `Composio sent ${sent(row)} as ${at}, where an account belongs. Whether this person is connected and what there is to withdraw are both read off this listing, so a row it cannot read is answered with neither. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const id = textOf(row.id); - if (id === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a withdrawal names. Nothing was sent, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, - ); - } +function withdrawableAccounts( + rows: { id?: unknown }[], + toolkit: string, +): { ids: string[]; nameless: BrokerRefusalError[] } { + const ids: string[] = []; + const nameless: BrokerRefusalError[] = []; + + rows.forEach((row, position) => { + const id = textOf(row.id); + if (id === null) { + nameless.push( + new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of row ${position + 1} of its ${toolkit} accounts for this person belongs, and the id is the whole of what a withdrawal names. Nothing was sent for that account, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + ids.push(id); + }); - return id; + return { ids, nameless }; } /** - * One tool row checked into the action this deployment holds, or a refusal saying why not. - * - * CHECKED DESPITE {@link VendorTool}'s ARGUMENT THAT THIS ONE ROW IS VALIDATED. That argument is - * sound about the SDK — `transformToolCases` ends in a throwing `ToolSchema.parse` rather than the - * warn-only `transform()` everything else here goes through — and it is an argument about the - * vendor's code rather than about this deployment's. A schema is one version away from being - * relaxed, and the difference in cost between the two sides is not close: the check is a few - * comparisons per action, and what it stands between is `inputParameters` going in front of a model - * as the vendor's own schema when it is not a schema at all. + * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. + * + * THIS IS THE ONE ROW HERE THAT THE VENDOR REALLY DOES VALIDATE, AND THE FILE USED TO CHECK IT + * TWICE. `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the + * warn-only `transform()` every other listing goes through (`@composio/core` 0.18.1, + * `src/models/Tools.ts:193`) — and both calls this file makes run through it (`:561` for the + * listing, `:719` for the single tool). So a tool whose description is a number, whose + * `inputParameters` is a string of JSON, whose tags are not all labels, whose version is a number, + * or which is not an object at all, does not arrive as a malformed row: it arrives as a `ZodError`, + * which `./composio`'s `isSchemaMismatch` already recognises and answers with the same package + * remedy. Five refusals stood here for those five shapes and not one of them could be reached. + * + * THE SCHEMA IS PREFERRED RATHER THAN COPIED, which is what deleting them means. Re-running + * `ToolSchema` here would be the same parse a second time and could no more fail than the branches + * it replaced; what makes the schema worth resting on is that the SDK enforces it, and + * {@link VendorTool} is that enforcement written down as the type this function takes. + * + * WHAT THE SCHEMA DOES NOT SETTLE IS THE ONE CHECK LEFT. `slug: z.string()` is satisfied by the + * empty string, and an action's slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL + * and half that table's primary key, it is what a grant points at, and it is what a later call + * sends back to Composio. An empty one is a row that cannot be written and a call that names + * nothing, so it is refused here — the only shape of this row a passing `ToolSchema.parse` still + * admits. * * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not * answered to a route. `./composio` records them in an app's `lastError` for an administrator to * read on its Plugins page, and `listingSentence` passes an authored message through untouched. */ function actionOf( - row: unknown, + row: VendorTool, position: number, toolkit: string, ): ComposioAction { const at = `row ${position + 1} of Composio's action list for ${toolkit}`; - if (!hasFields(row)) { - throw new Error( - `Composio sent ${sent(row)} as ${at}, where an action belongs, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } const slug = textOf(row.slug); if (slug === null) { @@ -603,35 +663,17 @@ function actionOf( ); } - const description = row.description; - if (description !== undefined && typeof description !== "string") { - throw new Error( - `Composio sent ${sent(description)} where ${slug}'s description belongs, and that description is what a model reads to decide whether to call it. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const inputParameters = row.inputParameters; - if (inputParameters !== undefined && !hasFields(inputParameters)) { - throw new Error( - `Composio sent ${sent(inputParameters)} where ${slug}'s input schema belongs. That value is put in front of a model as Composio's own JSON Schema for the action, so a thing that is not an object cannot be shown as one. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const tags = row.tags; - if (tags !== undefined && !isTextList(tags)) { - throw new Error( - `Composio sent ${sent(tags)} where ${slug}'s tags belong, and this deployment reads those to decide whether an action only reads or also writes. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const version = row.version; - if (version !== undefined && typeof version !== "string") { - throw new Error( - `Composio sent ${sent(version)} where ${slug}'s version belongs, and the version travels with every call made to the action. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - return { slug, description, inputParameters, tags, version }; + /* + * Mapped field by field rather than spread, so what crosses the seam is the four things + * `./composio` documents and not whatever else the vendor's tool object happens to carry. + */ + return { + slug, + description: row.description, + inputParameters: row.inputParameters, + tags: row.tags, + version: row.version, + }; } /** @@ -826,6 +868,39 @@ function vendorRefusal( `Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so ${outcome}. A dated version is recorded when an app's actions are listed, so refreshing ${app}'s tools on its Plugins page replaces "latest" with a version Composio will accept.`, ); + /* + * AN ANSWER THE VENDOR'S OWN PACKAGE COULD NOT READ, WHICH IS THE ROW THE FILE HAD BACKWARDS. + * + * This used to be classified as a bug of this deployment's — see the note now on + * {@link askForEach} — on the premise that a `TypeError` is what a program's own mistake looks + * like and never something Composio can reply. Running `@composio/core` 0.18.1 falsifies that + * outright. Its list transformers dereference the answer before returning it, so a malformed + * reply dies inside the vendor's code and arrives here as a bare `TypeError`: + * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list or an `items` that + * is a string (`src/utils/transformers/authConfigs.ts:79`), `authConfig.toolkit.logo` off a row + * that is not an object (`:41`), and the same two shapes at + * `src/utils/transformers/connectedAccounts.ts:113` and `:60` and at `src/models/Tools.ts:561`. + * Five vendor answers, five `TypeError`s, none of them this deployment's doing. + * + * IT IS TRANSLATED HERE BECAUSE HERE IS WHERE IT SURFACES. {@link askVendor} wraps the + * `await vendor.*` and nothing else, so a throw reaching this function came out of the vendor's + * code by construction rather than by inspection — which is exactly the distinction the guards + * this replaces were trying to make one layer too late, in readers the SDK never let them reach. + * + * WHAT THE READER IS TOLD IS DELIBERATELY NOT A THING TO TRY. Nobody holding an admin page can + * correct the shape of a reply, and "try again" would be advice to repeat a request that will + * be answered identically; the one act that changes anything is a package upgrade, and the two + * things the route's default sends an operator to check are both already proven fine — the + * request went out and Composio replied to it. The crash itself is carried as `cause` and never + * quoted: a sentence that reads like a stack trace is what every refusal here exists not to be. + */ + case "TypeError": { + const about = call.app === null ? "" : ` for ${call.app}`; + return refusal( + `Composio's answer${about} was a shape this deployment's @composio/core could not read, so ${outcome}. The failure was raised inside the vendor's own package as it read the reply, so the request went out and Composio answered it: neither the key nor anything on this deployment's pages is what to check. ${VENDOR_SHAPE_REMEDY}`, + ); + } + default: return null; } @@ -858,29 +933,6 @@ async function askVendor( } } -/** - * Whether a thrown failure is this deployment's own bug rather than anybody's answer to a request. - * - * ONLY A PROGRAM PRODUCES THESE. A `TypeError`, a `ReferenceError` and a `RangeError` are what a - * mistake in this file or in the SDK looks like — a field read off `undefined`, a name that is not - * there — and none of them is a thing Composio can reply. The distinction matters in exactly one - * place, {@link buildComposioClient}'s delete loop, whose whole job is to keep going after a - * refusal: a loop that absorbs a bug of ours reports it as "Composio refused the rest" and tells an - * operator to press the button again about a fault that will do the same thing every time. - * - * NAMED AS A SMALL CLOSED LIST RATHER THAN GUESSED AT, because the cost of the two mistakes is not - * symmetric. A vendor error wrongly treated as our bug escapes the loop early and is reported as - * itself, which is loud and recoverable; our bug wrongly treated as a vendor refusal is swallowed - * into a count and a retry instruction, which is the state that hides. - */ -function isOurFault(error: unknown): boolean { - return ( - error instanceof TypeError || - error instanceof ReferenceError || - error instanceof RangeError - ); -} - /** * Every reason a set-wide refusal collected, in one value a `cause` can hold. * @@ -984,6 +1036,11 @@ export type ComposioVendor = { /** * The vendor's own word for "there is another page", which was being discarded at this type. * + * `unknown` RATHER THAN `string | null`, because the transformer writes `response.next_cursor + * ?? null` (`src/utils/transformers/authConfigs.ts:80`) and that is the wire's value with no + * check on it — a numeric cursor reaches {@link everyRowOf} exactly as Composio sent it, which + * is the one shape that could be read as the end of a listing that has not ended. + * * `AuthConfigListResponseSchema` carries it (`@composio/core` 0.18.1, * `src/types/authConfigs.types.ts:129-133`) and * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer @@ -993,7 +1050,7 @@ export type ComposioVendor = { * follows it until the vendor stops offering one — so a listing at {@link LISTING_LIMIT} is * no longer a fragment this file can mistake for the whole answer. */ - nextCursor?: string | null; + nextCursor?: unknown; }>; create( toolkit: string, @@ -1059,9 +1116,13 @@ export type ComposioVendor = { * renames (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:52-66`), so * `id` arrives exactly as Composio sent it inside the same warn-only `transform()` as * everything else here. An account with no id is the one shape the revoke below cannot act - * on, and it is the shape nothing was checking for. + * on, and {@link withdrawableAccounts} is what says so about it. + * + * THE ROW ITSELF IS AN OBJECT BY CONSTRUCTION, which is why only the field is in doubt. The + * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a + * row that is not an object raises inside the vendor's code and never reaches this listing. */ - items: { id?: string | null }[]; + items: { id?: unknown }[]; /** * The same truncation signal as the auth-config listing above, from the same vendor schema. * @@ -1072,7 +1133,7 @@ export type ComposioVendor = { * person's access has ended", and it used to have seen only one page of the accounts it * would have to end. {@link everyRowOf} follows it until there is none left. */ - nextCursor?: string | null; + nextCursor?: unknown; }>; /** * Mint one person's connect link against one auth config, with the page to come back to. @@ -1101,7 +1162,7 @@ export type ComposioVendor = { userId: string, authConfigId: string, options: { callbackUrl: string }, - ): Promise<{ redirectUrl?: string | null }>; + ): Promise<{ redirectUrl?: unknown }>; /** * Delete one connected account, and ask for the grant behind it to be revoked too. * @@ -1282,14 +1343,19 @@ export function buildComposioClient( userId: string, toolkit: string, statuses: VendorAccountStatus[], - ): Promise => { + ): Promise<{ id?: unknown }[]> => { /* - * EVERY PAGE, AND THE SHAPE OF EACH ONE CHECKED HERE RATHER THAN AT EITHER CALLER, so that the - * two questions cannot drift on either. `isConnected` answers a boolean and `revoke` deletes by - * id, and both a truncated listing and an unreadable one are the wrong answer to both for the - * same reason — `false` claims somebody has no account for an app when nobody looked at all of - * them, and a delete without an id claims a withdrawal that never went out. The ids are all - * either caller takes, so this hands back ids and nothing else. + * EVERY PAGE, READ HERE RATHER THAN AT EITHER CALLER, so that the two questions cannot drift on + * the one thing they do share. A truncated listing is the wrong answer to both of them for the + * same reason: `false` claims somebody has no account for an app when nobody looked at all of + * them, and a withdrawal that saw one page ends fewer grants than it reports. + * + * THE ROWS GO BACK AS ROWS, WHICH IS NARROWER THAN WHAT THIS USED TO HAND OVER. It read every + * id here, and the two callers do not want the same thing: `isConnected` is a COUNT — the id is + * nothing it reads — so taking ids on its behalf turned an account Composio described without + * one into a thrown refusal against a person who is, in fact, connected. Reading the ids is the + * withdrawal's business, and it is done there, where a row that cannot be named is something to + * report alongside the grants that were ended rather than something to stop them. */ const rows = await everyRowOf( { @@ -1314,7 +1380,7 @@ export function buildComposioClient( }), ), ); - return rows.map((row, position) => accountIdOf(row, position, toolkit)); + return rows; }; /** @@ -1395,12 +1461,19 @@ export function buildComposioClient( * collected them and both callers then read `refused[0]`, so the second and third reason existed * for the length of one expression and were then dropped. * - * AND A BUG OF OURS IS NOT A REFUSAL BY COMPOSIO, which is the other half. The catch used to take - * everything, so a `TypeError` out of this file's own code arrived in the same list as a vendor's - * 502 and came back to a person as "Composio refused the rest, press disconnect again" — advice - * about a fault that will do exactly the same thing the second time, wearing the vendor's name. - * {@link isOurFault} names the three classes only a program produces, and one of those comes - * straight back out of the loop as itself. + * EVERY FAILURE IS A REFUSAL HERE, AND THE CLASSIFICATION THAT USED TO SIT IN THIS LOOP HAS MOVED + * ONE LAYER DOWN. There was an `isOurFault` test in the catch that re-threw a `TypeError`, a + * `ReferenceError` or a `RangeError` rather than counting it, on the premise that those are what + * a program's own mistake looks like and are never "a thing Composio can reply". The premise is + * false — see the `TypeError` row in {@link vendorRefusal} for the five vendor shapes that raise + * exactly that from inside `@composio/core`'s own transformers — so its effect was inverted: a + * vendor fault escaped the loop as a bug of ours, abandoning every account after it unasked, and + * the person was handed a crash instead of a sentence. + * + * IT MOVED RATHER THAN BEING RETUNED because this loop cannot make the distinction and + * {@link askVendor} can. Every `ask` below is one `await vendor.*` wrapped by that function, so + * whether a fault came from inside the vendor's code is a fact about the call stack there, where + * here it could only ever have been guessed at from an error class. */ const askForEach = async ( items: T[], @@ -1411,7 +1484,6 @@ export function buildComposioClient( try { await ask(item); } catch (error) { - if (isOurFault(error)) throw error; refused.push(error); } } @@ -1448,25 +1520,22 @@ export function buildComposioClient( * catalogue is this deployment's own job, over the rows below. */ const fetchDirectory = async (): Promise => { - const answered: unknown = await askVendor( + /* + * TAKEN AS THE LIST IT IS DECLARED TO BE, which is the one answer in this file whose container + * the vendor settles TWICE OVER. `Toolkits.getToolkits` returns + * `transformToolkitListResponse(result)`, whose body is `response.items.map(...)` — so it is an + * array or it threw — and the whole method sits inside a try that rethrows anything at all as + * `ComposioToolkitFetchError` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:70-82`). A + * container refusal used to stand here for a null or an envelope; neither can arrive, and a + * malformed catalogue reaches a reader as that vendor class, whose own message is the bare + * "Failed to fetch toolkits" and whose remedy genuinely is the key or the status page — which + * is why {@link vendorRefusal} deliberately leaves it to the route's default answer. + */ + const toolkits = await askVendor( { outcome: "the app catalogue was not read", app: null }, () => vendor.toolkits.get({ limit: LISTING_LIMIT, sortBy: "usage" }), ); - /* - * THE CONTAINER IS SETTLED BEFORE ITS LENGTH IS MEASURED, which is why this sits above the - * truncation guard rather than beside the row checks below. `null.length` and `{}.length` are a - * crash and an `undefined` respectively, and the second of those is the worse one: it compares - * false against the ceiling, sails past the guard, and reaches a `.map` that throws a sentence - * naming a vendor method instead of naming the fault. - */ - const toolkits = Array.isArray(answered) ? answered : null; - if (toolkits === null) { - throw new BrokerRefusalError( - `Composio answered the app catalogue with ${sent(answered)} where a list of apps belongs, so there was no directory to show. ${VENDOR_SHAPE_REMEDY}`, - ); - } - if (toolkits.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE CATALOGUE, and this deployment cannot find out which it is. @@ -1519,8 +1588,15 @@ export function buildComposioClient( * The caller's page is passed through rather than defaulted here, because the seam made * `page` required precisely so that no layer could quietly supply one. See the module * comment on what an omitted limit does beyond truncating. + * + * AND THE ANSWER IS TAKEN AS THE LIST IT IS DECLARED TO BE, for the reason the catalogue + * above is. `getRawComposioTools` answers `[]` where the client returned nothing and + * otherwise returns `tools.items.map(tool => this.transformToolCases(tool))` + * (`@composio/core` 0.18.1, `src/models/Tools.ts:557-561`), so it is an array or the `.map` + * threw — and the refusal that used to stand here for a non-array was a branch the SDK made + * unreachable. */ - const answered: unknown = await askVendor( + const tools = await askVendor( { outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, app: toolkit, @@ -1532,19 +1608,6 @@ export function buildComposioClient( }), ); - const tools = Array.isArray(answered) ? answered : null; - if (tools === null) { - throw new Error( - `Composio answered ${toolkit}'s action list with ${sent(answered)} where a list of actions belongs, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * Mapped field by field rather than spread, so what crosses the seam is the four things - * `./composio` documents and not whatever else the vendor's tool object happens to carry. - * `inputParameters` in particular goes straight in front of a model — which is also why - * {@link actionOf} checks it rather than passing it on as whatever arrived. - */ return tools.map((row, position) => actionOf(row, position, toolkit)); }, @@ -1561,17 +1624,16 @@ export function buildComposioClient( * `tools.execute` resolves the same tool again internally, so this is a second request * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is * refused before anything runs, rather than discovered in an audit row afterwards. + * + * AND IT IS TAKEN AT ITS DECLARED TYPE, WHICH IS THE ONE ANSWER IN THIS FILE MOST SAFE TO DO + * THAT WITH. `getRawComposioToolBySlug` ends in `this.transformToolCases(tool)` (`@composio/core` + * 0.18.1, `src/models/Tools.ts:719`), whose last act is `ToolSchema.parse(...)` — a throwing + * parse — so what resolves here is an object satisfying that schema or a `ZodError` that + * `./composio` recognises and answers with a package remedy. An answer that is not an object, + * and a `toolkit` that is present and not an object, both die at that parse; two refusals + * stood here for exactly those and neither could be reached. */ - /* - * READ OUT OF `unknown`, LIKE EVERY LISTING, AND FOR THE REASON THE LISTINGS GIVE. This is - * one of the two answers in this file that is a single object rather than a page of them, - * and that is the whole of why it went unchecked for as long as it did: a row type widened to - * what the wire can send makes an unchecked read fail to build, and an object read straight - * off an `await` has a declared type that looks settled. It is not — `transformToolCases`' - * throwing parse is an argument about the SDK's code rather than about this deployment's, and - * the same one {@link actionOf} declines to rest on. - */ - const resolved: unknown = await askVendor( + const resolved = await askVendor( { outcome: `${call.slug} was not resolved and nothing was run`, app: call.toolkit, @@ -1581,28 +1643,23 @@ export function buildComposioClient( version: call.version, }), ); - if (!hasFields(resolved)) { - throw new Error( - `Composio answered ${sent(resolved)} where the ${call.slug} action belongs, so nothing was run: this deployment cannot show that the call it was about to make is for ${call.toolkit} rather than for some other app. ${VENDOR_SHAPE_REMEDY}`, - ); - } /* * AN UNREADABLE APP IS NOT THE SAME FACT AS NO APP, AND THEIR REMEDIES DIFFER. The mismatch * refusal below ends by telling an administrator to refresh this app's tools, which is right * for a slug recorded against a url that has since changed and useless for an SDK that has - * begun answering a different shape. So a `toolkit` that is present and not an object, or - * whose slug is not a usable name, is refused as what it is rather than folded into "no app - * at all" — where it would arrive wearing a remedy that cannot work. + * begun answering a different shape. So a toolkit whose slug is not a usable name is refused + * as what it is rather than folded into "no app at all", where it would arrive wearing a + * remedy that cannot work. + * + * AND IT IS STILL READ, DESPITE `ToolkitSchema` SPELLING THE SLUG REQUIRED, for the reason + * {@link actionOf} reads the action's own: `z.string()` is satisfied by the empty string, so + * a passing parse still admits an app with no name — which would compare unequal to every + * toolkit and refuse this call as a mismatch with nothing on the other side of the sentence. */ const answeredApp = resolved.toolkit; let ran: string | undefined; if (answeredApp !== undefined) { - if (!hasFields(answeredApp)) { - throw new Error( - `Composio sent ${sent(answeredApp)} where the app ${call.slug} belongs to should be, so nothing was run: this deployment cannot show that the call is for ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, - ); - } const named = textOf(answeredApp.slug); if (named === null) { throw new Error( @@ -1892,8 +1949,18 @@ export function buildComposioClient( * track of. `toolkits.authorize` passed `allowMultiple: true` unconditionally — the SDK * calls it a "magic function" for exactly that — which is the opposite of what this * deployment wants. + * + * THE ANSWER IS AN OBJECT BY CONSTRUCTION AND ITS ONE FIELD IS NOT. `link` builds what it + * returns with `createConnectionRequest(client, response.connected_account_id, INITIATED, + * response.redirect_url)` inside a try that turns anything thrown into + * `ComposioFailedToCreateConnectedAccountLink` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:420-453`), and that builder assembles a literal + * (`src/models/ConnectionRequest.ts:39-43`). So a refusal for "Composio answered something + * that is not an object" could not be reached — the vendor either hands over its own object + * or raises a class {@link vendorRefusal} already translates. What the builder copies across + * untouched is the url, which is why that is the field still read. */ - const request: unknown = await askVendor( + const request = await askVendor( { outcome: `this person's connection to ${toolkit} was not begun`, app: toolkit, @@ -1903,17 +1970,6 @@ export function buildComposioClient( callbackUrl: returnUrl, }), ); - /* - * THE OTHER SINGLE OBJECT, AND THE ONE WITH A PERSON WAITING ON IT. Read out of `unknown` for - * the reason the resolve in `execute` is: nothing about this answer is validated any harder - * than a listing row, and a field read off a null answer is a `TypeError` that reaches - * somebody who has just pressed Connect as though Composio were down. - */ - if (!hasFields(request)) { - throw new BrokerRefusalError( - `Composio answered ${sent(request)} where the connection it was asked to begin to ${toolkit} belongs, so there is nothing to send this person to — and whether anything was begun at Composio is not something this deployment can tell from that. ${VENDOR_SHAPE_REMEDY}`, - ); - } const redirectUrl = request.redirectUrl; /* @@ -1948,6 +2004,12 @@ export function buildComposioClient( }, async isConnected({ userId, toolkit }): Promise { + /* + * A COUNT, AND NOTHING IS READ OFF A ROW TO REACH IT. An ACTIVE account Composio described + * without an id is still an ACTIVE account: this person can act through the app, which is the + * whole of what this gate asks. Reading the id here used to turn that into a refusal, so a + * field this question never looks at decided its answer. + */ return (await accountsFor(userId, toolkit, CONNECTED)).length > 0; }, @@ -1972,7 +2034,14 @@ export function buildComposioClient( * than the one behind `isConnected`. */ const accounts = await accountsFor(userId, toolkit, REVOCABLE); - const refused = await askForEach(accounts, (id) => + /* + * THE READABLE ONES GO FIRST AND THE UNREADABLE ONES ARE REPORTED AFTERWARDS. Reading the ids + * of all of them before sending any delete is what made one unnameable row a permanent block + * on a person's disconnect — see {@link withdrawableAccounts}. Partitioning puts the withdrawal + * back in front of the report, which is the order a person's grants actually need. + */ + const { ids, nameless } = withdrawableAccounts(accounts, toolkit); + const refused = await askForEach(ids, (id) => askVendor( { outcome: `one of this person's ${toolkit} accounts was not withdrawn`, @@ -1982,7 +2051,7 @@ export function buildComposioClient( ), ); - if (refused.length > 0) { + if (refused.length > 0 || nameless.length > 0) { /* * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which @@ -2000,10 +2069,19 @@ export function buildComposioClient( * for themselves, and EVERY refusal the loop met is kept as `cause` for whoever is reading * a log rather than a page — see {@link everyRefusal} for why all of them rather than the * first, which is what this used to keep. + * + * AND THE TWO WAYS A GRANT SURVIVES ARE NOT THE SAME ADVICE. An account Composio refused is + * one a second press reaches, which is what "disconnecting again" is worth saying about. An + * account it described with no id is not: the row will be as unnameable next time, so the + * only honest instruction is the one that does not run through this page at all. */ + const left = + nameless.length === 0 + ? "Disconnecting again asks only for the accounts that are left." + : `Composio described ${nameless.length} of them with no id at all, so this deployment has no way to name those in a withdrawal and disconnecting again meets them unchanged: removing them in Composio's own dashboard is what ends them.`; throw new BrokerRefusalError( - `Composio withdrew ${accounts.length - refused.length} of this person's ${accounts.length} accounts for ${toolkit} and refused the rest, so their access to it has not ended. Disconnecting again asks only for the accounts that are left.`, - { cause: everyRefusal(refused) }, + `Composio withdrew ${ids.length - refused.length} of this person's ${accounts.length} accounts for ${toolkit} and did not withdraw the rest, so their access to it has not ended. ${left}`, + { cause: everyRefusal([...refused, ...nameless]) }, ); } diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 0645d005c..0992f08bd 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -166,6 +166,29 @@ async function failureOf(work: Promise): Promise { const A_CRASH = /is not a function|(?:undefined|null) is not an object|is not iterable|cannot read propert/i; +/** + * Every sentence one failure carries, its own first and then the ones hanging off it. + * + * A COUNT IS A SENTENCE WITH ITS REASONS SOMEWHERE ELSE, which is why a question about what a reader + * was told cannot always be asked of `message` alone. `revoke` and `deleteAuthConfig` withdraw a SET + * and make the count their whole message on purpose — it is the part a reader can act on — and hang + * every refusal the loop met on `cause`, as one error or as an `AggregateError` of them. Asking only + * the first line there would grade those two methods on a wording that is deliberately not where + * their detail lives. + */ +function everythingSaidBy(error: unknown, depth = 0): string[] { + if (!(error instanceof Error) || depth > 4) return []; + const carried = + error instanceof AggregateError + ? error.errors.flatMap((one) => everythingSaidBy(one, depth + 1)) + : []; + return [ + error.message, + ...carried, + ...everythingSaidBy(error.cause, depth + 1), + ]; +} + describe("the page size this file is written against", () => { test("the module's ceiling is still the number every assertion here spells out", () => { // The one place the imported constant is read. Changing `LISTING_LIMIT` reddens exactly this @@ -488,6 +511,83 @@ describe("executing an action", () => { ); expect(executed).toEqual([]); }); + + /** + * WHOSE CALL IT IS, WHICH NOTHING IN THIS FILE WAS ASKING. + * + * Every test around this one is about a call being REFUSED, and each asserts that nothing went + * out. Not one asserted what goes out when the call is allowed — so the three fields that decide + * what a successful call MEANS were covered by nobody. `userId` is the whole of a tool call's + * attribution to a person: it is what Composio resolves to a connected account, so replacing it + * with a literal runs one person's action against somebody else's mailbox, and the audit row this + * deployment writes afterwards names the wrong human. `version` is what stops a recorded action + * being run at whatever Composio publishes today. `arguments` is the call itself. + * + * ASSERTED AS THE WHOLE CALL RATHER THAN FIELD BY FIELD, because an extra field on this request is + * as much a finding as a missing one — `allowMultiple`, a session id, a modifier — and a per-field + * assertion is blind to every one of them. + * + * THE RESOLVE IS ASSERTED TOO, for the reason the execute is. It is the round trip this adapter + * spends on purpose, and the version it carries is what decides WHICH definition of the action the + * app-mismatch check below is made against. + */ + test("a call that runs carries the person, the version and the arguments", async () => { + const resolved: unknown[] = []; + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async (...call: unknown[]) => { + resolved.push(call); + return { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + toolkit: { slug: "gmail" }, + }; + }, + execute: async (...call: unknown[]) => { + ran.push(call); + return { data: { messages: [] }, error: null, successful: true }; + }, + }, + }), + ); + + const answer = await actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + // DELIBERATELY NOT THE `user_1` EVERY OTHER FIXTURE HERE USES. A literal standing in for + // the caller's id is the mutation this test exists to catch, and the file's own house id is + // the one literal a mutation would plausibly be — so a person named after nothing else is + // what makes the assertion able to tell attribution from coincidence. + userId: "user_whose_mailbox_this_is", + version: "20260903_00", + }, + { max_results: 5 }, + ); + + expect(resolved).toEqual([ + ["GMAIL_FETCH_EMAILS", { version: "20260903_00" }], + ]); + expect(ran).toEqual([ + [ + "GMAIL_FETCH_EMAILS", + { + arguments: { max_results: 5 }, + userId: "user_whose_mailbox_this_is", + version: "20260903_00", + }, + ], + ]); + // And the vendor's answer crosses the seam as itself: this adapter adds nothing to a result and + // takes nothing off one. + expect(answer).toEqual({ + data: { messages: [] }, + error: null, + successful: true, + }); + }); }); /** @@ -987,6 +1087,76 @@ describe("withdrawing one person's grants", () => { broker.revoke({ userId: "user_1", toolkit: "gmail" }), ).rejects.toBeInstanceOf(BrokerRefusalError); }); + + /** + * AN ACCOUNT THIS DEPLOYMENT CANNOT NAME MUST NOT STOP IT WITHDRAWING THE ONES IT CAN. + * + * The id check used to run over every row BEFORE any delete went out, so one row whose id + * Composio omitted threw ahead of the first withdrawal — and the next attempt met the same row + * and threw in the same place. A person with three grants and one unreadable row could never + * withdraw any of them, for ever, and the page told them to try again. + * + * THE TWO FAILURES THIS SITS BETWEEN ARE BOTH WORSE, which is why the answer is neither of them. A + * delete sent with `undefined` where an id belongs is a request Composio may read as anything, + * followed by a `true` and an audit row saying this person's access ended. Refusing before + * anything goes out is a permanent block over a row nobody can remove from here. What a person is + * owed is the withdrawal of every grant this deployment CAN name, and a sentence counting what + * was left — which is a state they can act on, by removing the rest in Composio's own dashboard. + */ + test("an account with no id does not stop the accounts beside it being withdrawn", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: null }, { id: "ca_3" }], + }), + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // Both readable grants gone, and no delete sent for the row that had no id: a withdrawal of + // `undefined` is the request this whole guard exists to stop being made. + expect(deleted).toEqual(["ca_1", "ca_3"]); + // STILL A FAILURE, because `store.ts` deletes the `composio_connections` row on a `true` and + // that row is the only thing in this deployment naming which app this person connected. + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).toMatch(/2 of this person's 3 accounts/); + // And the reason the third could not be asked about is named, because "try again" is not the + // remedy for it and the reader would otherwise be given a count with no way to read it. + expect(failure.message).not.toMatch(A_CRASH); + expect((failure.cause as Error).message).toMatch(/id/); + }); + + /** + * THE GATE IS A COUNT AND WAS ASKING FOR AN ID IT NEVER USES. + * + * `isConnected` answers whether this person holds an ACTIVE account for an app, which is a + * question about how many rows came back — the id is the revoke's business and nothing this + * question reads. Taking the ids anyway meant an account Composio described without one turned a + * true answer into a thrown refusal: the person IS connected, and the gate that refuses their run + * says so on the strength of a field it was never going to look at. + */ + test("an account the vendor described without an id still counts as connected", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { list: async () => ({ items: [{}] }) }, + }), + () => 1_000_000, + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + }); }); /** @@ -1168,8 +1338,26 @@ describe("what a vendor failure becomes on its way out of the seam", () => { * `ComposioRequestCancelledError` — and each one is the vendor saying WHICH condition happened, * in a message it wrote for a reader. None of it is nested where {@link vendorSentence} looks, so * a seam that lets one through unexamined converts an explanation into "check the key". + * + * A SHAPE THE SDK ITSELF COULD NOT READ is the third, and it is the one three rounds of review + * wrote guards for at the wrong layer. `@composio/core` 0.18.1 does not hand a malformed listing + * over to be inspected: its own transformers dereference the answer first, so + * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list, and + * `authConfig.toolkit.logo` off a row that is not an object, raise a bare `TypeError` from inside + * the vendor's code before any reader here is reached (`src/utils/transformers/authConfigs.ts:79`, + * `:41`, and the same two shapes in `connectedAccounts.ts:113`, `:60` and `models/Tools.ts:561`). + * A `TypeError` arriving out of an `await vendor.*` is therefore Composio's shape and not a bug of + * this deployment's, and what it needs is the same thing every other vendor failure needs: a + * sentence. */ - const THROWN = [ + const THROWN: { + kind: string; + raise: () => Error; + /** What the sentence must say, where the kind of failure settles a remedy. */ + demands?: RegExp; + /** What the sentence must not have picked up from the error it translated. */ + quiets?: RegExp; + }[] = [ { kind: "an outage", raise: () => new Error("socket hang up"), @@ -1184,6 +1372,17 @@ describe("what a vendor failure becomes on its way out of the seam", () => { { name: "ComposioMultipleConnectedAccountsError" }, ), }, + { + kind: "a shape the SDK itself could not read", + raise: () => + new TypeError( + "undefined is not an object (evaluating 'response.items.map')", + ), + // One remedy, and it is a package rather than a page: nobody operating this deployment can + // correct what Composio answers, and the key is demonstrably fine — the call went out. + demands: /@composio\/core/, + quiets: A_CRASH, + }, ]; type SeamMethod = keyof ComposioBroker | keyof ComposioActions; @@ -1226,17 +1425,28 @@ describe("what a vendor failure becomes on its way out of the seam", () => { because: "The account listing did not answer. This is a gate rather than a page, and its caller refuses the run either way.", }, + /* + * THE TWO REASONS BELOW USED TO BE FALSE, AND THIS IS THE CASE THE ALLOW-LIST CANNOT CATCH BY + * ITSELF. Its assertion asks whether an AUTHORED sentence reached the reader, so an entry whose + * `because` mis-describes what the reader gets INSTEAD stays green for ever. Both of these said + * the app gets named somewhere downstream; neither does. `listingSentence` returns the thrown + * message verbatim whenever it is neither a schema mismatch nor the vendor's placeholder + * (`./composio`), and `callTool` does the same on the execute path — so "socket hang up" reaches + * an administrator's Plugins page and a model's context exactly like that, bare, naming no app. + * The entries now say so, and the `allowed` branch of the test asserts the message travels + * untouched, which is what makes the claim hold itself up. + */ { method: "listActions", kind: "an outage", because: - "The action listing did not answer. `./composio`'s own `listingSentence` names the app in what it records, so what is missing here is the vendor's reason and not the subject.", + "The action listing did not answer. `./composio`'s `listingSentence` passes the thrown message on verbatim, so the reader gets the vendor's own bare words with no app named — which is all there is, because a listing that did not answer leaves nothing else to say.", }, { method: "execute", kind: "an outage", because: - "The call itself failed with nothing said. `callTool` already refuses to pass the vendor's placeholder on and names the app instead.", + "The call itself failed with nothing said. `callTool` passes the thrown message on verbatim unless it is the vendor's placeholder, so the reader gets the vendor's own bare words — the app is named by the connection they pressed, not by this sentence.", }, ]; const UNEXPLAINABLE = new Set( @@ -1355,10 +1565,11 @@ describe("what a vendor failure becomes on its way out of the seam", () => { ? "nothing to say, and that is named as an outage" : "a sentence" }`, async () => { + const raised = thrown.raise(); const client = buildComposioClient( fakeVendor( seamCase.vendor(async () => { - throw thrown.raise(); + throw raised; }), ), () => 1_000_000, @@ -1371,10 +1582,34 @@ describe("what a vendor failure becomes on its way out of the seam", () => { // The allow-list's other direction. This entry claims the reader cannot be told anything // useful here; the day that stops being true, this line is what says so. expect(sentence).toBeNull(); + /* + * AND WHAT THE READER DOES GET, WHICH IS THE HALF THE `because` COLUMN KEPT GETTING WRONG. + * Two entries above used to claim the reader is left with a named app and only the reason + * missing; the truth is that the vendor's own bare message travels on untouched — through + * `listingSentence` into an app's `lastError`, and through `callTool` into a result — with + * nothing added and no app named. That is what "unexplainable" costs here, so the entries + * say so and this line holds them to it: the day a method starts wrapping the failure, the + * `because` beside it stops being true and this reddens rather than outliving it. + */ + expect(escaped.message).toBe(raised.message); return; } expect(sentence).not.toBeNull(); expect(sentence?.trim()).not.toBe(""); + /* + * THE REMEDY IS ASKED OF THE WHOLE FAILURE AND NOT OF ITS FIRST LINE, because two of these + * methods deliberately make a COUNT their sentence. `revoke` and `deleteAuthConfig` are + * withdrawing a SET, and what a reader can act on there is how many of it survived; every + * reason the loop met travels on `cause` instead, which is the only place the detail lives. + * So what has to be true is that the remedy reached the failure somewhere, and reading the + * chain is what says so for both shapes at once. + */ + if (thrown.demands) { + expect(everythingSaidBy(escaped).join("\n")).toMatch(thrown.demands); + } + // The vendor's crash is carried as `cause` and never quoted: a sentence that reads like a + // stack trace is the thing every refusal in this seam was written to stop being. + if (thrown.quiets) expect(sentence).not.toMatch(thrown.quiets); }); } } @@ -1646,15 +1881,37 @@ describe("what the delete loop keeps of the failures it meets", () => { expect((failure.cause as Error).message).toBe("Composio refused that one."); }); - test("a fault in this adapter escapes rather than being counted as Composio refusing", async () => { + /** + * A `TypeError` OUT OF A VENDOR CALL IS COMPOSIO'S SHAPE, AND THIS TEST USED TO ASSERT THE REVERSE. + * + * It was written around `isOurFault`, whose premise was that a `TypeError` is what a mistake in + * this file looks like and never something "Composio can reply". Running `@composio/core` 0.18.1 + * falsifies that premise outright: a bare list where `{ items }` belongs, an envelope whose + * `items` is a string, and a row that is not an object all raise a bare `TypeError` from inside + * the vendor's own transformers, before any code here is reached. So the classification was + * inverted — a vendor fault escaped the loop as a bug of ours, abandoning every account after it + * unasked, and the reader was told nothing at all. + * + * WHICH IS WHY THE GUARD MOVED RATHER THAN BEING RETUNED. `askVendor` wraps the `await vendor.*` + * and nothing else, so "the fault surfaced inside the vendor's code" is a fact about the call + * stack there rather than a guess from an error class; the loop no longer has to tell the two + * apart, because by the time an error reaches it the question has been answered one layer down. + */ + test("a shape the SDK could not read is Composio refusing, and the loop goes on", async () => { + const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], + }), delete: async (id: string) => { if (id === "ca_2") { - throw new TypeError("held.slug is not a function"); + throw new TypeError( + "undefined is not an object (evaluating 'x')", + ); } + deleted.push(id); }, }, }), @@ -1665,11 +1922,14 @@ describe("what the delete loop keeps of the failures it meets", () => { broker.revoke({ userId: "user_1", toolkit: "gmail" }), ); - // Out as itself, and NOT as a count sentence telling somebody to press disconnect again about a - // fault that will do exactly the same thing the second time. - expect(failure).toBeInstanceOf(TypeError); - expect(failure.message).toBe("held.slug is not a function"); - expect(brokerSentence(failure)).toBeNull(); + // The third account is the one the old classification abandoned: a `TypeError` at the second + // escaped the loop, so a live grant was never asked about and nothing in the answer said so. + expect(deleted).toEqual(["ca_1", "ca_3"]); + expect(brokerSentence(failure)).toMatch(/2 of this person's 3 accounts/); + // And the crash is carried rather than quoted: the sentence a person reads is this + // deployment's, and the vendor's own words are on `cause` for whoever is reading a log. + expect(failure.message).not.toMatch(A_CRASH); + expect((failure.cause as Error).cause).toBeInstanceOf(TypeError); }); test("the same two promises hold for the loop that removes an app's configs", async () => { @@ -1701,36 +1961,50 @@ describe("what the delete loop keeps of the failures it meets", () => { /** * WHAT EACH VENDOR LISTING IS ALLOWED TO BE, given that this file's types only assert its shape. * - * A TYPESCRIPT INTERFACE OVER A WIRE VALUE IS AN ASSERTION AND NOT A CHECK, which is the whole - * reason this table exists. `ComposioVendor` declares these four listings with required fields, and - * the SDK that fills them in is warn-only: `@composio/core`'s own `transform()` logs a `safeParse` - * failure and then returns the unvalidated object, so the shapes below are what actually arrives - * rather than what could theoretically arrive. Each one has been observed reaching the adapter — a - * toolkit with no name, an auth config whose name is null, a connected account with no id, a - * category list containing null. + * A TYPESCRIPT INTERFACE OVER A WIRE VALUE IS AN ASSERTION AND NOT A CHECK, which is the reason + * this table exists — but only for the fields, and that distinction is the whole of what a round of + * running `@composio/core` 0.18.1 established. Three of these four listings go through the SDK's + * warn-only `transform()`, which logs a `safeParse` failure and returns the unvalidated object, so a + * toolkit whose name is null, an auth config whose id is missing and a connected account with no id + * all arrive here exactly as they came off the wire. Those are the shapes below, and each one was + * OBSERVED arriving rather than reasoned about. + * + * WHAT IS NOT BELOW ANY MORE IS THE CONTAINER, AND THAT IS A CORRECTION RATHER THAN A GAP. This + * table used to open each listing with "nothing at all" and "a bare list where an envelope belongs", + * on the claim that those had been observed too. They had not, and they cannot be: every one of the + * SDK's list transformers dereferences the answer before returning it — `response.items.map(...)` + * for the two paged listings and for the tool list, `item.meta.categories` for the catalogue — so a + * container of the wrong shape dies inside the vendor's code, as a bare `TypeError` on three paths + * and as `ComposioToolkitFetchError` on the catalogue, before any reader in the adapter is reached. + * That failure is a vendor-shape fault and is answered as one, which is asserted next door in the + * seam table rather than pretended at here. * * WHAT A MALFORMED ANSWER MUST NOT BECOME IS A CRASH REPORT. `f.toLowerCase is not a function` and - * `null is not an object` are what these produce today: the first as an unhandled 500 on a live - * route, the rest as a 502 telling an administrator to check a key that is perfectly good. So each - * case asserts that the call refuses, and that the refusal is a sentence rather than the name of a - * method that was not there. + * `null is not an object` are what these produce without a reader: the first as an unhandled 500 on + * a live route, the rest as a 502 telling an administrator to check a key that is perfectly good. So + * each case asserts that the call refuses, and that the refusal is a sentence rather than the name + * of a method that was not there. * - * ASSERTED AS A PROPERTY AND NOT AS A WORDING, because the fix does not exist yet and a test that - * pinned the sentence would pin whichever one got written. What is required is that a reader be - * told something, and that nothing be sent to the vendor on the strength of a field the answer did - * not carry. + * ASSERTED AS A PROPERTY AND NOT AS A WORDING, because what is required is that a reader be told + * something, and that nothing be sent to the vendor on the strength of a field the answer did not + * carry. The second half is the one that had rotted: `sent` was a literal empty array in half these + * cases and no stub ever wrote to it, so the assertion holding it was a tautology dressed as a + * check. It is now every vendor call the adapter made, in order, and each listing says which single + * call that should be. */ describe("a vendor listing that is not the shape it is declared to be", () => { - /** A recorder for what went out AFTER the malformed answer came back, which must be nothing. */ + /** Every vendor method the adapter reached, named, in the order it was called. */ type Probe = { parts: Parameters[0]; - sent: unknown[]; + sent: string[]; }; const MALFORMED_LISTINGS: { listing: string; answers: { shape: string; answer: unknown }[]; probe: (answer: unknown) => Probe; + /** The one call this listing's fault is allowed to have made before it refused. */ + asked: string[]; ask: (client: ReturnType) => Promise; /** Whether the refusal has to be one a route passes through as this deployment's own. */ authored: boolean; @@ -1738,46 +2012,66 @@ describe("a vendor listing that is not the shape it is declared to be", () => { { listing: "the app catalogue", answers: [ - { shape: "nothing at all", answer: null }, { - shape: "an envelope where a bare list belongs", - answer: { items: [] }, + shape: "a row whose slug is null", + answer: [{ slug: null, name: "Gmail", meta: {} }], }, - { shape: "a row that is not an object", answer: ["gmail"] }, { - shape: "a row with no slug", - answer: [{ name: "Gmail", meta: {} }], + shape: "a row whose name is null", + answer: [{ slug: "gmail", name: null, meta: {} }], + }, + { + shape: "a category the vendor named with nothing", + answer: [ + { slug: "gmail", name: "Gmail", meta: { categories: [{}] } }, + ], }, ], - probe: (answer) => ({ - parts: { toolkits: { get: async () => answer } }, - sent: [], - }), + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + toolkits: { + get: async () => { + sent.push("toolkits.get"); + return answer; + }, + }, + }, + sent, + }; + }, + asked: ["toolkits.get"], ask: ({ broker }) => broker.listApps(), authored: true, }, { listing: "this app's auth configs", answers: [ - { shape: "nothing at all", answer: null }, - { shape: "a bare list where an envelope belongs", answer: [] }, - { - shape: "a row that is not an object", - answer: { items: ["ac_ours"] }, - }, { shape: "a row with no name", answer: { items: [{ id: "ac_ours", status: "ENABLED" }] }, }, + { + shape: "a row whose id is null", + answer: { + items: [{ id: null, name: "Linear (OpenBot)", status: "ENABLED" }], + }, + }, ], probe: (answer) => { - const sent: unknown[] = []; + const sent: string[] = []; return { parts: { - authConfigs: { list: async () => answer }, + authConfigs: { + list: async () => { + sent.push("authConfigs.list"); + return answer; + }, + }, connectedAccounts: { - link: async (...call: unknown[]) => { - sent.push(call); + link: async () => { + sent.push("connectedAccounts.link"); return { redirectUrl: "https://backend.composio.dev/s/a-link" }; }, }, @@ -1785,6 +2079,7 @@ describe("a vendor listing that is not the shape it is declared to be", () => { sent, }; }, + asked: ["authConfigs.list"], ask: ({ broker }) => broker.authorize({ userId: "user_1", @@ -1796,25 +2091,32 @@ describe("a vendor listing that is not the shape it is declared to be", () => { { listing: "this person's accounts", answers: [ - { shape: "nothing at all", answer: null }, - { shape: "a bare list where an envelope belongs", answer: [] }, - { shape: "a row that is not an object", answer: { items: ["ca_1"] } }, { shape: "a row with no id", answer: { items: [{}] } }, + { shape: "a row whose id is null", answer: { items: [{ id: null }] } }, + { shape: "a row whose id is a number", answer: { items: [{ id: 7 }] } }, ], probe: (answer) => { - const sent: unknown[] = []; + const sent: string[] = []; return { parts: { connectedAccounts: { - list: async () => answer, - delete: async (...call: unknown[]) => { - sent.push(call); + list: async () => { + sent.push("connectedAccounts.list"); + return answer; + }, + delete: async () => { + sent.push("connectedAccounts.delete"); }, }, }, sent, }; }, + // The whole listing is unreadable here, so there is no readable grant to withdraw and the + // delete must not be reached at all — a withdrawal of `undefined` is the request this refusal + // exists to stop being made. Where SOME rows are readable the answer is different and the + // test for it sits beside `revoke`: those go, and the sentence counts what was left. + asked: ["connectedAccounts.list"], ask: ({ broker }) => broker.revoke({ userId: "user_1", toolkit: "gmail" }), authored: true, @@ -1822,24 +2124,29 @@ describe("a vendor listing that is not the shape it is declared to be", () => { { listing: "this app's actions", answers: [ - { shape: "nothing at all", answer: null }, - { - shape: "an envelope where a bare list belongs", - answer: { items: [] }, - }, + // The one row fault `ToolSchema` admits: `z.string()` is satisfied by the empty string, and + // an action's slug is `mcp_tools.name` — NOT NULL and half the primary key — as well as + // what a later call sends back to Composio. { - shape: "a row that is not an object", - answer: ["GMAIL_FETCH_EMAILS"], - }, - { - shape: "a row with no slug", - answer: [{ description: "Fetch emails from Gmail." }], + shape: "an action whose slug is empty", + answer: [{ slug: "", name: "Fetch emails" }], }, ], - probe: (answer) => ({ - parts: { tools: { getRawComposioTools: async () => answer } }, - sent: [], - }), + probe: (answer) => { + const sent: string[] = []; + return { + parts: { + tools: { + getRawComposioTools: async () => { + sent.push("tools.getRawComposioTools"); + return answer; + }, + }, + }, + sent, + }; + }, + asked: ["tools.getRawComposioTools"], ask: ({ actions }) => actions.listActions("gmail", { limit: WHOLE_LISTING }), // `./composio` authors this one's sentence, not `./broker`: a listing failure is recorded in @@ -1867,8 +2174,10 @@ describe("a vendor listing that is not the shape it is declared to be", () => { } // Nothing was sent to the vendor on the strength of a field the answer did not carry: an // id-less account reaching the delete is a request to withdraw `undefined`, which the - // vendor is free to read as anything at all. - expect(probe.sent).toEqual([]); + // vendor is free to read as anything at all. The listing itself is what SHOULD have gone + // out, so the assertion names it rather than asking for silence — an adapter that stopped + // asking at all would satisfy an empty expectation perfectly. + expect(probe.sent).toEqual(listing.asked); }); } } @@ -2147,14 +2456,22 @@ describe("a listing that arrived with a cursor still outstanding", () => { }); /** - * The two vendor answers that are single objects, and were therefore never checked. + * The two vendor answers that are single objects, and what is actually in doubt about each. + * + * THE CONTAINER IS NOT, WHICH IS A CORRECTION TO WHAT THESE USED TO ASSERT. Both answers were once + * fed a bare `null` on the argument that a single object read straight off an `await` has a declared + * type that only looks settled. Running `@composio/core` 0.18.1 settles it for real, on both paths + * and for different reasons: `getRawComposioToolBySlug` ends in a throwing `ToolSchema.parse` + * (`src/models/Tools.ts:719`), and `connectedAccounts.link` builds its answer with + * `createConnectionRequest(...)` inside a try that turns everything else into + * `ComposioFailedToCreateConnectedAccountLink` (`src/models/ConnectedAccounts.ts:420-453`). So + * neither can hand over something that is not an object, and the refusals that stood for that were + * branches no answer could reach. * - * NEITHER PRODUCED A COMPILE ERROR AND NEITHER HAD A RED TEST, which is the same fact twice over. - * Every other read in the adapter goes through a listing whose element type was widened to what the - * wire can send, so an unchecked read of one fails to build; a single object read straight off an - * `await` has a declared type that looks settled. It is not: `transform()` is warn-only on both of - * these paths as well, so the declaration says what Composio MEANS to send here exactly as it does - * everywhere else in this file. + * WHAT IS IN DOUBT IS EACH ONE'S ONE COPIED FIELD. The tool's `toolkit.slug` is a required string + * that `z.string()` lets be empty, and the request's `redirectUrl` is `response.redirect_url` + * carried across untouched by a builder that validates nothing. Those are what these tests are + * about now, and both are shapes Composio can actually send. */ describe("a vendor answer that is one object rather than a listing", () => { /** The call `execute` is made with in this section, which is a mismatch test's whole setup. */ @@ -2165,12 +2482,28 @@ describe("a vendor answer that is one object rather than a listing", () => { version: "20260903_00", }; - test("an unreadable resolve is refused as one, and nothing is run", async () => { + /** + * A RESOLVE THAT FAILED IS A CALL THAT MUST NOT RUN, WHICH IS THE HALF WORTH KEEPING. + * + * This test used to hand the resolve a bare `null` and assert that reading a field off it was + * refused rather than crashed. `@composio/core` 0.18.1 cannot answer that: `getRawComposioToolBySlug` + * ends in `transformToolCases`, whose last act is a throwing `ToolSchema.parse` + * (`src/models/Tools.ts:719`, `:193`), so a null answer raises `TypeError: null is not an object + * (evaluating 'tool.input_parameters')` and anything else that is not a tool raises a `ZodError` — + * neither of which reaches a reader in the adapter. The guard it covered is gone; the property + * underneath it is not, and this is that property asked of an input the vendor can actually + * produce. + */ + test("a resolve the SDK could not read refuses, and nothing is run", async () => { const ran: unknown[] = []; const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioToolBySlug: async () => null, + getRawComposioToolBySlug: async () => { + throw new TypeError( + "null is not an object (evaluating 'tool.input_parameters')", + ); + }, execute: async (...call: unknown[]) => { ran.push(call); return { successful: true, data: {} }; @@ -2181,22 +2514,27 @@ describe("a vendor answer that is one object rather than a listing", () => { const refusal = await failureOf(actions.execute(GMAIL_CALL, {})); - // `resolved.toolkit?.slug` off a null answer is a `TypeError` wearing the vendor's name, and - // `./composio` puts whatever comes out of here into an app's `lastError` for an administrator - // to read. + // `./composio` puts whatever comes out of here into a model's context and an audit row, so the + // crash is carried rather than quoted and the sentence names the one act that changes anything. expect(refusal.message).not.toMatch(A_CRASH); expect(refusal.message).toMatch(/GMAIL_FETCH_EMAILS/); + expect(refusal.message).toMatch(/@composio\/core/); expect(ran).toEqual([]); }); - test("an app the vendor sent unreadably is not reported as no app at all", async () => { + test("an app the vendor named with nothing is not reported as no app at all", async () => { const ran: unknown[] = []; const { actions } = buildComposioClient( fakeVendor({ tools: { getRawComposioToolBySlug: async () => ({ slug: "GMAIL_FETCH_EMAILS", - toolkit: "gmail", + name: "Fetch emails", + // The one toolkit fault a passing `ToolSchema.parse` still admits: `ToolkitSchema` + // spells the slug required and `z.string()` is satisfied by the empty string. A bare + // string where `{ slug }` belongs — what this fixture used to be — raises a `ZodError` + // inside the SDK and never arrives. + toolkit: { slug: "", name: "Gmail" }, }), execute: async (...call: unknown[]) => { ran.push(call); @@ -2213,36 +2551,13 @@ describe("a vendor answer that is one object rather than a listing", () => { * action to no app at all" is a statement about the action, and the sentence carrying it tells * an administrator to refresh the app's tools — right for a slug recorded against a url that * has since changed, and useless for an SDK that has begun answering a different shape. The - * toolkit here is PRESENT: it arrived as a bare string where `{ slug }` belongs. + * toolkit here is PRESENT and its name is blank, which is neither of those. */ expect(refusal.message).not.toMatch(A_CRASH); expect(refusal.message).not.toMatch(/no app at all/); expect(ran).toEqual([]); }); - test("a link answered with something other than an object is refused in a sentence", async () => { - const { broker } = buildComposioClient( - fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, - connectedAccounts: { link: async () => null }, - }), - ); - - const refusal = await failureOf( - broker.authorize({ - userId: "user_1", - toolkit: "linear", - returnUrl: RETURN_URL, - }), - ); - - // A route answers this to a person who has just pressed Connect, so it has to be a refusal this - // deployment authored rather than a `TypeError` that reads to them as Composio being down. - expect(refusal).toBeInstanceOf(BrokerRefusalError); - expect(refusal.message).not.toMatch(A_CRASH); - expect(refusal.message).toMatch(/linear/); - }); - test("a redirect that is not a url is refused rather than handed to a browser", async () => { const { broker } = buildComposioClient( fakeVendor({ @@ -2332,53 +2647,50 @@ describe("what a malformed field of a row actually costs", () => { }); } - const ACTION_ROWS: { fault: string; row: unknown; names: RegExp }[] = [ - { - fault: "a description that is not text", - // What a model reads to decide whether to call the action at all. - row: { slug: "GMAIL_FETCH_EMAILS", description: 12 }, - names: /description/, - }, - { - fault: "input parameters that are not an object", - /* - * THE ONE THAT MATTERS MOST. This value goes in front of a model as Composio's own JSON - * Schema for the action, and a string of JSON is not a schema. What a model does with a - * parameter list it cannot read is invent one, and the call that follows is made with - * arguments nobody wrote. - */ - row: { slug: "GMAIL_FETCH_EMAILS", inputParameters: '{"type":"object"}' }, - names: /schema/, - }, - { - fault: "tags that are not all labels", - // The tags are the only thing that says whether an action reads or writes. - row: { slug: "GMAIL_FETCH_EMAILS", tags: ["readOnlyHint", 7] }, - names: /tags/, - }, - { - fault: "a version that is not text", - // The version travels with every call made to the action. - row: { slug: "GMAIL_FETCH_EMAILS", version: 20260903 }, - names: /version/, - }, - ]; - - for (const { fault, row, names } of ACTION_ROWS) { - test(`an action row with ${fault} stops the listing`, async () => { - const { actions } = buildComposioClient( - fakeVendor({ tools: { getRawComposioTools: async () => [row] } }), - ); + /** + * THE ACTION ROW HAS ONE FAULT LEFT, AND FOUR TESTS HERE WERE ABOUT SHAPES IT CANNOT HAVE. + * + * A description that is not text, an `inputParameters` that is a string of JSON, tags that are + * not all labels and a version that is a number each had a row in a table beside this one, and + * each was answered by a refusal in the adapter. Running `@composio/core` 0.18.1 shows all four + * dying one layer earlier: `transformToolCases` ends in `ToolSchema.parse(...)` — a throwing + * parse, not the warn-only `transform()` the other three listings go through + * (`src/models/Tools.ts:193`) — and both calls this adapter makes run through it (`:561`, `:719`). + * Every one of those four fixtures produces a `ZodError` from inside the SDK, which `./composio`'s + * `isSchemaMismatch` already recognises and answers with the same package remedy. So the tests + * were green over guards nothing could reach, which is the worst of the three states: a reader + * takes both the guard and the test as proof the path is watched. + * + * WHAT `ToolSchema` LEAVES OPEN IS THE ONE BELOW. `slug: z.string()` is satisfied by the empty + * string, and this slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL and half + * that table's primary key, it is what a grant points at, and it is what a later call sends back + * to Composio. That is the fault this listing still has to refuse, and it is the only one. + */ + test("an action whose slug is blank stops the listing rather than being written", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioTools: async () => [ + { slug: "GMAIL_FETCH_EMAILS", name: "Fetch emails" }, + { slug: " ", name: "Send mail" }, + ], + }, + }), + ); - const refusal = await failureOf( - actions.listActions("gmail", { limit: WHOLE_LISTING }), - ); + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); - expect(refusal.message).not.toMatch(A_CRASH); - expect(refusal.message).toMatch(names); - expect(refusal.message).toMatch(/GMAIL_FETCH_EMAILS/); - }); - } + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/slug/); + // Which row, because an administrator reading this off a Plugins page has a listing of sixty + // actions and no other way to tell which of them Composio named with nothing. + expect(refusal.message).toMatch(/row 2/); + // And what it cost them, which is nothing: a refusal here leaves the actions already recorded + // for the app in place rather than replacing them with a listing one row short. + expect(refusal.message).toMatch(/tools already held are untouched/); + }); test("a nameless config stops the removal rather than being left standing", async () => { const deleted: unknown[] = []; From 105dbe82e54bf15a0697088ee0c6d359a3690533 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 19:43:36 +0200 Subject: [PATCH 144/215] Read Composio's verdict on a withdrawal, and stop calling an unclaimed config a clean removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more ways this adapter told an administrator that somebody's access had been withdrawn when it had not. The account delete answers with a required `success: boolean` — "indicates whether the connected account was successfully deleted", @composio/client 0.1.0-alpha.76, resources/connected-accounts.d.ts:7445 — and the return was typed `unknown` and read for nothing, on the argument that no field on the reply could support a stronger claim than "we asked". That is true of the revocation and false of the delete. A 200 carrying `success: false` is Composio saying it did not delete the account, so the background job the flag asks for was never started either; `revoke` counted it as withdrawn, answered `true`, and store.ts wrote `vendorRevocationRequested: true` beside a grant still live at the provider. The verdict is now read, and an account Composio declined is counted with the accounts it refused, so the existing partial-withdrawal refusal reports it. "Asked" is not blurred into "done": `success: true` still claims only that the account is gone at the broker and the job was started. `revoke_job_id` is deliberately not read — the declaration marks it optional, so a guard on it would turn an accepted withdrawal into a permanent failure the first time Composio stopped sending it. A verdict that is absent or not a boolean gets its own sentence and the package remedy, because that is a fact about @composio/core and not about the account. Removing an app was quiet whenever it found no config carrying the (OpenBot) suffix — and "nothing of ours" and "nothing at all" reached the caller as the same empty array. Rename a config in Composio's dashboard, or edit the app's title past the suffix, and the object this deployment made stops being recognisable: deleteAuthConfig returned normally, removeServer deleted the app's row, and the config and every grant on it outlived the removal with nothing in this deployment naming them. The listing now hands back the unclaimed rows too, and a removal that could claim none of them refuses instead of reporting success. It does not delete what it cannot claim — a readable name without the suffix is as likely to be an operator's own dashboard work, with their scopes and every account connected against it — and it does not fire where a config of ours was found and dropped beside somebody else's, which is the narrower promise ComposioBroker.deleteAuthConfig states. The app's row survives the throw, so the app stays on its Plugins page and stays removable, which a silent success took away. Not fixed, and reported instead: CONFIG_SUFFIX names the product rather than the deployment, so two OpenBot deployments sharing one Composio project each read the other's auth configs as their own. A stable identity does exist — DEPLOYMENT_ID, with tenantPackage.tenantId as index.ts's fallback, which is how channels/thread-identity.ts answers the same question — but it reaches neither createComposioClient nor buildComposioClient, and plumbing it is a change to index.ts, outside this scope. It is also a migration: every config named " (OpenBot)" today stops matching a deployment-scoped marker, so an existing deployment would orphan its own configs rather than adopt them, and a recogniser that accepts both the legacy suffix and the scoped one has to land before the writer changes. Vendor answers audited for the same unread-reply pattern, against the declarations in @composio/client 0.1.0-alpha.76 and @composio/core 0.18.1: connectedAccounts.delete → success: boolean, REQUIRED — now read authConfigs.delete → AuthConfigDeleteResponse carries no success field at all, only an optional revoke_job_id (resources/auth-configs.d.ts:377); nothing to read, so that loop is unchanged authConfigs.create → answer unread, and nothing on it is a claim connectedAccounts.link → redirectUrl already read and shape-checked connectedAccounts.list → items and nextCursor already read authConfigs.list → items and nextCursor already read tools.execute → result already read by ./composio toolkits.get → rows already read by appOf Paths audited for the same silent-success pattern: deleteAuthConfig → fixed ensureAuthConfig → quiet where a config of ours exists, which is the idempotence it is for; a renamed config makes it create a second, which is a split rather than a false claim, and it is the marker that fixes it authorize → already refuses rather than borrowing a config it cannot claim revoke → already refuses a partial withdrawal; the unread verdict was the remaining hole, fixed above Tests: every connectedAccounts.delete double answered `undefined`, which the vendor cannot send. They answer WITHDRAWN now, and the two new cases hand back `{ success: false }` and `{}` on purpose. A removal over an app Composio holds no configs for at all is asserted quiet, so the refusal above cannot be reached by refusing whenever nothing was deleted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 180 ++++++++++++++++++++++--- server/tests/composio-adapter.test.ts | 155 +++++++++++++++++++++ 2 files changed, 320 insertions(+), 15 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 4bd28fd49..633fdf5d1 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -620,6 +620,55 @@ function withdrawableAccounts( return { ids, nameless }; } +/** + * Composio's own verdict on one withdrawal, as a refusal wherever it is not a yes. + * + * THE VENDOR TELLS US WHEN THE DELETE DID NOT HAPPEN AND NOTHING WAS LOOKING. `success` is a + * required field of `ConnectedAccountDeleteResponse` — see the declaration on + * {@link ComposioVendor}'s `connectedAccounts.delete` — and the answer used to be thrown away + * unread. A 200 whose body says `success: false` therefore became a withdrawn account in + * {@link ComposioBroker.revoke}'s count, a `true` out of that method, and a + * `vendorRevocationRequested: true` in the audit trail, over a grant Composio had just said it had + * not touched. That is the same class of lie as the unflagged delete before it and the one-page + * listing before that, arriving through the one door left unwatched: the reply. + * + * WHICH DOES NOT BLUR "ASKED" INTO "DONE", and the distinction is worth being exact about because + * the whole audit field rests on it. `success: true` still claims no more than it ever did — the + * account is gone at the broker and the revocation job was started — and whether Google honoured it + * happens afterwards, out of sight, with no supported way to poll. What `success: false` adds is + * the other end: Composio did not delete the account, so there is no job and nothing was asked of + * the provider at all. Reading it narrows the set of things `true` can be covering up rather than + * widening what `true` means. + * + * TWO SENTENCES, BECAUSE THEY ARE TWO DIFFERENT FACTS ABOUT TWO DIFFERENT THINGS. "Composio said + * no" is a fact about this account: the account is still there, a second press reaches it, and the + * caller's own count already says to press again. "Composio answered with something where its + * verdict belongs" is a fact about the package — nobody holding an admin page can correct the shape + * of a reply, and pressing disconnect again would be answered identically — so it carries + * {@link VENDOR_SHAPE_REMEDY} instead. Collapsing the two would send an operator to press a button + * for a condition no button changes. + * + * NULLABLE IN THE PARAMETER THOUGH THE DECLARATION SAYS OTHERWISE. The generated client parses the + * body and returns it; a `null` body is a `null` here, and reading a field off it would be a + * `TypeError` carrying a sentence that reads like a stack trace — which is the one thing every + * refusal in this file exists not to be. It falls into the unreadable branch, where it belongs. + */ +function withdrawalDeclined( + answer: { success?: unknown } | null | undefined, + toolkit: string, +): BrokerRefusalError | null { + const verdict = answer?.success; + if (verdict === true) return null; + if (verdict === false) { + return new BrokerRefusalError( + `Composio answered the withdrawal of one of this person's ${toolkit} accounts with success: false, so it did not delete the account and started no revocation of the grant behind it. Nothing was asked of the provider for that account, whatever this deployment would otherwise have recorded. Disconnecting again asks Composio for it a second time.`, + ); + } + return new BrokerRefusalError( + `Composio sent ${sent(verdict)} where its verdict on the withdrawal of one of this person's ${toolkit} accounts belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing rather than counted as ended. ${VENDOR_SHAPE_REMEDY}`, + ); +} + /** * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. * @@ -1178,12 +1227,35 @@ export type ComposioVendor = { * WHAT THE FLAG BUYS IS A REQUEST AND NOT A RESULT, which is the whole reason * {@link ComposioBroker.revoke}'s answer is named the way it is. The upstream revocation runs as * a background job; the response carries its `revoke_job_id` and the vendor documents that no - * generally available endpoint polls it (`:7447-7459`). So the answer is deliberately typed as - * `unknown` and read for nothing: there is no field on it this deployment could turn into a - * stronger claim than "we asked", and a shape declared here that nothing reads is a shape a - * vendor rename can break for no benefit. + * generally available endpoint polls it (`:7447-7459`). Nothing here can say the provider tore + * the refresh token up, and nothing here pretends to. + * + * BUT THE ANSWER DOES SAY WHETHER COMPOSIO DID ITS OWN HALF, AND THAT WAS BEING DISCARDED. + * `ConnectedAccountDeleteResponse` carries a REQUIRED `success: boolean`, "indicates whether + * the connected account was successfully deleted" (`@composio/client` 0.1.0-alpha.76, + * `resources/connected-accounts.d.ts:7445-7459`). This used to be `Promise`, read for + * nothing, on the argument that no field on the answer could support a stronger claim than "we + * asked". That argument is true of the REVOCATION and false of the DELETE: a 200 carrying + * `success: false` is Composio saying it did not delete the account, so the background job the + * flag asks for was never started either — and {@link ComposioBroker.revoke} answered `true` + * over the top of it while the grant stood at the provider. Reading it is not a stronger claim + * than "we asked"; it is the difference between having asked and having been refused. + * + * DECLARED `success?: unknown` RATHER THAN AT THE VENDOR'S OWN `boolean`, for the reason every + * other field in this projection is: the generated client parses the body and hands it over, so + * "required" is the schema's promise about what Composio means to send rather than a fact about + * what arrived. {@link withdrawalDeclined} is where the three answers are told apart. + * + * `revoke_job_id` IS DELIBERATELY NOT READ, and its absence is deliberately not a refusal. The + * same declaration marks it optional and says it is present "only when `revoke_on_delete=true`" + * — which says when it CAN appear, not that it always does — so a guard on it would turn every + * withdrawal Composio accepted into a permanent failure the first time they stopped sending it, + * which is the shape of mistake this file has already made twice in the other direction. */ - delete(id: string, params: { revoke_on_delete: true }): Promise; + delete( + id: string, + params: { revoke_on_delete: true }, + ): Promise<{ success?: unknown }>; }; }; @@ -1384,7 +1456,7 @@ export function buildComposioClient( }; /** - * The auth configs for one app that THIS DEPLOYMENT made, oldest name first. + * Every auth config Composio holds for one app, ours and anybody else's alike, in one order. * * THE LISTING IS SCOPED TO THE PROJECT AND NOT TO THIS DEPLOYMENT, which is the correction. An * auth config is scoped to the project the API key belongs to — so nothing here is hidden from @@ -1393,13 +1465,18 @@ export function buildComposioClient( * purposes this deployment knows nothing about. {@link madeHere} is the only thing that tells the * two apart, and every caller below is about an object one of them must not touch. * + * THE UNCLAIMED ROWS ARE RETURNED RATHER THAN DROPPED HERE, which is the part that moved. The + * filter used to live on the way out, so "no configs at all" and "configs, none of them ours" + * reached every caller as the same empty array — and telling those two apart is the whole of what + * {@link ComposioBroker.deleteAuthConfig} was missing. The two callers that only ever want ours + * go on asking for exactly that through {@link configsMadeHere}, which is now one line over this + * one rather than the only way in. + * * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure * being fixed here is two calls resolving different rows; a total order on the id makes the * choice this file makes a stable one, whoever asks and whenever. */ - const configsMadeHere = async ( - toolkit: string, - ): Promise => { + const configsFor = async (toolkit: string): Promise => { /* * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two * callers below are wrong in the two opposite directions {@link madeHere} describes: @@ -1437,10 +1514,15 @@ export function buildComposioClient( */ return rows .map((row, position) => configOf(row, position, toolkit)) - .filter(madeHere) .sort((one, other) => one.id.localeCompare(other.id)); }; + /** The subset of {@link configsFor} this deployment can claim, which is what two callers want. */ + const configsMadeHere = async ( + toolkit: string, + ): Promise => + (await configsFor(toolkit)).filter(madeHere); + /** * Ask for every one of them and answer with what refused, rather than stopping at the first. * @@ -1818,8 +1900,61 @@ export function buildComposioClient( * press the button together, and an app enabled before this deployment created configs at all * has none to drop. In every one of those the end state is the one that was asked for, so a * throw would report a failure while the caller got exactly what they wanted. + * + * AND QUIET USED TO MEAN QUIET OVER A CONFIG THAT WAS STILL STANDING, which is the failure + * being closed here. "Nothing of ours" and "nothing at all" are not the same state, and the + * listing could not tell a caller which one it was in: rename a config in Composio's + * dashboard — drop the suffix, or edit the app's title past it — and every decision in this + * file stops recognising the object it made. `ensureAuthConfig` would create a second beside + * it, `authorize` would refuse against it, and this returned normally, after which + * `removeServer` deleted the app's row. The config and every grant made against it outlive + * the removal with nothing in this deployment naming them, and an administrator is told the + * app was withdrawn. */ - const ours = await configsMadeHere(toolkit); + const held = await configsFor(toolkit); + const ours = held.filter(madeHere); + if (ours.length === 0 && held.length > 0) { + /* + * REPORTED, NOT DELETED AND NOT SWALLOWED, AND THE THIRD OPTION IS THE ONLY HONEST ONE. + * + * Deleting anyway is the worse half of the same guess {@link madeHere} exists to stop: a + * row with a readable name that does not carry the suffix is as likely to be an operator's + * own dashboard work — their scopes, their tool restrictions, and every account anybody + * connected against it — as it is to be ours under a new name. Nothing in the row tells + * them apart, which is why nothing here chooses. + * + * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link configOf} AND NOT THE SAME + * CASE. There the ambiguity is about ADDRESSING: the field that decides ownership did not + * arrive, so no row can be sorted and the removal cannot begin. Here every name arrived and + * every row is legible; what is in doubt is whether this deployment's own config is among + * them under a title somebody edited. So the refusal is narrower than that one — it fires + * only where the removal found nothing it could claim, and stays quiet where a config of + * ours was found and dropped beside somebody else's, which is the contract + * {@link ComposioBroker.deleteAuthConfig} states. + * + * IT IS A BLOCK, AND THE BLOCK IS THE POINT. The app's row survives this throw — + * `removeServer` deletes it only after this returns — so the app stays on its Plugins page + * and stays removable, which is the one thing a silent success took away. The remedy is an + * operator's and it is one act in a dashboard: rename the config back so it ends with the + * suffix and remove the app again, which finishes the withdrawal, or satisfy yourself that + * it is your own and delete it there, which is the only way anything can tell this + * deployment that the config it made is genuinely gone. + * + * A COUNT AND THE SUFFIX, because together they are the whole of what an operator has to + * look at: how many objects are standing, and the exact string that would have claimed + * them. The names are not quoted — a config's title is an app name an administrator typed + * and this file's refusals quote no vendor field it does not have to. + * + * AND IT CLAIMS NOTHING ABOUT WHAT THIS DEPLOYMENT ONCE MADE. An app enabled before this + * deployment created configs at all never had one, which the quiet case above names, so a + * sentence opening "the config this deployment made" would be a guess in the one place a + * guess is what is being refused. What is said is only what was just read. + */ + throw new BrokerRefusalError( + `Removing ${toolkit} found none of this deployment's own authorization configs at Composio, and Composio holds ${held.length} for ${toolkit} whose name does not carry ${CONFIG_SUFFIX} — so nothing was deleted and the app has not been withdrawn, rather than a config this deployment cannot show is its own being deleted along with every account connected against it. Nothing here can tell one of ours, renamed in Composio's dashboard, from an operator's own work. If it is this deployment's, the grants made against it are still live, and renaming it to end with ${CONFIG_SUFFIX} lets removing the app again withdraw them. If it is an operator's, only taking it out of that dashboard leaves this app with nothing standing, after which the removal goes through.`, + ); + } + const refused = await askForEach(ours, (config) => askVendor( { @@ -2041,15 +2176,30 @@ export function buildComposioClient( * back in front of the report, which is the order a person's grants actually need. */ const { ids, nameless } = withdrawableAccounts(accounts, toolkit); - const refused = await askForEach(ids, (id) => - askVendor( + /* + * WHAT CAME BACK IS READ, WHICH IS THE HALF THAT USED TO BE MISSING. Not throwing is not the + * same as having been done: Composio answers a delete with a `success` saying whether it + * performed one, and a `false` there means the account is still attached and no revocation + * job was started. It is counted as a refusal — into the same list a thrown vendor error + * lands in — so that the sentence below reports it as an account that was not withdrawn, + * which is exactly what it is. See {@link withdrawalDeclined}. + * + * INSIDE THE LOOP AND OUTSIDE {@link askVendor}, deliberately. `askVendor` wraps the + * `await vendor.*` and nothing else, because everything it translates is a fault raised + * inside the vendor's package; the refusal below is this file's own reading of a reply that + * arrived intact, and it already carries an authored sentence. + */ + const refused = await askForEach(ids, async (id) => { + const answer = await askVendor( { outcome: `one of this person's ${toolkit} accounts was not withdrawn`, app: toolkit, }, () => vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), - ), - ); + ); + const declined = withdrawalDeclined(answer, toolkit); + if (declined !== null) throw declined; + }); if (refused.length > 0 || nameless.length > 0) { /* diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 0992f08bd..0885c7fe5 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -92,6 +92,25 @@ const WHOLE_LISTING = 1000; /** The page this deployment sends somebody back to once the consent screen is done with them. */ const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; +/** + * What Composio answers a withdrawal it actually performed, returned by every double that performs + * one. + * + * A DOUBLE THAT OMITS THIS IS NOT A DOUBLE OF THE VENDOR. `success` is a REQUIRED field of + * `ConnectedAccountDeleteResponse` — "indicates whether the connected account was successfully + * deleted" (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7445-7451`) — and + * every fixture here used to answer `undefined`, which Composio cannot send. That was harmless only + * for as long as the adapter read nothing off the reply; the moment it started telling a performed + * delete from a declined one, a fixture answering nothing was a fixture asserting the adapter's + * behaviour against a reply no vendor produces. + * + * SPELLED AT EACH DOUBLE RATHER THAN DEFAULTED IN {@link fakeVendor}, for the reason the refusals + * there are spelled: the two tests next door hand back `{ success: false }` and `{}` on purpose, + * and a default would put the interesting answer and the ordinary one at different distances from + * the reader. + */ +const WITHDRAWN = { success: true }; + /** * The three auth configs this file reasons about, named once rather than spelled at each fixture. * @@ -850,6 +869,64 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); }); + test("an app whose configs no longer carry this deployment's name is not a clean removal", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + /* + * THE CONFIG THIS DEPLOYMENT MADE, RENAMED IN COMPOSIO'S DASHBOARD. Same object, same + * grants on it, same accounts connected against it; the one field that changed is the + * only one this file writes and the only one it can recognise itself by. + */ + list: async () => ({ + items: [{ id: "ac_ours", name: "Linear", status: "ENABLED" }], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + /* + * QUIET IS THE FAILURE HERE, AND IT IS THE ONE NOTHING REPORTS. `deleteAuthConfig` returning + * normally lets `removeServer` delete the app's row, which is the last thing in this + * deployment naming the app — so an administrator reads that the app was withdrawn while the + * config and every grant made against it stand at Composio with nothing pointing at them. + */ + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal).not.toBeInstanceOf(AggregateError); + expect(refusal.message).not.toMatch(A_CRASH); + // The count of what is standing and the marker that would claim it, which together are the + // whole remedy: an operator can look at one config and see which of the two readings it is. + expect(refusal.message).toMatch(/Composio holds 1 for linear/); + expect(refusal.message).toMatch(/\(OpenBot\)/); + /* + * AND NOTHING WAS DELETED, which is the half this refusal is not allowed to trade away. The + * row carries no marker, so deleting it is as likely to destroy an operator's own dashboard + * work — with every account on THAT — as it is to finish the removal. + */ + expect(deleted).toEqual([]); + }); + + test("an app Composio holds no configs for at all is still a quiet removal", async () => { + /* + * THE HALF THE REFUSAL ABOVE MUST NOT SWALLOW. Removing an app has to be able to happen twice: + * an app can be removed, re-enabled and removed again, two administrators can press the button + * together, and an app enabled before this deployment created configs at all has none to drop. + * In each of those the end state is the one that was asked for, so a throw would report a + * failure to somebody who got exactly what they wanted — and an implementation that reached + * green above by refusing whenever it deleted nothing would do precisely that. + */ + const { broker } = buildComposioClient( + fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + ); + + await broker.deleteAuthConfig("linear"); + }); + test("a disabled config of ours stops a second one being created", async () => { const created: unknown[] = []; const { broker } = buildComposioClient( @@ -957,6 +1034,7 @@ describe("withdrawing one person's grants", () => { list: async () => ({ items: [{ id: "ca_1" }] }), delete: async (...call: unknown[]) => { deleted.push(call); + return WITHDRAWN; }, }, }), @@ -968,6 +1046,73 @@ describe("withdrawing one person's grants", () => { expect(deleted).toEqual([["ca_1", { revoke_on_delete: true }]]); }); + test("a delete Composio answered `success: false` is not a withdrawal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + // A 200 whose body says the account was not deleted. The flag went out, Composio read + // the request and answered it — this is the vendor declining, not failing. + delete: async () => ({ success: false }), + }, + }), + ); + + /* + * WITHOUT THE CHECK THIS ANSWERS `true`, WHICH IS THE WHOLE FINDING. `store.ts` writes that + * boolean into `mcp.account_disconnected` as `vendorRevocationRequested` and then deletes the + * `composio_connections` row — the only thing in this deployment naming which app this person + * connected. So a `success: false` nobody read ends as a trail entry claiming a grant was + * withdrawn, an account that is still live at Google, and nothing left pointing at it. + * + * ASSERTED AS A REFUSAL AND AS A COUNT, because "did not answer true" is satisfied by a crash. + */ + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /withdrew 0 of this person's 1 accounts for gmail/, + ); + expect(refusal.message).not.toMatch(A_CRASH); + // The reason lives on `cause`, because the count is deliberately the whole of the sentence. + expect(everythingSaidBy(refusal).join(" ")).toMatch(/success: false/); + }); + + test("a delete whose verdict Composio did not send is not counted as one either", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + /* + * `success` IS REQUIRED IN THE DECLARATION AND ABSENT ON THIS WIRE, which is the shape + * the check has to survive rather than the one it is for. `@composio/client` parses the + * body and hands it over, so the schema's "required" is a promise about what Composio + * means to send and not a fact about what arrived. + */ + delete: async () => ({}), + }, + }), + ); + + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + + /* + * A DIFFERENT SENTENCE FROM THE ONE NEXT DOOR, and that is what this asserts. "Composio said + * no" is a fact about this account that a second press can meet again; "Composio answered + * something this deployment cannot read" is a fact about the package, correctable by nobody + * holding an admin page. Collapsing them would send an operator to press a button for a + * condition a button cannot change. + */ + const said = everythingSaidBy(refusal).join(" "); + expect(said).toMatch(/upgrading this deployment's @composio\/core/); + expect(said).not.toMatch(/success: false/); + }); + test("the listing asks about every state a grant can be hiding in", async () => { const asked: unknown[] = []; const { broker } = buildComposioClient( @@ -1051,6 +1196,7 @@ describe("withdrawing one person's grants", () => { delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); deleted.push(id); + return WITHDRAWN; }, }, }), @@ -1071,6 +1217,7 @@ describe("withdrawing one person's grants", () => { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); + return WITHDRAWN; }, }, }), @@ -1113,6 +1260,7 @@ describe("withdrawing one person's grants", () => { }), delete: async (id: string) => { deleted.push(id); + return WITHDRAWN; }, }, }), @@ -1841,6 +1989,7 @@ describe("what the delete loop keeps of the failures it meets", () => { }), delete: async (id: string) => { if (id !== "ca_1") throw new Error(`Composio refused ${id}.`); + return WITHDRAWN; }, }, }), @@ -1868,6 +2017,7 @@ describe("what the delete loop keeps of the failures it meets", () => { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); + return WITHDRAWN; }, }, }), @@ -1912,6 +2062,7 @@ describe("what the delete loop keeps of the failures it meets", () => { ); } deleted.push(id); + return WITHDRAWN; }, }, }), @@ -2227,6 +2378,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { }, delete: async (id: string) => { deleted.push(id); + return WITHDRAWN; }, }, }), @@ -2361,6 +2513,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { }, delete: async (id: string) => { deleted.push(id); + return WITHDRAWN; }, }, }), @@ -2404,6 +2557,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { // would satisfy any assertion that only asked for a refusal of some kind. delete: async (id: string) => { deleted.push(id); + return WITHDRAWN; }, }, }), @@ -2437,6 +2591,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { }, delete: async (id: string) => { deleted.push(id); + return WITHDRAWN; }, }, }), From f499d199517812d5f669cbfaef4f298c5af060bc Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 20:07:23 +0200 Subject: [PATCH 145/215] Stop the Composio diagnostic promising a distinction it never made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composio-smoke` said it told three states apart — a key with no project, an app with no authorization config, and a person who never consented. It reads two. Nothing in it ever asks for an auth config, so an app no administrator enabled and a person who never finished consenting printed the same no-connection line, and that line sent the reader to the person: the one party who cannot enable an app. Both are now named, and the docblock and the page say two rather than three. Making it three needs a read-only auth-config listing on `ComposioBroker`, which today offers only `ensureAuthConfig` and `deleteAuthConfig` — both writes, and a read-only diagnostic must not create the object it was asked to look for. The remedy helper was aimed at the wrong thing for every read. `unexplained` ends "check that this app is still connected on its Plugins page", which is right for the action it was written for and wrong for a step: a catalogue that would not list, or a key Composio rejects, answered with advice about somebody's connection. The fallback is now the caller's to pass, so the reads say to check COMPOSIO_API_KEY and then Composio, and the one action call keeps `unexplained`. The client constructor was the single vendor call outside the redactor, so a throw there printed raw, past this file's own promise about the key. It cannot be reached today — the only throw `new Composio` makes is for an empty key, and the refusal above it means that never arrives — but "every vendor call except one" is not a promise worth making, so it is wrapped the way the reads are. `--call` was checked too and does reach Composio: it resolves the action, verifies the vendor still labels it read-only, and POSTs the execute. Nothing changed there. Also: the Composio page was missing from the docs index, and the tsconfig comment explaining why it names one script rather than the directory blamed the sibling scripts for redeclaring each other's variables. They do not. Taking the directory in fails on three TS1375s for top-level `await` in a non-module, plus a TS2451 and TS2367 where `test-ci.ts`'s `const status` collides with the `status` the DOM lib declares — a global shared with the standard library, not with a sibling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- docs/README.md | 1 + docs/plugins/composio.md | 14 +++- scripts/composio-smoke.ts | 133 +++++++++++++++++++++++++++++++++----- server/tsconfig.json | 17 +++-- 4 files changed, 143 insertions(+), 22 deletions(-) diff --git a/docs/README.md b/docs/README.md index 6b7c66687..bc726bc9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Start with the root [README](../README.md), then use these references: - [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration. - [Routines](routines.md): standing instructions a Bot runs on a schedule, the worker that fires them, and who they run as. - Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean: + - [Composio](plugins/composio.md): the broker, and so the one page here that is a catalogue of apps rather than a single connector. - [Google Drive](plugins/google-drive.md) - [Notion](plugins/notion.md) - [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes. diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 7d68cff0f..12295b4ab 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -59,8 +59,18 @@ COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] says why. It ships inside the container image, so it is run where the key is — in the deployment, not on a laptop — and it answers the question no page can: whether *this* key opens *this* project. A key with no project behind it, a project with no authorization config for the app, and a person who -never finished the consent page all leave a product that looks configured and answers nothing, and -this is what tells the three apart. +never finished the consent page all leave a product that looks configured and answers nothing. + +**It separates the key from the person, which is two of those three and not all of them.** The +catalogue read answers for the key: a key Composio rejects, and a project whose catalogue does not +carry the app, both stop the run with the vendor's own sentence. The connection read answers for the +person. What it does not separate is the authorization config — an app no administrator has enabled +here has no config for anybody to connect against, so it reads exactly like a person who never +finished consenting, and the no-connection line says so rather than blaming the person. Check the +app's page under `/admin/plugins` to tell those two apart; enabling an app is what creates the +config. Making the script itself distinguish them would need a read-only auth-config listing on the +broker seam, which does not exist yet — the seam has `ensureAuthConfig` and `deleteAuthConfig`, both +of which write, and a read-only diagnostic must not create the object it was asked to look for. It is all reads. It mints no connect link and starts no session, because a link is a bearer capability and a diagnostic that printed one would leave somebody's mailbox in a terminal scrollback. diff --git a/scripts/composio-smoke.ts b/scripts/composio-smoke.ts index 94898378c..0b33f4b91 100644 --- a/scripts/composio-smoke.ts +++ b/scripts/composio-smoke.ts @@ -3,10 +3,26 @@ * * `server/tests/composio-live.test.ts` asks what the vendor does; this asks what THIS KEY can see. * They are different questions and only the second one is about an operator's own account: a key - * with no project behind it, a project with no auth config for the app, or a person who never - * finished the consent page all produce a product that looks configured and answers nothing. So - * everything below is a read, and every read is one an operator would otherwise make by clicking - * around Composio's dashboard and guessing which of the three it was. + * with no project behind it, a project that cannot see the app, or a person who never finished the + * consent page all produce a product that looks configured and answers nothing. So everything below + * is a read, and every read is one an operator would otherwise make by clicking around Composio's + * dashboard. + * + * IT SEPARATES TWO STATES AND NOT THREE, WHICH IS A CORRECTION TO WHAT THIS DOCBLOCK USED TO CLAIM. + * The promise was that a key with no project, an app with no authorization config, and an unfinished + * consent were told apart. Two of those are: the catalogue read answers for the KEY, and the + * connection read answers for the PERSON. The third was never delivered — nothing here reads an auth + * config, so an app this deployment has no config for and a person who never consented both print + * the same "no live connection" line, and the line now names both rather than asserting the second. + * + * IT IS NOT READ BECAUSE THERE IS NOTHING HERE TO READ IT WITH, AND THAT IS THE HONEST REASON. + * `server/src/plugins/broker.ts` gives this script `ensureAuthConfig` and `deleteAuthConfig`, both of + * which WRITE, and no listing at all; a diagnostic that called the first would create the very object + * it was asked whether anybody had created, and a read-only diagnostic may not do that. Making the + * distinction real therefore means a new read on that seam and in its adapter — a change to + * `server/src`, not to this file, and one nobody should infer from a docblock. Until it exists, an + * operator separates the two on the app's page under `/admin/plugins`, which is where enabling an app + * creates the config. * * COMPOSIO_API_KEY=... bun run composio:smoke -- --user [--call] * @@ -21,9 +37,12 @@ * every line this script writes goes through {@link redact}, which takes it back out. That is belt * and braces on purpose: the adapter promises not to quote the key, but a vendor exception is a * foreign object and "this SDK does not put the key in an error" is not a promise this file is in a - * position to make on the SDK's behalf. Which is why no vendor call below is awaited bare — an - * unhandled rejection is printed by the runtime rather than by this file, and that is the one way - * out past the redactor. {@link ask} is what closes it. + * position to make on the SDK's behalf. Which is why NO vendor call below is made bare — an + * unhandled rejection, or a synchronous throw nothing caught, is printed by the runtime rather than + * by this file, and that is the one way out past the redactor. {@link ask} closes it for the reads, + * which are awaited, and {@link build} closes it for the client constructor, which is not: it was + * the single call still outside the guarantee, and "every vendor call except one" is not a promise + * worth making. * * WHICH STREAM A LINE GOES TO IS DECIDED BY THE EXIT CODE IT EXPLAINS, and that is the whole rule: * @@ -166,23 +185,56 @@ function stop(line: string, code: number): never { * AND THE PLACEHOLDER IS REFUSED HERE TOO, which is the half this file was missing. The fallback * used to be `error.message` unconditionally — so on the one path where the placeholder is what * `error.message` holds, a docblock saying the sentence is never worth passing on sat directly - * above the code that passed it on. {@link VENDOR_PLACEHOLDER} and {@link unexplained} are the same - * two the transport uses at the same fork, imported rather than re-spelled so this file cannot - * drift from `callTool`'s wording about a failure Composio declined to explain. + * above the code that passed it on. {@link VENDOR_PLACEHOLDER} is the same guard the transport uses + * at the same fork, imported rather than re-spelled so this file cannot drift from `callTool`'s + * reading of a failure Composio declined to explain. + * + * WHAT TO SAY IN THAT SILENCE IS THE CALLER'S TO DECIDE, WHICH IS WHY IT IS A PARAMETER. It used to + * be {@link unexplained} for everybody, and that function's sentence is about a person's connection + * — correct for the one caller that hands it an action's name, wrong for every caller that hands it + * a step. A single fallback could only be right for one of the two, so the fork that already exists + * at the call sites decides it: {@link ask} passes {@link unexplainedRead} and the action call + * passes {@link unexplained}. Named rather than inlined so each sentence keeps a docblock saying who + * it is for. * * The thrown message is still the fallback where it says anything at all, because a failure that is * not a Composio throw — DNS, a proxy, a TLS refusal — carries its whole diagnosis there. */ -function failed(subject: string, error: unknown): string { +function failed( + subject: string, + error: unknown, + silence: (subject: string) => string, +): string { const vendor = vendorSentence(error); if (vendor !== null) return `${subject} failed: ${vendor}`; const thrown = error instanceof Error ? error.message.trim() : String(error).trim(); return thrown === "" || VENDOR_PLACEHOLDER.test(thrown) - ? unexplained(subject) + ? silence(subject) : `${subject} failed: ${thrown}`; } +/** + * What to say when one of this script's READS failed and Composio explained nothing. + * + * NOT {@link unexplained}, AND THAT IS THE CORRECTION. That sentence ends "Check that this app is + * still connected on its Plugins page", which is the right advice for what it was written for — a + * named ACTION that failed, where a lapsed connection is the likeliest cause by a wide margin and + * the reader fixes it in two clicks. Every {@link ask} below was handing it a STEP instead, so + * "Listing the apps this key can see" and "Listing gmail's actions" both answered with advice about + * one person's connection. A catalogue that will not list and a key Composio has stopped accepting + * are faults in the deployment, and the person whose connection that sentence sends the reader to + * inspect is the one party who cannot do anything about either. + * + * SO IT NAMES THE TWO THINGS ACTUALLY IN QUESTION AT THIS STAGE, in the order worth checking: the + * key this deployment sent, then Composio itself. By the time any read here runs, nothing about + * anybody's connection has been established or is implicated — the catalogue read does not involve a + * person at all. + */ +function unexplainedRead(step: string): string { + return `${step} failed and Composio did not say why. At this stage that is a fault in the key this deployment sent or at Composio, and not in anybody's connection: check COMPOSIO_API_KEY on this deployment, then Composio's status page.`; +} + /** * One vendor read, with a thrown failure reported rather than raised. * @@ -200,11 +252,46 @@ async function ask(attempt: string, read: () => Promise): Promise { try { return await read(); } catch (error) { - stop(failed(attempt, error), 1); + stop(failed(attempt, error, unexplainedRead), 1); + } +} + +/** + * {@link ask} for something that is not awaited, which at present is exactly one call. + * + * THE CLIENT CONSTRUCTOR WAS THE ONE VENDOR CALL OUTSIDE THE REDACTOR. Everything else in this file + * goes through {@link ask}, whose whole purpose is that no thrown vendor object is printed by the + * runtime instead of by this file — and `createComposioClient` sat bare above it, so a throw there + * went straight past the guarantee this file's own docblock makes about never printing the key. + * `ask` could not cover it because `ask` awaits and this does not, so the shape is repeated + * synchronously rather than the call being made to look asynchronous. + * + * WHAT IT CAN ACTUALLY THROW WAS CHECKED BEFORE THIS WAS ADDED, AND THE ANSWER IS "ONE THING, NOT + * REACHABLE FROM HERE". `new Composio(...)` validates nothing but the key: `getSDKConfig` raises + * `ComposioNoAPIKeyError` when the key is empty after falling back to the environment and the user + * config file, and everything after that is object construction (`@composio/core` 0.18.1, + * `src/composio.ts`). The refusal above means this file never hands it an empty one, and that error's + * message quotes no key even when it is raised. A malformed `COMPOSIO_BASE_URL` does NOT throw here + * either — it is carried to the first request and fails there, inside {@link ask} already. + * + * SO THIS IS BELT AND BRACES, AND DELIBERATELY SO. The file's guarantee is that the key cannot reach + * a terminal, and the docblock at the top says why it will not rest that guarantee on the SDK's + * behaviour: a vendor exception is a foreign object, the constructor runs their telemetry + * instrumentation and their provider's constructor, and the audit above is true of 0.18.1 rather + * than of the next version. A guard that costs four lines makes the promise structural instead of + * something a reader has to re-derive from the vendor's source every bump. + */ +function build(attempt: string, make: () => T): T { + try { + return make(); + } catch (error) { + stop(failed(attempt, error, unexplainedRead), 1); } } -const { actions, broker } = createComposioClient(key); +const { actions, broker } = build("Opening Composio with this key", () => + createComposioClient(key), +); const apps = await ask("Listing the apps this key can see", () => broker.listApps(), @@ -249,10 +336,19 @@ const connected = await ask( `Asking whether ${user} has a ${APP} connection`, () => broker.isConnected({ userId: user, toolkit: APP }), ); +/* + * BOTH STATES ARE NAMED BECAUSE THIS READ CANNOT TELL THEM APART. `isConnected` is a count of this + * person's ACTIVE accounts for the app, so it answers `false` for somebody who never consented and + * equally for an app this deployment has no authorization config for — in the second case there is + * nothing for a consent to have been made against, and nobody could have connected even if they + * tried. Sending the reader to the person in that case is sending them to the one party who cannot + * fix it. See the docblock at the top for why the config is not read here and what reading it would + * take. + */ say( connected ? `${user} has a live ${APP} connection.` - : `${user} has no live ${APP} connection. They connect one in their own browser, from the app's page in the product; nothing here can do it for them.`, + : `${user} has no live ${APP} connection, and this script cannot say which of two reasons it is. Either no administrator has enabled ${APP} on this deployment, so there is no authorization config for anybody to connect against — check the app's page under /admin/plugins — or the app is enabled and ${user} never finished the consent page, which they do in their own browser; nothing here can do it for them.`, ); if (!call) { @@ -331,7 +427,12 @@ try { {}, ); } catch (error) { - stop(failed(READ_ACTION, error), 1); + /* + * {@link unexplained} rather than {@link unexplainedRead}, and this is the one call site it was + * written for: the subject is an action's name, and a connection that lapsed between the listing + * above and this call really is the likeliest reason a run that got this far fails here. + */ + stop(failed(READ_ACTION, error, unexplained), 1); } /* diff --git a/server/tsconfig.json b/server/tsconfig.json index e6590fca3..9d8810dd6 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -11,10 +11,19 @@ // `bun run --filter '*' typecheck`, which matches workspaces only and never the root manifest. // // THE FILE AND NOT THE DIRECTORY, which is the line the Dockerfile already draws. The rest of - // `../scripts` is the laptop's, and two of those are global scripts with no import or export at - // all, so they share one scope and redeclare each other's variables the moment a project takes - // them in. Named individually the way `app/tsconfig.json` names the two `shared` files it reaches - // for. + // `../scripts` is the laptop's, and `check-rendered-chart.ts` and `test-ci.ts` carry no import and + // no export, so TypeScript reads them as global scripts rather than as modules and taking the + // whole directory in fails the build on five errors. Three of them are that, all TS1375: a file + // that is not a module may not use top-level `await`, and both files do. + // + // THE OTHER TWO ARE NOT THE TWO SCRIPTS COLLIDING WITH EACH OTHER, which is what this comment used + // to say and what the code does not do — `status` appears in `test-ci.ts` and nowhere else in that + // directory. Its `const status = await proc.exited` collides with the AMBIENT `status`, which + // `lib.dom.d.ts` declares as a `string` and which is in scope because nothing here narrows `lib`: + // TS2451 for the redeclaration, and then TS2367 on the line below it, because comparing the lib's + // `string` with `0` is two types that do not overlap. A global shared with the standard library, + // not with a sibling. Named individually the way `app/tsconfig.json` names the two `shared` files + // it reaches for. // // `tests` is NOT here yet, and that is a known gap rather than a decision: the suite has never been // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let From 17ec5ded1a98ca27a9496c876f3b98796effa6e8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 20:16:32 +0200 Subject: [PATCH 146/215] Hand Composio the return address that was checked, not the padding around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brokerReturnUrl` approved a parsed address and returned the raw string. The emptiness test trims, and URL parsing drops leading and trailing spaces and control characters along with every tab, newline and carriage return inside the string — so the guard read one address and handed back another. `OPENBOT_APP_URL` is an environment string and carries whatever was pasted into it. A leading space, a trailing newline, a tab inside the host: each one passed the guard and travelled on to Composio verbatim as the callback address. That is the person stranded on the vendor's hosted page holding a just-completed consent, which is the single thing this guard exists to prevent, reached through the guard itself. `isWebAddress` becomes `webAddress` and answers with the parsed address rather than a boolean, so the one caller can return what was actually examined. This is a reading of the address and not a choice about it — the padding was never part of the destination, only of the string — and a malformed address is still refused rather than repaired, because a missing scheme is a setting to fix. What callers now receive: - `server/src/plugins/routes.ts:814` is the only call site. It passes the result straight into `composio.broker.authorize({ returnUrl })` without storing it, comparing it or echoing it back, so nothing depended on byte identity with the string it handed in. - `server/src/plugins/composio-adapter.ts:2105` is where that value lands, copied untouched into `connectedAccounts.link`'s `callbackUrl`. Nothing between the two reads the string. - Neither is a redirect URI registered with a provider, which is the one address on these flows that must match character for character; that is `redirectUriFor`, built from `OPENBOT_PUBLIC_URL` and untouched here. The refusals are unchanged and still name only the setting, never the address. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 40 +++++++++++++++++------ server/tests/composio-broker-seam.test.ts | 31 ++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index acc7685a4..d8302febb 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -236,10 +236,20 @@ export class BrokerReturnUrlError extends BrokerRefusalError { * the address arrived empty or malformed; the value is a page address for one person's connection * and belongs in no message, no log and no audit row, for the reason the connect url does not. * - * The address is returned unchanged rather than normalised. Whatever a caller passes is what the - * vendor is told and what the person's browser is sent to, so a guard that quietly rewrote it would - * be choosing a destination — and choosing where somebody lands holding a just-completed consent is - * exactly the decision this seam keeps in one place. + * WHAT COMES BACK IS THE ADDRESS THAT WAS CHECKED, WHICH IS NOT ALWAYS THE STRING THAT WENT IN. The + * check reads a parsed address: the emptiness test trims, and parsing drops the spaces and control + * characters a URL cannot contain — so ` https://openbot.test/…`, an address ending in a newline and + * one with a tab inside its host all satisfy this guard while denoting something else entirely. A + * version that approved the parsed address and returned the raw one approved nothing: the padding + * travelled on to Composio as part of the callback, which is the person stranded on a vendor page + * that this function exists to prevent. Returning what was read is what makes the reading binding. + * + * THAT IS A READING OF THE ADDRESS AND NOT A CHOICE ABOUT IT. Where somebody lands holding a + * just-completed consent is a decision this seam keeps in one place, so the destination is still + * the caller's; a parsed address names the same place a browser handed the original would have gone + * — the padding was never part of the destination, only of the string. What this cannot do is guess + * at an address that means nothing, which is why the branch above refuses rather than repairs: a + * missing scheme is a setting to fix and not whitespace to drop. */ export function brokerReturnUrl(returnUrl: string): string { if (returnUrl.trim() === "") { @@ -247,23 +257,33 @@ export function brokerReturnUrl(returnUrl: string): string { "This deployment built no address for Composio to send you back to, so the connection was not begun rather than begun with nowhere to land. Set OPENBOT_APP_URL to the address this deployment's pages are served from, and connecting an app will have a return leg.", ); } - if (!isWebAddress(returnUrl)) { + const address = webAddress(returnUrl); + if (address === null) { throw new BrokerReturnUrlError( "The address Composio would send you back to is not a web address, so a consent granted there would end on Composio's own page with no way back here. Set OPENBOT_APP_URL to this deployment's own origin including the scheme — https://openbot.example.com rather than openbot.example.com.", ); } - return returnUrl; + return address.href; } -/** Whether a browser on somebody else's origin could follow this: absolute, and http or https. */ -function isWebAddress(value: string): boolean { +/** + * The address a browser on somebody else's origin could follow — absolute, and http or https — and + * null for anything else. + * + * It answers with the parsed address rather than a boolean so that the one caller can hand back what + * was actually examined. A predicate would leave the caller holding only the string it was given and + * no way to tell it apart from the address that string denotes. + */ +function webAddress(value: string): URL | null { let parsed: URL; try { parsed = new URL(value); } catch { - return false; + return null; } - return parsed.protocol === "https:" || parsed.protocol === "http:"; + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed + : null; } /** diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index b8829159e..49177a1f7 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -88,4 +88,35 @@ describe("brokerReturnUrl", () => { "http://localhost:3001/admin/plugins/x", ); }); + + /** + * The address handed back is the one that was checked, which is the whole of what the check is + * worth. + * + * A guard that reads one value and returns another has approved nothing. `OPENBOT_APP_URL` is an + * environment string, and an environment string carries whatever was pasted into it: a leading + * space from a copied address, a trailing newline from a file read line by line, a tab or a + * carriage return from a variable assembled by a shell. Every one of those is invisible where it + * is set and every one reaches Composio as part of the callback if the guard hands the raw string + * back — which is the same person on the same hosted page the guard exists to keep them off. + * + * Each case below is the same intended address wearing a different disguise, and the disguises are + * split deliberately: the first three sit at the ends, where trimming would find them, and the + * last three sit in the middle of the host and the path, where it would not. A fix that only + * trimmed would pass the first half of this table and strand somebody on the second. + */ + test("hands back the address it checked rather than the padding around it", () => { + const intended = "https://openbot.test/settings/connected-accounts/x"; + + for (const disguised of [ + " https://openbot.test/settings/connected-accounts/x", + "https://openbot.test/settings/connected-accounts/x\n", + "\thttps://openbot.test/settings/connected-accounts/x ", + "https://openbot\n.test/settings/connected-accounts/x", + "https://openbot.test/settings/\tconnected-accounts/x", + "https://openbot.test/settings\r/connected-accounts/x", + ]) { + expect(brokerReturnUrl(disguised)).toBe(intended); + } + }); }); From 63c3b78f85137c01f3738e7d0af98ae36b6033e8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 20:24:14 +0200 Subject: [PATCH 147/215] Hand on the identifier the guard checked, and stop four refusals stating what nothing measured A tail of confirmed review findings in the Composio adapter, all of the same family: a value read one way and used another, and a sentence asserting more than the code beside it established. textOf decided emptiness on the TRIMMED string and answered the PADDED one. " " was correctly refused; " ac_1 " was accepted and handed on with its spaces, which is a guard that checks one value and passes along another. Every caller was audited for what the corrected value changes: everyRowOf, the cursor - the followed cursor and the dedupe key are now the trimmed token. This is the one caller where trimming changes what goes ON the wire; a next_cursor is an opaque token and padding one is not something Composio does, while two cursors differing only by surrounding space are not two pages. appOf, slug - the slug becomes composio:// at enable time, and toolkitOf reads the app back out through [A-Za-z0-9_-]+, which admits no space. A padded slug was an enabled app whose url named no app at all. appOf, name and category - display strings; the change is cosmetic here and is the same fix. configOf, id - what authConfigs.delete NAMES. A padded id asked Composio to remove an object nobody holds, after which removeServer deleted the app's row over a live config. configOf, name - feeds madeHere, which already trimEnd()s before the suffix test, so the claim is unchanged. trimEnd() is left standing rather than deleted as newly dead: it is what the docblock beside it explains. withdrawableAccounts, id - what connectedAccounts.delete NAMES, with the audit trail recording a withdrawal behind it. actionOf, slug - becomes mcp_tools.name, half that table's primary key, and travels back to Composio on the next call. execute, resolved toolkit - compared against call.toolkit. A padded slug was a spurious mismatch refusal, whose remedy - refresh this app's tools - cannot change what Composio pads. The page ceiling was tested before the page it was counting had been recorded, so the listing read 51 pages while the refusal said 50. The test moved below the add. The same sentence asserted "at 1000 rows each", which is the limit this deployment ASKED for stated as a fact about what Composio sent; it now reports the rows that arrived. The unreadable-status refusal counted every config it could not read and quoted the first one's status, asserting that word of the whole set. It names each distinct status once now, bounded at five, so a refusal's length is not decided by how many configs an app has. named() quoted an arbitrary wire string on the branch reached precisely because the value is NOT the closed set the code expects - so a gateway's HTML error page, a stack trace or a megabyte of anything went into a sentence read off an admin page, written into an app's lastError and put in front of a model. It now quotes only what is shaped like a vendor enum name and falls back to sent() for everything else, which is what the rest of this file does with an unchecked wire value. The config ordering used localeCompare, which collates in the HOST's locale. The two callers it exists to keep in step - authorize and deleteAuthConfig - need not run on the same machine or the same build of ICU, and an order two machines can disagree about is not an order two callers agree on. Compared by code unit now. ComposioToolNotFoundError was translated as "Composio no longer publishes that action, refresh this app's tools". The class does not carry that: getRawComposioToolBySlug wraps its retrieve in a try whose catch rethrows EVERYTHING except a cancellation as this class (@composio/core 0.18.1, src/models/Tools.ts:709-721), and tools.execute resolves through the same method (:1163). A 500, a 429, a refused key and a dropped socket all arrive under that name, so an outage read to an administrator as a catalogue change with an instruction to press Refresh at a vendor that was not answering. The sentence now says the action could not be fetched, names both readings, and gives the fact that separates them - whether every action of every app is failing the same way. The refresh stays, named as the remedy for one reading rather than as the remedy. The auth-config create answer was awaited and dropped. transformCreateAuthConfigResponse builds it by dereferencing response.auth_config.id and response.toolkit.slug (src/utils/transformers/authConfigs.ts:96-106), so a shape drift raises inside the vendor's package AFTER the create has gone out and been answered - and the outcome clause read "no authorization config was created", which is the one thing that call cannot know. The outcome says what is true of every condition instead, the reply's id is read, and an answer that does not name the new config refuses with a sentence saying one may well be standing at Composio and that enabling the app again finds it rather than making a second. A page size of zero vanished. getRawComposioTools composes its request with ...(limit ? { limit } : {}) (src/models/Tools.ts:536) over a schema with no floor (src/types/tool.types.ts:257), so a zero is not sent short, it is not sent: Composio applies its own page of twenty, ./composio's fragment guard measures against 1000 and never fires, and twenty rows commit as the whole of what the app publishes. listActions refuses a limit that is not a positive integer rather than defaulting one. A local binding named `named` shadowed the module helper of that name; renamed to answeredSlug. Latent only. REFUTED, with evidence: The docblock beside the sort was said to claim "oldest name first". No such text exists - grep -rn "oldest" over server/src and server/tests hits only routines/store.ts, db/schema/work.ts and three unrelated test files. What the docblock actually claims is that a total order on the id makes the choice stable, which is true of an id sort; only the locale-dependence was wrong, and that is fixed above. DEFERRED, and it is the same root cause as the known item: An app slug in the wrong case passes every listing and fails permanently at execution, with a remedy that cannot help. It is not a fault of the mismatch gate: toolkitOf preserves the url's case on purpose (composio.ts:247-250 says so), composio_connections.toolkit documents itself lower case, and store.ts:1164 looks the row up with eq() - a case-sensitive comparison in Postgres. Case-folding the gate in this file alone would let the call run while the person's connection row still failed to match, which is fixing half of it. Left where the known item put it. Test gaps closed in the same file: Nothing could fail a gate that always opens: both isConnected assertions expected true, so `async isConnected() { return true }` was green. An empty listing is asserted false now, and the mutation reddens it. The catalogue guards were swept one at a time. Only `logo` was genuinely uncovered - disabling it left the suite green. slug, name and category were covered by the shape table, description and toolsCount by CATALOGUE_ROWS. Both are added to CATALOGUE_ROWS so the table lists all six, because a table listing five of six is how the logo one was missed. The ceiling was asserted with toBeLessThan(200) against a ceiling of 50, which passes at 51, at 199 and at any ceiling anybody raises it to. It now pins both the pages read and the number the sentence states to a written-out 50, per this file's own constant discipline. The "each refusal names only its own remedy" cross-check was vacuous across contexts: the linking and creating helpers used linear while the resolving and running ones used gmail, and three remedies embed the app name, so most pairs in the table could never have matched. One app across all four contexts. Proven: giving ComposioAclOnlyForSharedError a second condition's remedy is green under the old table and red under the new one. Red-green for every behavioural change: 13 new tests red against the unfixed adapter, all green after. Mutation checks on the shipped code - textOf answering the padded string reddens 5, testing the ceiling before the add reddens 1 with "Expected 50, received 51", restoring the "no longer publishes" sentence reddens 1. bunx tsc --noEmit clean. bun test server/tests: 2284 pass, 5 skip, 0 fail, 2289 across 138 files - the 2269/5/0 baseline plus 15 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 226 +++++++++++- server/tests/composio-adapter.test.ts | 485 ++++++++++++++++++++++++- 2 files changed, 683 insertions(+), 28 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 633fdf5d1..d1c514797 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -262,10 +262,25 @@ function sent(value: unknown): string { * status is one of a closed set of vendor enum names, carries nobody's data, and IS the finding — * "Composio called it PENDING" is something an operator can search their dashboard and the vendor's * changelog for, where "Composio sent a string" is something they can only shrug at. + * + * AND THE ARGUMENT ONLY HOLDS WHILE THE VALUE IS ACTUALLY ONE OF THOSE NAMES, which is the hole + * this closes. Every caller reaches this function on the branch taken precisely BECAUSE the value + * is not one of the words the code expects — so what arrives is not "an enum name Composio has + * added", it is whatever came off the wire: a gateway's HTML error page, a stack trace, a sentence + * carrying a person's mailbox address, a megabyte of it. That string was interpolated whole into a + * refusal that is read off an admin page, written into an app's `lastError` and put in front of a + * model. + * + * SO THE SHAPE OF AN ENUM NAME IS THE TEST, and anything that is not one is described by + * {@link sent} like every other wire value in this file. A vendor enum name is a short run of + * letters, digits and underscores; nothing that fails that is a word an operator could search a + * changelog for, which was the entire argument for quoting it. */ +const VENDOR_ENUM_NAME = /^[A-Za-z0-9_]{1,40}$/; + function named(value: unknown): string { const text = typeof value === "string" ? value.trim() : ""; - return text === "" ? sent(value) : `"${text}"`; + return VENDOR_ENUM_NAME.test(text) ? `"${text}"` : sent(value); } /* @@ -301,9 +316,22 @@ function named(value: unknown): string { * EMPTY COUNTS AS ABSENT because every caller of this reads an identifier — a slug, an id, a name * this file matches a suffix against — and an empty identifier is unusable in exactly the way a * missing one is, while being the one that reads as present at every glance. + * + * AND THE STRING THAT COMES BACK IS THE ONE THAT WAS JUDGED, which it was not. This decided + * emptiness on the TRIMMED value and answered the PADDED one, so " " was correctly refused while + * " ac_1 " was accepted and handed on with its spaces — a guard that checked one thing and passed + * along another. What that reached is the whole of this file: a padded id is what an auth-config + * delete and an account withdrawal NAME, so Composio is asked to remove an object nobody has; + * a padded slug is what an enabled app records into its url and what an action list writes into + * `mcp_tools`; and a padded app slug on the vendor's own answer compares unequal to the app the + * caller was gated on, refusing a call that was about the right app the whole time. Trimming is + * not tidying here — it is answering with the identifier rather than with the identifier plus + * whatever the wire wrapped it in. */ function textOf(value: unknown): string | null { - return typeof value === "string" && value.trim() !== "" ? value : null; + if (typeof value !== "string") return null; + const text = value.trim(); + return text === "" ? null : text; } /** @@ -319,6 +347,12 @@ function textOf(value: unknown): string | null { * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling * again, one order of magnitude further out and harder to notice. + * + * AND IT IS THE NUMBER OF PAGES THAT ARE READ, WHICH IS NOT WHAT IT USED TO BE. The test stood + * ahead of the line recording the page it was counting, so the set held one fewer than had + * arrived and the refusal fired on the fifty-FIRST page while telling its reader fifty. A ceiling + * is a number somebody reasons about; stating one and doing another makes it the one number here + * nobody can check. */ const PAGE_CEILING = 50; @@ -416,12 +450,22 @@ async function everyRowOf( `Composio answered the same page of ${listing.noun} twice, so ${listing.consequence}: following its cursor did not advance, so the rest of them cannot be reached. ${VENDOR_SHAPE_REMEDY}`, ); } + followed.add(follow); + + /* + * COUNTED AFTER THE PAGE IS RECORDED, so the number in the sentence is the number of pages that + * happened — see {@link PAGE_CEILING} for the off-by-one this order closes. + * + * AND THE ROWS ARE THE ONES THAT ARRIVED. The sentence asserted `at ${LISTING_LIMIT} rows each`, + * which is the page this deployment ASKED FOR and not one thing it measured: Composio is free + * to answer fifty pages of one row, and a reader told the listing was fifty thousand rows long + * would be reading a figure nothing had counted. What is said now is what was collected. + */ if (followed.size >= PAGE_CEILING) { throw new BrokerRefusalError( - `Composio has answered ${PAGE_CEILING} pages of ${listing.noun} at ${LISTING_LIMIT} rows each and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, + `Composio has answered ${PAGE_CEILING} pages of ${listing.noun}, ${rows.length} rows in all, and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, ); } - followed.add(follow); cursor = follow; } } @@ -896,14 +940,33 @@ function vendorRefusal( ); /* - * THE ACTION THIS DEPLOYMENT HOLDS IS NO LONGER ONE COMPOSIO PUBLISHES. Raised by the resolve - * that `execute` makes before it runs anything (`src/models/Tools.ts`), so nothing ran. The - * tools this deployment stores are a listing taken at some earlier moment, and the remedy is - * therefore the same one the app-mismatch refusal below already names: re-read the listing. + * THE ACTION COULD NOT BE FETCHED, WHICH IS NOT THE SAME CLAIM AS THE ONE THIS USED TO MAKE. + * + * The sentence here read "Composio no longer publishes that action", on the strength of the + * class's name. The name does not carry that: `getRawComposioToolBySlug` wraps its whole + * retrieve in a try whose catch rethrows EVERYTHING except a cancellation as this class — + * `throw new ComposioToolNotFoundError(\`Unable to retrieve tool with slug ${"${slug}"}\`, { cause: error })` + * (`@composio/core` 0.18.1, `src/models/Tools.ts:709-721`) — and `tools.execute` resolves + * through that same method (`:1163`). So a 500, a 429, a refused key, a socket that hung up and + * an action genuinely withdrawn all arrive under one name, and nothing on the error tells them + * apart. An outage was being reported to an administrator as a catalogue change, with an + * instruction to press Refresh at a vendor that was not answering. + * + * WHAT CAN HONESTLY BE SAID IS THAT IT COULD NOT BE FETCHED, AND WHICH TWO READINGS THAT HAS. + * The refresh stays in the sentence because a withdrawn action is the commonest of them and + * the refresh is the only act that settles it — but it is named as the remedy for ONE of the + * readings rather than as the remedy, and the reader is given the fact that separates them: + * whether every other action of every other app is failing too. That is a thing they can look + * at, which "no longer publishes" was not. + * + * THE VENDOR'S OWN WORDS STILL WIN WHERE THERE ARE ANY. Where the failure underneath was an API + * error carrying a server sentence, the guard at the top of this function has already returned + * null and none of this is reached — so what this row answers is the half of the class that + * explained itself least. */ case "ComposioToolNotFoundError": return refusal( - `Composio no longer publishes that action at the version this deployment recorded for it, so ${outcome}. An action withdrawn from an app and a toolkit version retired both read like this, and a retry reaches neither: refreshing ${app}'s tools on its Plugins page records what Composio publishes now.`, + `Composio would not hand that action over at the version this deployment recorded for it, so ${outcome}. This deployment's @composio/core reports an action Composio has withdrawn and a request for one that failed — a timeout, a dropped connection, a 500, a refused key — under one condition and says nothing that tells the two apart, so neither can this deployment. Refreshing ${app}'s tools on its Plugins page records what Composio publishes now, which settles it where the action is gone; where every action of every app is failing the same way, it is the request rather than the action, and Composio's status page is where that shows.`, ); /* @@ -1101,10 +1164,33 @@ export type ComposioVendor = { */ nextCursor?: unknown; }>; + /** + * Create one auth config, and hand back the id Composio gave it. + * + * `Promise` UNTIL NOW, AND THE ANSWER WAS NEVER LOOKED AT. That was read as harmless + * because nothing here needs the id — the next listing finds the config by its name. It is not: + * `transformCreateAuthConfigResponse` builds what this resolves to by dereferencing + * `response.auth_config.id` and `response.toolkit.slug` (`@composio/core` 0.18.1, + * `src/utils/transformers/authConfigs.ts:96-106`), so a drift in the answer's SHAPE raises a + * `TypeError` inside the vendor's package — after the create has gone out and been answered. + * {@link ComposioBroker.ensureAuthConfig} then reported that no config had been created, over a + * config standing at Composio that nothing in this deployment names. Reading the reply is what + * makes the difference between "nothing was created" and "something may have been" a fact + * rather than an assumption. + * + * `id?: unknown` FOR THE REASON EVERY OTHER FIELD IN THIS PROJECTION IS ONE: the `transform()` + * around it is the warn-only kind (`src/utils/transform.ts:26-36`), so + * `CreateAuthConfigResponseSchema` spelling the id a required string is the answer Composio + * MEANS to send rather than a fact about the one that arrived. + * + * NULLABLE THOUGH THE VENDOR'S OWN DECLARATION IS NOT, exactly as the account delete below is: + * the generated client parses the body and returns it, and reading a field off a `null` would + * be a crash carrying a sentence that reads like a stack trace. + */ create( toolkit: string, options: { type: "use_composio_managed_auth"; name: string }, - ): Promise; + ): Promise<{ id?: unknown } | null>; /** * Delete one auth config, and ask for the upstream credentials on it to be revoked too. * @@ -1268,6 +1354,17 @@ export type ComposioVendor = { */ const CONFIG_SUFFIX = "(OpenBot)"; +/** + * How many different unrecognised statuses one refusal names before it stops naming them. + * + * The set it bounds is as large as the app's config listing, which is paged — see + * {@link everyRowOf} — so without a bound the length of an operator's refusal is decided by how + * many authorization configs somebody made. Five is past the number of distinct words this can + * plausibly be about: `AuthConfigRetrieveResponseSchema` names two, and a vendor that has invented + * five more at once is a package upgrade rather than a sentence to read. + */ +const STATUSES_NAMED = 5; + /** * Whether this deployment made that auth config, which is the question every decision here turns on. * @@ -1475,6 +1572,15 @@ export function buildComposioClient( * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure * being fixed here is two calls resolving different rows; a total order on the id makes the * choice this file makes a stable one, whoever asks and whenever. + * + * BY CODE UNIT RATHER THAN BY `localeCompare`, WHICH IS THE WHOLE POINT OF THE SORT RATHER THAN A + * QUIBBLE WITH IT. `localeCompare` called with no locale collates in the HOST's, and the hosts + * are not one host: "ac_B" comes before "ac_a" by code unit and after it under an English + * collation, and the two callers this order exists to keep in step — a person pressing Connect + * and an administrator pressing Remove — need not be answered by the same process, the same + * container or the same build of ICU. An order that two machines can disagree about is not an + * order two callers agree on. `<` is the same total order everywhere, which is the only property + * asked of it here. */ const configsFor = async (toolkit: string): Promise => { /* @@ -1514,7 +1620,9 @@ export function buildComposioClient( */ return rows .map((row, position) => configOf(row, position, toolkit)) - .sort((one, other) => one.id.localeCompare(other.id)); + .sort((one, other) => + one.id < other.id ? -1 : one.id > other.id ? 1 : 0, + ); }; /** The subset of {@link configsFor} this deployment can claim, which is what two callers want. */ @@ -1666,6 +1774,29 @@ export function buildComposioClient( const actions: ComposioActions = { async listActions(toolkit, page): Promise { + /* + * A LIMIT THAT CANNOT TRAVEL IS THE VENDOR'S DEFAULT WEARING THE CALLER'S NAME. + * + * `getRawComposioTools` composes its request with `...(limit ? { limit } : {})` + * (`@composio/core` 0.18.1, `src/models/Tools.ts:536`), and its schema spells the field + * `z.number().optional()` with no floor (`src/types/tool.types.ts:257`). So a zero is not + * sent short — it is not sent at all: the request goes out with no limit, Composio applies + * its own page of twenty, and twenty rows come back looking exactly like everything a small + * app publishes. Nothing downstream can see it. `./composio` measures the answer against + * {@link LISTING_LIMIT} to catch a page that might be a fragment, and twenty is nowhere near + * it, so a refresh commits the vendor's default as the whole truth about the app and deletes + * every action past it from `mcp_tools` while reporting success. + * + * REFUSED RATHER THAN DEFAULTED, because the page is required on this seam precisely so that + * no layer supplies one quietly — and a caller asking for no rows is a caller with a fault, + * which is a thing to report rather than a thing to correct on their behalf. + */ + if (!Number.isInteger(page.limit) || page.limit < 1) { + throw new Error( + `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. This deployment's @composio/core drops a limit it reads as falsy rather than refusing it, so the request would have gone out with no limit at all and come back as the vendor's own default page — indistinguishable from everything ${toolkit} publishes.`, + ); + } + /* * The caller's page is passed through rather than defaulted here, because the seam made * `page` required precisely so that no layer could quietly supply one. See the module @@ -1742,13 +1873,20 @@ export function buildComposioClient( const answeredApp = resolved.toolkit; let ran: string | undefined; if (answeredApp !== undefined) { - const named = textOf(answeredApp.slug); - if (named === null) { + /* + * `answeredSlug` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE. This + * binding was called `named` and shadowed the module helper of that name for the rest of + * the block — so {@link named} was unreachable here, and an edit reaching for it would have + * been calling a string. Nothing was wrong today; the next change to this block is what the + * rename is for. + */ + const answeredSlug = textOf(answeredApp.slug); + if (answeredSlug === null) { throw new Error( `Composio sent ${sent(answeredApp.slug)} where the slug of the app ${call.slug} belongs to should be, so nothing was run: a name this deployment cannot read is not one it can compare with ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, ); } - ran = named; + ran = answeredSlug; } if (ran !== call.toolkit) { @@ -1869,9 +2007,20 @@ export function buildComposioClient( const existing = await configsMadeHere(toolkit); if (existing.length > 0) return; - await askVendor( + /* + * THE OUTCOME NO LONGER CLAIMS NOTHING WAS CREATED, BECAUSE THIS CALL CANNOT KNOW THAT. + * + * It said "no authorization config was created for gmail", and {@link vendorRefusal} reads + * every condition out through that clause — including the `TypeError` row, which is reached + * exactly when the vendor's own transformer could not read a reply it had already received. + * So the one condition most likely to mean the config EXISTS was the one telling an + * administrator it did not, and nothing in this deployment then named the object standing at + * Composio. What is true of every condition here is the half that is said now: the app is not + * enabled, and what is at Composio is not something this call can report. + */ + const created = await askVendor( { - outcome: `no authorization config was created for ${toolkit} and the app is not enabled`, + outcome: `the app is not enabled, and whether an authorization config for ${toolkit} now stands at Composio is not something this deployment can tell`, app: toolkit, }, () => @@ -1880,6 +2029,26 @@ export function buildComposioClient( name: `${name} ${CONFIG_SUFFIX}`, }), ); + + /* + * THE REPLY IS READ, WHICH IS THE HALF THAT WAS MISSING. Composio names the config it just + * made, and the answer was awaited and dropped — so an answer carrying no id at all was a + * successful enable of an app whose config this deployment could not show existed. It is not + * an id anything here needs: the next listing finds the config by its name. It is the only + * evidence in the reply that the creation this method reports actually happened, and a method + * that returns nothing has no other way to have checked. + * + * AND THE REFUSAL SAYS WHAT IS PROBABLY TRUE RATHER THAN WHAT WOULD BE TIDY. The request went + * out and Composio replied to it, so a config very likely IS standing there — saying "none + * was created" would be the same lie the outcome above stopped telling. The remedy is the + * button they just pressed, because {@link ComposioBroker.ensureAuthConfig} is idempotent + * through the name: a second enable finds the suffix and adopts what is there. + */ + if (textOf(created?.id) === null) { + throw new BrokerRefusalError( + `Composio answered the creation of an authorization config for ${toolkit} with ${sent(created?.id)} where the new config's id belongs, so this deployment cannot show that the config it just asked for exists and the app is not enabled. The request went out and Composio replied to it, so one may well be standing there: enabling ${toolkit} again finds it rather than making a second, because a config whose name ends with ${CONFIG_SUFFIX} is one this deployment claims. ${VENDOR_SHAPE_REMEDY}`, + ); + } }, async deleteAuthConfig(toolkit): Promise { @@ -2057,10 +2226,29 @@ export function buildComposioClient( * operator can search a dashboard and a changelog for. */ const unreadable = ours.filter((held) => held.status !== "DISABLED"); - const first = unreadable[0]; - if (first !== undefined) { + if (unreadable.length > 0) { + /* + * EVERY STATUS THAT WAS ACTUALLY READ, AND NONE OF THEM SPEAKING FOR THE REST. The + * sentence counted the whole set and quoted `unreadable[0]` — "Composio describes 3 of + * this deployment's configs as PENDING" is a claim about three rows established of one, + * and an operator searching their dashboard for the word they were handed would never + * reach the two that say something else. Each distinct word once, so two configs wearing + * one status do not read as two findings. + * + * AND THE LIST IS BOUNDED, for the reason {@link named} bounds each word. This set is as + * large as the listing, which is paged; a refusal whose length is decided by how many + * configs an app has is a refusal nothing downstream can hold. + */ + const words = [ + ...new Set(unreadable.map((held) => named(held.status))), + ]; + const shown = words.slice(0, STATUSES_NAMED); + const said = + words.length > shown.length + ? `${shown.join(", ")} and ${words.length - shown.length} other words` + : shown.join(", "); throw new BrokerRefusalError( - `Composio describes ${unreadable.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${named(first.status)}, which is neither ENABLED nor DISABLED, so whether a connection begun against one could complete is not something this deployment can tell. No link was made, because consent spent against a config that turns out to be disabled attaches nothing. ${VENDOR_SHAPE_REMEDY}`, + `Composio describes ${unreadable.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${said}, which ${words.length === 1 ? "is" : "are"} neither ENABLED nor DISABLED, so whether a connection begun against one could complete is not something this deployment can tell. No link was made, because consent spent against a config that turns out to be disabled attaches nothing. ${VENDOR_SHAPE_REMEDY}`, ); } throw new BrokerRefusalError( diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 0885c7fe5..b010b5b13 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { - type ComposioBroker, BrokerRefusalError, brokerSentence, + type ComposioBroker, } from "../src/plugins/broker"; import { type ComposioActions, @@ -89,6 +89,22 @@ function fakeVendor(parts: { */ const WHOLE_LISTING = 1000; +/** + * How many pages of one listing the adapter reads before it refuses, WRITTEN OUT for the reason + * {@link WHOLE_LISTING} is — and asserted exactly, for a reason of its own. + * + * `expect(calls).toBeLessThan(200)` stood against a ceiling of 50. That bound pins nothing: it + * passes at 50 pages, at 51, at 199, and at any ceiling anybody cares to raise it to short of the + * test's own runaway stop — which is to say it asserted that the paging terminated and called that + * an assertion about the ceiling. It was also green over the off-by-one it was the only test in a + * position to see: the adapter read 51 pages while its refusal said 50, because the ceiling was + * tested before the page it was counting had been recorded. + * + * So the number of pages READ and the number the refusal STATES are both asserted, and both + * against this literal. + */ +const PAGES_BEFORE_REFUSING = 50; + /** The page this deployment sends somebody back to once the consent screen is done with them. */ const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; @@ -111,6 +127,18 @@ const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; */ const WITHDRAWN = { success: true }; +/** + * What Composio answers a creation it actually performed, for the reason {@link WITHDRAWN} exists. + * + * `transformCreateAuthConfigResponse` builds this answer by reading `response.auth_config.id` + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:96-106`), so an id is the one + * thing a created config comes back with. A double answering `undefined` is a double of a reply the + * vendor cannot send — harmless only for as long as the adapter read nothing off it, which is + * exactly the state that let a shape drift here report that nothing had been created over a config + * standing at Composio. + */ +const CREATED = { id: "ac_created" }; + /** * The three auth configs this file reasons about, named once rather than spelled at each fixture. * @@ -261,6 +289,41 @@ describe("listing an app's actions", () => { }, ]); }); + + test("a page of no rows is refused rather than dropped on the way to the vendor", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioTools: async (query: unknown) => { + asked.push(query); + // Twenty rows: Composio's own default page, which is what a dropped limit produces. + return Array.from({ length: 20 }, (_, index) => ({ + slug: `GMAIL_ACTION_${index}`, + })); + }, + }, + }), + ); + + const refusal = await failureOf(actions.listActions("gmail", { limit: 0 })); + + /* + * A ZERO DOES NOT TRAVEL SHORT — IT DOES NOT TRAVEL. `getRawComposioTools` composes its request + * with `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over + * a schema that spells the field `z.number().optional()` with no floor + * (`src/types/tool.types.ts:257`), so the request goes out with no limit and Composio answers + * with its own page of twenty. Nothing downstream can see that: `./composio` catches a page + * that might be a fragment by measuring it against 1000, and twenty is nowhere near it, so the + * vendor's default commits as everything the app publishes and every action past it is deleted + * from `mcp_tools` by a refresh that reported success. + */ + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/0 rows is not a page/); + expect(refusal.message).toMatch(/tools already held are untouched/); + // Nothing went out, which is the point: the fault is in the request, not in the answer. + expect(asked).toEqual([]); + }); }); describe("the app catalogue", () => { @@ -996,6 +1059,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { list: async () => ({ items: [BY_HAND] }), create: async (...call: unknown[]) => { created.push(call); + return CREATED; }, }, }), @@ -1185,6 +1249,25 @@ describe("withdrawing one person's grants", () => { ]); }); + test("nobody with no account for the app is answered no", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { list: async () => ({ items: [] }) }, + }), + () => 1_000_000, + ); + + /* + * THE ONLY TEST IN THIS FILE THAT CAN FAIL A GATE THAT ALWAYS OPENS. Both assertions about + * `isConnected` expected `true`, so `async isConnected() { return true }` was green — and a + * gate that cannot answer no is a gate that lets every call through, which is exactly the + * question `./access` asks this method before running somebody's action. + */ + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(false); + }); + test("a refusal partway through still asks about the accounts behind it", async () => { const deleted: string[] = []; const { broker } = buildComposioClient( @@ -1817,9 +1900,19 @@ describe("each vendor condition reaches the reader as its own remedy", () => { return broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); } + /** + * ONE APP ACROSS ALL FOUR CONTEXTS, WHICH IS WHAT MAKES THE CROSS-CHECK BELOW MEAN ANYTHING. + * + * Three of these remedies name the app they are about. The linking and creating contexts were + * about `linear` and the resolving and running ones about `gmail`, so "this sentence does not + * also prescribe somebody else's step" was being asked with a regular expression naming an app + * the sentence could not have mentioned — it passed for every pair that crossed the two contexts + * for a reason that had nothing to do with the sentences, which is most of the table. One app + * makes every row comparable with every other. + */ const CALL = { - toolkit: "gmail", - slug: "GMAIL_FETCH_EMAILS", + toolkit: "linear", + slug: "LINEAR_CREATE_ISSUE", userId: "user_1", version: "20260903_00", }; @@ -1839,8 +1932,8 @@ describe("each vendor condition reaches the reader as its own remedy", () => { fakeVendor({ tools: { getRawComposioToolBySlug: async () => ({ - slug: "GMAIL_FETCH_EMAILS", - toolkit: { slug: "gmail" }, + slug: CALL.slug, + toolkit: { slug: CALL.toolkit }, }), execute: raise, }, @@ -1890,7 +1983,7 @@ describe("each vendor condition reaches the reader as its own remedy", () => { name: "ComposioConnectedAccountNotFoundError", raisedBy: "tools.execute", remedy: - /connecting gmail again on this deployment's Connected accounts page/, + /connecting linear again on this deployment's Connected accounts page/, ask: whileRunning, }, { @@ -1924,6 +2017,36 @@ describe("each vendor condition reaches the reader as its own remedy", () => { }); } + /** + * ONE VENDOR CLASS OVER EVERY WAY A FETCH CAN FAIL, which is a fact about the SDK rather than a + * reading of its name. `getRawComposioToolBySlug` wraps its retrieve in a try whose catch + * rethrows everything except a cancellation as `ComposioToolNotFoundError` (`@composio/core` + * 0.18.1, `src/models/Tools.ts:709-721`), and `tools.execute` resolves through that same method + * (`:1163`). A 500, a 429, a refused key and a socket that hung up therefore all arrive wearing + * the name of an action that was withdrawn — and the sentence read the name as the finding, + * telling an administrator during an outage that Composio no longer publishes their action and + * that pressing Refresh at the vendor that is not answering will fix it. + */ + test("the withdrawn-action condition does not claim to know Composio withdrew anything", async () => { + const failure = await failureOf( + whileResolving(raising("ComposioToolNotFoundError")), + ); + const sentence = brokerSentence(failure); + + expect(sentence).not.toBeNull(); + // The refresh stays, because a withdrawn action is the commonest of them and the refresh is + // the only act that settles that reading. + expect(sentence).toMatch(/records what Composio publishes now/); + // What was missing is the other reading, and the fact that separates the two — which is + // something an administrator can go and look at. + expect(sentence).toMatch( + /a timeout, a dropped connection, a 500, a refused key/, + ); + expect(sentence).toMatch(/says nothing that tells the two apart/); + // And the claim it must no longer make. + expect(sentence).not.toMatch(/no longer publishes that action/); + }); + /** * The vendor's own words win where there are any, which is the limit on translating at all. * @@ -2587,7 +2710,12 @@ describe("a listing that arrived with a cursor still outstanding", () => { // the one shape only the ceiling catches. The throw is the test's own stop: without a // ceiling in the adapter this listing has no end at all. if (calls > 200) throw new Error("The paging did not terminate."); - return { items: [], nextCursor: `page_${calls}` }; + // One row a page, so the row count in the refusal is a figure somebody counted rather + // than the page size this deployment asked for. + return { + items: [{ id: `ca_${calls}` }], + nextCursor: `page_${calls}`, + }; }, delete: async (id: string) => { deleted.push(id); @@ -2604,9 +2732,20 @@ describe("a listing that arrived with a cursor still outstanding", () => { // Stopping is the easy half; the half that matters is that stopping is not answering. A ceiling // that returned the rows it had would be the page ceiling again, further out and harder to see. expect(refusal).toBeInstanceOf(BrokerRefusalError); - expect(refusal.message).toMatch(/pages/); - expect(calls).toBeLessThan(200); expect(deleted).toEqual([]); + + // The number of pages that happened, and the number the sentence states, are the same number. + expect(calls).toBe(PAGES_BEFORE_REFUSING); + expect(refusal.message).toMatch( + new RegExp(`answered ${PAGES_BEFORE_REFUSING} pages`), + ); + + // And the sentence asserts no page size nobody measured. `at 1000 rows each` was the limit this + // deployment ASKED for, stated as a fact about what Composio sent — these pages carry one row. + expect(refusal.message).not.toMatch(new RegExp(`${WHOLE_LISTING} rows`)); + expect(refusal.message).toMatch( + new RegExp(`${PAGES_BEFORE_REFUSING} rows in all`), + ); }); }); @@ -2755,6 +2894,25 @@ describe("a vendor answer that is one object rather than a listing", () => { */ describe("what a malformed field of a row actually costs", () => { const CATALOGUE_ROWS: { fault: string; row: unknown; names: RegExp }[] = [ + { + fault: "a slug that arrived as null", + // The slug is the only name this deployment has for an app: it is what enabling one writes + // into a url and what every later call names, so a row without one is an app whose Add button + // records something nothing can act on. Deleting this guard left the suite green. + row: { slug: null, name: "Gmail", meta: {} }, + names: /slug/, + }, + { + fault: "a logo that is not an address", + // This value is put in an image address on an administrator's picker. Deleting this guard + // also left the suite green: the object went into `src` and the page showed a broken image. + row: { + slug: "gmail", + name: "Gmail", + meta: { logo: { url: "https://example.test/gmail.png" } }, + }, + names: /logo/, + }, { fault: "a name that arrived as null", // `?? ""` here is an app in an administrator's picker with nothing written on it, and `gmail` @@ -2902,6 +3060,177 @@ describe("what a malformed field of a row actually costs", () => { expect(deleted).toEqual([]); }); + test("an unreadable status names every config's own, not the first one's for all of them", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_a", name: "Linear (OpenBot)", status: "PENDING" }, + { id: "ac_b", name: "Linear (OpenBot)", status: "SUSPENDED" }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + /* + * TWO CONFIGS, TWO STATUSES, AND THE SENTENCE COUNTED BOTH AND QUOTED ONE — asserting of the + * pair a fact it had established of the first. The status is quoted because it is the one + * thing an operator can search a dashboard and a changelog for, and an operator given PENDING + * would never find the config that says SUSPENDED. + */ + expect(refusal.message).toMatch(/"PENDING"/); + expect(refusal.message).toMatch(/"SUSPENDED"/); + expect(refusal.message).toMatch(/2 of this deployment's 2/); + }); + + test("a status that is not a vendor enum name is described rather than repeated", async () => { + /* + * THIS BRANCH IS REACHED PRECISELY BECAUSE THE VALUE IS NOT ONE OF THE WORDS THE CODE EXPECTS, + * so "it is a closed set of enum names" — the whole argument for quoting it — is the one thing + * that cannot be assumed here. What arrives is whatever came off the wire, and the refusal it + * lands in is read off an admin page, written into an app's `lastError` and put in front of a + * model. + */ + const WIRE = `\n502 Bad Gateway\n${"x".repeat(20_000)}`; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_a", name: "Linear (OpenBot)", status: WIRE }], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal.message).not.toContain("502 Bad Gateway"); + expect(refusal.message).not.toContain("x".repeat(64)); + expect(refusal.message.length).toBeLessThan(1000); + // Still a refusal, and still one naming the state: what the value IS remains the finding even + // where the value itself is not safe to repeat. + expect(refusal.message).toMatch(/neither ENABLED nor DISABLED/); + expect(refusal.message).toMatch(/a string/); + }); + + test("the config a connection is begun against is chosen in one order everywhere", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_a", name: "Linear (OpenBot)", status: "ENABLED" }, + { id: "ac_B", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + () => 1_000_000, + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + /* + * `ac_B` BECAUSE "B" IS 0x42 AND "a" IS 0x61, which is the same answer on every machine. Under + * `localeCompare` with no locale it is the HOST that decides — an English collation puts + * `ac_a` first — and the two callers this order exists to keep in step are a person pressing + * Connect and an administrator pressing Remove, who need not be answered by the same process, + * container or build of ICU. This pair is the one that tells the two orders apart. + */ + expect(linked).toEqual([["user_1", "ac_B", { callbackUrl: RETURN_URL }]]); + }); + + test("a created config the answer does not name is reported as possibly standing", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + // The create was accepted; what came back carries no id. The answer used to be awaited + // and dropped, so this was a successful enable of an app whose config nothing here could + // show existed. + create: async () => ({}), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/may well be standing/); + expect(refusal.message).toMatch(/enabling linear again finds it/); + }); + + test("a create whose reply the SDK could not read does not claim nothing was created", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + /* + * What `transformCreateAuthConfigResponse` does to an answer with no `auth_config`: it + * reads `response.auth_config.id` (`@composio/core` 0.18.1, + * `src/utils/transformers/authConfigs.ts:96-106`) and raises inside the vendor's own + * package — AFTER the create has been sent and answered. + */ + create: async (): Promise => { + throw new TypeError( + "undefined is not an object (evaluating 'response.auth_config.id')", + ); + }, + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + ); + + /* + * THE ONE CONDITION MOST LIKELY TO MEAN THE CONFIG EXISTS WAS THE ONE SAYING IT DID NOT. The + * `TypeError` row translates a fault raised inside `@composio/core` while it READ a reply, so + * by the time this sentence is composed the request has gone out and Composio has answered it + * — and the outcome clause read "no authorization config was created for linear". + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(/no authorization config was created/); + expect(refusal.message).toMatch( + /whether an authorization config for linear now stands at Composio is not something this deployment can tell/, + ); + }); + const UNSETTLED: { fault: string; status: unknown }[] = [ { fault: "a status this deployment has never heard of", status: "PENDING" }, { fault: "no status at all", status: undefined }, @@ -2950,3 +3279,141 @@ describe("what a malformed field of a row actually costs", () => { }); } }); + +/** + * WHAT A FIELD COMPOSIO PADDED IS WORTH, WHICH IS THE FIELD AND NOT THE FIELD PLUS ITS PADDING. + * + * `textOf` decided emptiness on the TRIMMED string and answered the PADDED one — a guard that + * checked one value and passed along another — so every identifier this adapter reads travelled + * with whatever whitespace the wire wrapped it in. Not one of the four below is cosmetic: three of + * them are what a later REQUEST names, and the fourth is both sides of the one comparison this + * file refuses a call on. + * + * WHY A VENDOR WOULD SEND ONE AT ALL is the same reason `madeHere` tolerates a trailing space in a + * config's name: these values pass through dashboards where people type, paste and edit them. + */ +describe("a field Composio padded with whitespace", () => { + test("a padded slug and title reach the picker as the app's own name", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => [ + { slug: " gmail ", name: " Gmail ", meta: { toolsCount: 63 } }, + ], + }, + }), + () => 1_000_000, + ); + + const [app] = await broker.listApps(); + + /* + * THE SLUG IS THE ONE THAT COSTS SOMETHING. `addBrokeredApp` composes `composio://` from + * exactly this value, and `toolkitOf` reads the app back out of that url through a character + * class that admits no spaces — so a padded slug here is an enabled app whose url names no app + * at all, and every later call through it refuses. + */ + expect(app?.slug).toBe("gmail"); + expect(app?.name).toBe("Gmail"); + }); + + test("a padded config id is what the delete names, without the padding", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [ + { id: " ac_ours ", name: "Linear (OpenBot)", status: "ENABLED" }, + ], + }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + // The id is the whole of what a deletion names, and this one was sent verbatim: Composio is + // asked to remove an object with a name nobody holds, and this deployment records a withdrawal. + expect(deleted).toEqual([["ac_ours", { revoke_on_delete: true }]]); + }); + + test("a padded account id is what the withdrawal names", async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [{ id: " ca_1 " }] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual([["ca_1", { revoke_on_delete: true }]]); + }); + + test("a padded action slug is recorded as the action rather than as one nothing can call", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioTools: async () => [ + { slug: " GMAIL_FETCH_EMAILS ", version: "20260903_00" }, + ], + }, + }), + ); + + const [action] = await actions.listActions("gmail", { + limit: WHOLE_LISTING, + }); + + // This becomes `mcp_tools.name`, which is half that table's primary key, what a grant points at + // and what the next call sends back to Composio. + expect(action?.slug).toBe("GMAIL_FETCH_EMAILS"); + }); + + test("a padded app slug on the vendor's answer is not a mismatch", async () => { + const ran: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioToolBySlug: async () => ({ + slug: "GMAIL_FETCH_EMAILS", + toolkit: { slug: " gmail " }, + }), + execute: async (...call: unknown[]) => { + ran.push(call); + return { successful: true, data: {}, error: null }; + }, + }, + }), + ); + + /* + * THE MISMATCH REFUSAL IS THE ONE PLACE THIS FILE REFUSES TO RUN SOMETHING, and it exists for a + * url edited between a refresh and a call. A padded slug is not that: it is the same app, + * refused with a sentence naming a remedy — refresh this app's tools — that cannot change what + * Composio pads. + */ + await actions.execute( + { + toolkit: "gmail", + slug: "GMAIL_FETCH_EMAILS", + userId: "user_1", + version: "20260903_00", + }, + {}, + ); + + expect(ran).toHaveLength(1); + }); +}); From 0c6439be97475047591d6fb6498da4ae97e70146 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 20:43:22 +0200 Subject: [PATCH 148/215] Look at the arguments a call forwards, and stop four guards passing what they never measured The last tail of confirmed review findings on the Composio transport, all of one family: a test that is green because it asked a smaller question than the one its name makes. THE ATTRIBUTION TEST WAS ASSERTED OF HALF THE CALL. This file exists for one property - the user id comes off the connection and never out of the arguments a model produced - and the recorder's default execute pushed the call record and dropped `args` on the floor. So the only half that could ever be seen was the field a transport fills from the connection, and the object actually forwarded to the vendor, which is where the three spellings of an identity live, was inspected by nobody. `Recorded` now carries the arguments, snapshotted rather than held by reference, and the test pins the WHOLE call the way the adapter suite's own attribution test does ("a call that runs carries the person, the version and the arguments") - an extra field on that request is as much a finding as a wrong one. The person is renamed to one named after nothing else in the file, because `user_asker` is the house id every other call here uses and is therefore exactly the literal a transport that had stopped reading the connection would be hard-coded to. The three spellings are asserted to travel on AS ARGUMENTS, which is the correct outcome: stripping them by name would be this module reading `args` for an identity, which is the thing that makes the property structural rather than merely checked. TWO PLACEHOLDER GUARDS COULD BE WALKED PAST MID-SENTENCE. The call-side tests asked `not.toBe("Error executing the tool X")` and `not.toMatch(/^error executing the tool/i)`, so a refusal that EMBEDDED the vendor's useless placeholder satisfied both. The module's own VENDOR_PLACEHOLDER is anchored because it decides whether a string IS the placeholder; a test asking whether an answer CARRIES one must not be. Both now ask of the whole string, which is what the listing-side sibling already did. THREE TRIMS WERE ASSERTED BY NOTHING and could be deleted with the suite green: __version, in callTool - a padded version is a string Composio matches to no revision of the action, so the call fails at the vendor wearing the vendor's words for a fault that is ours; a version of only padding is no version and must reach the recorded-version refusal rather than the wire. The listing side already tested its mirror. connection.actorId - only the ABSENT key was covered. " " is a user id Composio looks up, finds no account for and refuses, so the run reads as somebody's lapsed connection instead of as a run this deployment never attributed to anybody. error, beside a success - only "" was covered. A newline read as a complaint turns a call that worked into a reported failure, withholds the action's data from the model and audits a failure against a call the vendor was happy with. THE OUTAGE FALLBACK WAS PINNED BY A FRAGMENT. `toContain("composio unreachable")` accepted the current answer and every wrong one too - the sentence with our generic advice bolted on, one cut short of the vendor's words, one with the action's name prefixed. The finding asked whether the CODE is wrong for refusing with neither the action name nor a remedy. Judgement: it is not. The module's rule is the one listingSentence follows - the words that were said beat the words we would invent - and `unexplained`, which names the action and the Plugins page, is reached only where the vendor said nothing usable. Replacing "composio unreachable" with "check that this app is still connected" is exactly the wrong advice at exactly the wrong moment, and store.ts writes the action name into the audit row beside this sentence either way. So the answer is pinned WHOLE, with the deliberate omission written down, and the padded variant added because the trim on it was untested too. THE LISTING FALLBACK WAS PINNED BY THE ONE WORD EVERY CANDIDATE ANSWER CONTAINS. "a vendor complaint that merely carries issues" asked only for "rejected", which is in the fixture, so it survived the sentence being wrapped, prefixed or cut short - and it was the one refusal on that path that never asserted the app it is about. Pinned whole now, and asserted to name the row an administrator is looking at off the Plugins page. THE BROKER SEAM CHECKED ITS OWN FIXTURE. "never quotes the address" was asked of an address built on openbot.example.com, which is the host the refusal names ON PURPOSE in its example of the fix - so `not.toContain` could only ever have been answered by the path segment, and a refusal that echoed the host back looked identical to one that had not. OPENBOT_APP_URL is an environment string and carries whatever was put in it: a customer's name in a tenant subdomain, an internal hostname, a preview host with a token. The address is now one no sentence here mentions for its own reasons, asked for whole and in its parts. A HELPER MISREPORTED ITS OWN FAILURES, in composio-adapter.test.ts, which is outside the two files this pass was scoped to and is the only place the helper the finding describes exists. `failureOf` produced `null` from its resolve arm and accepted `null` from its reject arm, so a call that REFUSED with one was reported as "The call answered where this test requires it to have refused" - sending the reader after a guard that had fired. And an `undefined` rejection was returned cast as an `Error`, so the caller's next line read `.message` off it and the suite failed with "undefined is not an object", the exact phrasing this file's own A_CRASH pattern exists to flag as a crash wearing a refusal's clothes. The two outcomes are told apart by which arm ran now, and a rejection that is not an Error says so itself. Also: three tests bound `const listing` over the module-level `listing()` helper; renamed to `refused`. Latent only. Mutation evidence, every one reverted (git status clean of src): delete the three identity keys from the forwarded args -> only "an actor named in the arguments is ignored" reddens unexplained() carries the placeholder mid-sentence -> both placeholder tests redden; both passed before this change drop .trim() from the __version read -> "the version goes out without its padding" reddens drop .trim() from connection.actorId -> "a call with nobody attributed refuses" reddens drop .trim() from the reported error field -> "an error made of nothing but padding" reddens append a remedy to the thrown-message fallback -> "falls back to the thrown message, whole" reddens; toContain would have stayed green drop .trim() from the thrown message -> the same test plus "carries no message at all" redden prefix the listing fallback with "Composio: " -> "merely carries issues" reddens; toContain("rejected") would have stayed green quote the HOST back in brokerReturnUrl's refusal -> the seam test reddens, and the pre-change seam file was run against the same mutation at 7 pass / 0 fail make listActions refuse with null / with undefined -> new failureOf says "The call refused with null rather than with an Error"; the pre-change helper said "The call answered", and for undefined crashed at `refusal.message` bunx tsc --noEmit clean. bun test server/tests: 2286 pass, 5 skip, 0 fail, 2291 across 138 files - the 2285/5/0 baseline plus the one test added here. Runs that report 2163/5/1 across 2169 are the shared-database flake: the unnamed failure is a timeout in plugin-store.integration.test.ts, which loses that file's 123 registrations (2291 - 2169 = 122) and passes 123/123 run alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-adapter.test.ts | 36 ++- server/tests/composio-broker-seam.test.ts | 27 +- server/tests/composio-transport.test.ts | 292 +++++++++++++++++----- 3 files changed, 281 insertions(+), 74 deletions(-) diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index b010b5b13..7c4de0bbf 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -182,18 +182,44 @@ const NO_PAGE_REMEDY = * `rejects.toThrow(...)` cannot be followed by a second question about the SAME error — which kind * it was, what else its sentence does not say — so every refusal assertion that wanted more than * one fact about one throw had to settle for the first. This hands the error over instead. + * + * IT TOLD TWO LIES ABOUT ITS OWN FAILURES, and a helper every refusal in this file is read through + * is the last place a wrong answer about what happened should come from. + * + * `null` WAS BOTH ANSWERS AT ONCE. It was the value the resolve arm produced AND a value the reject + * arm can hand back — `Promise.reject(null)` is a rejection, and a vendor stub or an adapter path + * that raises a falsy value is exactly the kind of thing the tests below are written to catch — so + * a call that REFUSED with one was reported as "The call answered where this test requires it to + * have refused." That sends the reader to look for a missing guard when the guard fired. The two + * outcomes are now told apart by which arm ran rather than by the value it carried. + * + * `undefined` WAS RETURNED AS AN `Error` IT IS NOT. The cast made the type check and nothing else: + * the caller's very next line reads `.message` off it and the suite fails with the runtime's own + * "undefined is not an object", which is the phrasing this file's own {@link A_CRASH} + * exists to flag as a crash wearing a refusal's clothes — raised here, in the helper, about the + * test rather than about the adapter. A rejection that is not an `Error` has no message to read, so + * this says so itself instead of handing the caller something that will. */ async function failureOf(work: Promise): Promise { - const raised = await work.then( - () => null, - (error: unknown) => error, + type Outcome = { refused: false } | { refused: true; raised: unknown }; + + const outcome = await work.then( + () => ({ refused: false }), + (raised: unknown) => ({ refused: true, raised }), ); - if (raised === null) { + if (!outcome.refused) { throw new Error( "The call answered where this test requires it to have refused.", ); } - return raised as Error; + if (!(outcome.raised instanceof Error)) { + throw new Error( + `The call refused with ${ + outcome.raised === null ? "null" : typeof outcome.raised + } rather than with an Error, so there is no message on it for this test to read.`, + ); + } + return outcome.raised; } /** diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 49177a1f7..0c8a72bdd 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -67,17 +67,40 @@ describe("brokerReturnUrl", () => { } }); + /** + * THE ADDRESS HANDED IN WAS BUILT ON THE HOST THE REFUSAL QUOTES ON PURPOSE, which left this + * assertion verifying one segment of it. + * + * The sentence names `openbot.example.com` in its own example of the fix — "https:// + * openbot.example.com rather than openbot.example.com" — and the value this test passed was + * `openbot.example.com/settings/...`. So `not.toContain` could only ever have been answered by + * the path: a refusal that echoed the host back would have matched the message's own example and + * looked, to this test, exactly like one that had not. + * + * The host is the half that carries the most. `OPENBOT_APP_URL` is an environment string and an + * environment string carries whatever was put in it: a customer's name in a tenant subdomain, an + * internal hostname that says how this deployment is reached, a preview host with a token in it. + * This refusal goes to whoever asked — it is a `BrokerRefusalError`, which is a promise + * that the message is safe to show them — so the address must be absent from it as a whole and + * in its parts, and the value asked about has to be one no sentence here mentions for its own + * reasons. + */ test("says which setting fixes it, and never quotes the address", () => { + const address = + "openbot-tenant-42.internal.corp/settings/connected-accounts/9f3c"; + let thrown: unknown; try { - brokerReturnUrl("openbot.example.com/settings/connected-accounts/x"); + brokerReturnUrl(address); } catch (error) { thrown = error; } const sentence = brokerSentence(thrown); expect(sentence).toContain("OPENBOT_APP_URL"); - expect(sentence).not.toContain("/settings/connected-accounts/x"); + expect(sentence).not.toContain(address); + expect(sentence).not.toContain("openbot-tenant-42.internal.corp"); + expect(sentence).not.toContain("/settings/connected-accounts/9f3c"); }); test("hands back the address a configured deployment built", () => { diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 201296c9d..85cfceb2a 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -31,11 +31,23 @@ import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; afterEach(() => useComposioClient(null)); +/** + * One call as the vendor received it: the four fields that decide what it MEANS, and the arguments. + * + * THE ARGUMENTS ARE HERE BECAUSE THE ATTRIBUTION PROPERTY IS ABOUT BOTH HALVES. The recorder used to + * take the call record and drop `args` on the floor, so the one test this file exists for — a user + * id that comes off the connection and never out of a model's arguments — could only ever see the + * half that was already right. `@composio/client` resolves the connected account from the execute + * body's `user_id` and carries the model's own arguments beside it in `arguments` + * (0.1.0-alpha.76, `resources/tools.d.ts:480-493`), so what a transport sends into the second of + * those is as much of the call as what it sends into the first. + */ type Recorded = { toolkit: string; slug: string; userId: string; version: string; + args: Record; }; /** @@ -93,8 +105,12 @@ function recording(answers: Partial = {}): { listActions: answers.listActions ?? (async () => []), execute: answers.execute ?? - (async (call) => { - calls.push({ ...call }); + (async (call, args) => { + // SNAPSHOTTED RATHER THAN HELD BY REFERENCE, for the reason the schema test snapshots: + // `toEqual` against a live reference holds whatever happened to the object afterwards, so + // a transport that handed its arguments over and then edited them would be recorded as + // having sent whatever it edited them into. + calls.push({ ...call, args: structuredClone(args) }); return answered({ ok: true }); }), }, @@ -670,13 +686,13 @@ describe("listing an app's actions", () => { // `refreshTools` commits it as a healthy refresh. No client installed is the SHIPPED state — // nothing under `server/src` calls `useComposioClient` — so `[]` here was the only answer a // real Composio refresh could produce, and committing it deleted every recorded action. - const listing = listTools({ url: "composio://gmail" }); + const refused = listTools({ url: "composio://gmail" }); - await expect(listing).rejects.toThrow( + await expect(refused).rejects.toThrow( /not configured for this deployment/i, ); - const thrown = (await listing.catch((error: unknown) => error)) as Error; + const thrown = (await refused.catch((error: unknown) => error)) as Error; expect(thrown.message).toContain("gmail"); // Not a crash report. No deployment installs a client yet, so an operator reading this has to // recognise a state rather than go hunting for a fault. @@ -704,13 +720,13 @@ describe("listing an app's actions", () => { }).client, ); - const listing = listTools({ url: "https://example.com" }); + const refused = listTools({ url: "https://example.com" }); // The two refusals send an operator to different places — one to this deployment's // configuration, one to the row — so they must not share a sentence. - await expect(listing).rejects.toThrow(/does not name a Composio app/i); + await expect(refused).rejects.toThrow(/does not name a Composio app/i); - const thrown = (await listing.catch((error: unknown) => error)) as Error; + const thrown = (await refused.catch((error: unknown) => error)) as Error; expect(thrown.message).not.toMatch(/not configured/i); expect(thrown.message).toContain("https://example.com"); expect(asked).toEqual([]); @@ -739,14 +755,14 @@ describe("listing an app's actions", () => { }).client, ); - const listing = listTools({ url: "composio://gmail" }); + const refused = listTools({ url: "composio://gmail" }); // Propagated rather than answered empty, because `refreshTools` records a throw in `lastError` // and leaves the tools it already holds alone. An empty answer would read as an app that has no // actions, and every grant would point at a name nothing advertises. - await expect(listing).rejects.toThrow(/did not match/i); + await expect(refused).rejects.toThrow(/did not match/i); - const thrown = await listing.catch((error: unknown) => error); + const thrown = await refused.catch((error: unknown) => error); expect(String((thrown as Error).message)).not.toContain("invalid_type"); expect(String((thrown as Error).message)).toContain("gmail"); }); @@ -1002,7 +1018,9 @@ describe("listing an app's actions", () => { */ for (const thrown of [ Object.assign( - new Error("Gmail rejected the query: from: is not a search operator."), + new Error( + "Composio's gmail gateway rejected the query: from: is not a search operator.", + ), { issues: ["from: is not a search operator."] }, ), Object.assign(new Error("Composio rejected the request for gmail."), { @@ -1022,7 +1040,15 @@ describe("listing an app's actions", () => { (error: unknown) => (error as Error).message, ); - expect(message).toContain("rejected"); + // PINNED WHOLE RATHER THAN BY THE ONE WORD EVERY CANDIDATE ANSWER CONTAINS. "rejected" is in + // the fixture, so it survived the sentence being wrapped, prefixed or cut short — and both + // of the answers this branch must not give, the upgrade advice and "Composio did not answer + // with an action list for gmail.", are sentences a fragment check cannot tell from this one. + expect(message).toBe(thrown.message); + // And it names the row the operator is looking at, which is what every sibling refusal on + // this path asserts and this one did not: `refreshTools` writes this string into `lastError` + // and an administrator reads it off the Plugins page beside a list of apps. + expect(message).toContain("gmail"); expect(message).not.toMatch(/upgrad/i); } }); @@ -1114,6 +1140,7 @@ describe("calling one action", () => { slug: "GMAIL_FETCH_EMAILS", userId: "user_asker", version: "20260903_00", + args: { query: "is:unread" }, }, ]); expect(result.isError).toBe(false); @@ -1202,19 +1229,85 @@ describe("calling one action", () => { expect(result.text).toMatch(/only if Composio publishes/i); }); + test("the version goes out without its padding, and padding alone is no version", async () => { + /* + * THE CALL SIDE OF A TRIM THE LISTING SIDE ALREADY TESTS. "a version made only of whitespace is + * recorded as no version at all" pins what `listTools` writes; nothing pinned what `callTool` + * sends, so the `.trim()` here could be deleted with the whole suite green. + * + * BOTH HALVES OF IT MATTER AND THEY FAIL DIFFERENTLY. A padded version forwarded as it arrived + * is a string Composio does not match to any revision of the action — the call fails at the + * vendor, wearing the vendor's words, for a fault that is this deployment's. A version made + * only of padding is not a version at all, and read as one it sends `" "` where the module's + * own refusal says there is nothing to fall back on; the recorded-version guard is the thing + * that keeps that off the wire, and it is the trim that lets the guard see it. + * + * The same two shapes the listing side uses, reached from the other end: `mcp_tools` holds what + * `listTools` wrote, and `store.ts` hands it back through `__version` on the next call. + */ + const { client, calls } = recording(); + useComposioClient(client); + + const padded = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: " 20260903_00\n" }, + ); + + expect(padded.isError).toBe(false); + expect(calls.map((call) => call.version)).toEqual(["20260903_00"]); + + const blank = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: " " }, + ); + + expect(blank.isError).toBe(true); + expect(blank.text).toMatch(/no recorded version/i); + // Refused before dialling, which is the half a message match cannot show: nothing was added to + // the record above. + expect(calls).toHaveLength(1); + }); + test("an actor named in the arguments is ignored, whichever way it is spelled", async () => { + /* + * THE HEADLINE PROPERTY OF THIS FILE, AND IT WAS ASSERTED OF HALF THE CALL. The recorder's + * default `execute` dropped the arguments, so what this test could see was the call record's + * own `userId` — the half a transport gets right by construction, because it is the field it + * fills from the connection. What it could not see was the object actually forwarded to the + * vendor, which is where a model's three spellings of an identity live and where the vendor + * reads `arguments` from. Both halves go out on one request, so both halves are the claim. + * + * ASSERTED AS THE WHOLE CALL rather than field by field, the way the adapter suite's own + * attribution test is ("a call that runs carries the person, the version and the arguments"): + * an EXTRA field on this request is as much a finding as a wrong one, and a per-field check is + * blind to every one of them. + * + * THE THREE SPELLINGS TRAVEL ON AS ARGUMENTS, which is the correct outcome and not an + * oversight. They are the model's own arguments; the vendor is the party that decides what an + * action's `userId` parameter means, and stripping keys by name would be this module reading + * `args` for an identity — the very thing that makes the property structural rather than + * merely checked. What must never happen is one of them reaching the field beside them. + * + * THE PERSON IS NAMED AFTER NOTHING ELSE IN THIS FILE, which is what makes the assertion able + * to tell attribution from coincidence: `user_asker` is the house id every other call here + * uses, so it is exactly the literal a transport that had stopped reading the connection would + * most plausibly be hard-coded to. + */ const { client, calls } = recording(); useComposioClient(client); + const spelled = { + userId: "user_victim", + user_id: "user_victim", + entityId: "user_victim", + }; + await callTool( - { url: "composio://gmail", actorId: "user_asker" }, + { url: "composio://gmail", actorId: "user_whose_mailbox_this_is" }, "GMAIL_FETCH_EMAILS", - { - userId: "user_victim", - user_id: "user_victim", - entityId: "user_victim", - __version: "20260903_00", - }, + { ...spelled, query: "is:unread", __version: "20260903_00" }, ); // The identity is not a field a model fills. This is the defect OpenTag got wrong three times, @@ -1223,27 +1316,42 @@ describe("calling one action", () => { { toolkit: "gmail", slug: "GMAIL_FETCH_EMAILS", - userId: "user_asker", + userId: "user_whose_mailbox_this_is", version: "20260903_00", + args: { ...spelled, query: "is:unread" }, }, ]); }); test("a call with nobody attributed refuses and reaches nothing", async () => { - const { client, calls } = recording(); - useComposioClient(client); + /* + * AN ACTOR MADE OF PADDING IS NOBODY, and that is the half of this guard nothing was asking. + * Only the absent key was tested, so the `.trim()` in front of the check could be deleted with + * the suite green — and what it keeps out is worse than an absent id, not better: `" "` is a + * user id Composio will happily look up, find no connected account for, and refuse. The call + * would then read as somebody's lapsed connection rather than as a run this deployment never + * attributed to anybody, which sends the person who reads it to the wrong page. + * + * The shapes are the ones an identifier arrives as when something upstream had nothing to put + * in it: a column read back empty, a header that was sent blank, a value assembled by a shell. + */ + for (const actorId of [undefined, "", " ", "\n\t "]) { + const { client, calls } = recording(); + useComposioClient(client); - const result = await callTool( - { url: "composio://gmail" }, - "GMAIL_FETCH_EMAILS", - { - __version: "20260903_00", - }, - ); + const result = await callTool( + { url: "composio://gmail", actorId }, + "GMAIL_FETCH_EMAILS", + { + __version: "20260903_00", + }, + ); - expect(result.isError).toBe(true); - expect(result.text).toMatch(/not attributed to anybody/i); - expect(calls).toEqual([]); + const named = JSON.stringify(actorId ?? null); + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + expect(result.text).toMatch(/not attributed to anybody/i); + expect(calls).toEqual([]); + } }); test("a thrown failure is reported with the vendor's own sentence", async () => { @@ -1283,23 +1391,52 @@ describe("calling one action", () => { expect(result.text).not.toContain("x-request-id"); }); - test("a failure with no vendor sentence falls back to the thrown message", async () => { - useComposioClient( - recording({ - execute: async () => { - throw new Error("composio unreachable"); - }, - }).client, - ); + test("a failure with no vendor sentence falls back to the thrown message, whole", async () => { + /* + * WHY THIS ONE REFUSAL NAMES NEITHER THE ACTION NOR A REMEDY, which every sibling around it + * does and which this test used to accept without saying anything about. + * + * `toContain` accepted the current answer and would have accepted any of the wrong ones too: a + * sentence with this deployment's generic advice bolted on, one cut short of the vendor's + * words, one with the action's name prefixed. So what the fallback actually IS was pinned by + * nobody, and the difference between the branches is the whole subject of this describe. + * + * THE RULE THE MODULE FOLLOWS IS THAT THE WORDS THAT WERE SAID BEAT THE WORDS WE WOULD INVENT, + * and it is the same rule `listingSentence` follows on the other path. `unexplained` — which + * names the action and sends the reader to the Plugins page — is what the two sibling tests + * below assert, and it is reached only where the vendor said NOTHING usable: an empty message, + * or the placeholder. A transport fault that came with a diagnosis is not that case, and + * replacing "composio unreachable" with "check that this app is still connected" would be + * exactly the wrong advice at exactly the wrong moment — the connection is fine and Composio + * is down. The action's name is in `store.ts`'s audit row beside this sentence either way, and + * the model reading it has just called the action. + * + * So this refusal carrying neither is deliberate, and the test now says so by pinning the + * answer WHOLE rather than by looking for a fragment inside whatever arrived. + * + * THE PADDED ONE IS HERE BECAUSE THE TRIM IS. What comes back is measured by the cap and read + * by a person, and the module already decided this string was worth passing on — with the + * padding dropped, the way `vendorSentence` drops it. + */ + for (const thrown of ["composio unreachable", " composio unreachable\n"]) { + useComposioClient( + recording({ + execute: async () => { + throw new Error(thrown); + }, + }).client, + ); - const result = await callTool( - { url: "composio://gmail", actorId: "user_asker" }, - "GMAIL_FETCH_EMAILS", - { __version: "20260903_00" }, - ); + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); - expect(result.isError).toBe(true); - expect(result.text).toContain("composio unreachable"); + expect(result.isError).toBe(true); + expect(result.text).toBe("composio unreachable"); + expect(result.truncated).toBe(false); + } }); test("a result is capped visibly rather than silently", async () => { @@ -1437,7 +1574,11 @@ describe("calling one action", () => { ); expect(result.isError).toBe(true); - expect(result.text).not.toBe("Error executing the tool GMAIL_FETCH_EMAILS"); + // ASKED OF THE WHOLE STRING, the way the listing-side sibling asks it. A not-equals only + // refuses the placeholder standing alone, so a refusal that carried it in the middle of a + // sentence — "GMAIL_FETCH_EMAILS failed: Error executing the tool GMAIL_FETCH_EMAILS" — is the + // same useless words in a model's context and in the audit row, and passed. + expect(result.text).not.toMatch(/error executing the tool/i); expect(result.text).toMatch(/Plugins page/); }); @@ -1587,25 +1728,38 @@ describe("calling one action", () => { expect(result.text).not.toContain("m1"); }); - test("an empty error beside a success is still a success", async () => { - // The other side of the rule, and the reason it is worded as a SENTENCE rather than as a - // present field: `successful: !response.error` treats `""` as success, so an empty string is - // the vendor saying nothing went wrong in the least committal way available to it. - useComposioClient( - recording({ - execute: async () => - answered({ messages: [] }, { successful: true, error: "" }), - }).client, - ); + test("an error made of nothing but padding beside a success is still a success", async () => { + /* + * The other side of the rule, and the reason it is worded as a SENTENCE rather than as a + * present field: `successful: !response.error` treats `""` as success, so an empty string is + * the vendor saying nothing went wrong in the least committal way available to it. + * + * THE PADDED ONES ARE THE HALF THAT WAS UNASSERTED. Only `""` was here, so the `.trim()` that + * decides whether this field says anything could be deleted with the suite green — and what it + * would cost is the worst outcome on this path: a newline in `error` read as a complaint turns + * a call that worked into a reported failure, the action's own data is withheld from the model + * as content, and `store.ts` audits a failure against a call the vendor was perfectly happy + * with. A blank field is not a sentence here for the same reason it is not one in + * `vendorSentence`. + */ + for (const error of ["", " ", "\n\t", "\r\n "]) { + useComposioClient( + recording({ + execute: async () => + answered({ messages: [] }, { successful: true, error }), + }).client, + ); - const result = await callTool( - { url: "composio://gmail", actorId: "user_asker" }, - "GMAIL_FETCH_EMAILS", - { __version: "20260903_00" }, - ); + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); - expect(result.isError).toBe(false); - expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); + const named = JSON.stringify(error); + expect(`${named}: ${result.isError}`).toBe(`${named}: false`); + expect(result.text).toBe(JSON.stringify({ messages: [] }, null, 2)); + } }); test("a list where the envelope should be is refused, not called an empty success", async () => { @@ -1932,7 +2086,11 @@ describe("calling one action", () => { ); expect(result.isError).toBe(true); - expect(result.text).not.toMatch(/^error executing the tool/i); + // ANCHORED TO NOTHING, for the reason above: the module's own `VENDOR_PLACEHOLDER` is anchored + // because it is deciding whether a string IS the placeholder, and this is asking whether a + // refusal CARRIES it. A start-anchored question of the answer lets it through anywhere but the + // first character. + expect(result.text).not.toMatch(/error executing the tool/i); expect(result.text).toMatch(/Plugins page/); }); From 69a91937e18638fd6f3496fd5b02532475ddb3a2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 21:36:14 +0200 Subject: [PATCH 149/215] Read the vendor's own sentence where it is actually thrown, and let an authored refusal beat it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this transport says in front of a person or a model, two of them wrong and the third unwatched. THE LISTING PATH NEVER ASKED WHETHER WE AUTHORED THE SENTENCE. `listingSentence` read `vendorSentence` and fell back to the thrown message, and consulted `brokerSentence` nowhere. `routes.ts` reads the authored one first and `callTool`'s own catch was corrected to the same order; `./composio-adapter`'s `askVendor` wraps the raw tool listing exactly as it wraps the execute, so a `BrokerRefusalError` arrives here as readily as there. What it lost to is the same read one level shallow that the call path lost to: `vendorRefusal` authors only where `vendorSentence` of the ORIGINAL was null, so asking the WRAPPER the same question lands somewhere the adapter never judged. A remedy naming the step that clears the condition was being replaced by whatever sat there. THE VENDOR'S WORDS WERE READ AT ONE DEPTH AND THROWN AT TWO. `cause.error.error.message` is the sentence inside a wrapper, but `@composio/core` 0.18.1 wraps only some of what it does: the auth-config and connected-account listings and the raw tool listing all `await this.client.*` with no try around them, and both raw deletes go straight to `@composio/client`. Those five throw that package's own `APIError`, which hangs the response body on `.error` and sets no `cause` at all — so the sentence sits one level shallower and the reach past it found nothing. What went out in its place was worse than nothing: `APIError` builds its message by JSON-stringifying the whole body behind the status code wherever the body has no top-level `message`, and Composio's bodies put theirs at `error.message`. So an operator's `lastError`, `store.ts`'s audit row and a model's context window were getting a status code and the entire reply, trace id and all. Both depths are read now, both through the one judgement that refuses the placeholder, the blank and the non-string — the reason that judgement lives in this function at all — and the response dump is refused on the same grounds the placeholder is, by a guard both callers now share rather than one growing it alone. AND NOTHING STOOD OVER THE FIELD THAT DECIDES WHETHER A BOT MAY RUN AN ACTION. `destructive` was asserted as `false` and never as `true` past `effectOf`, so hardcoding `destructive: false` in the mapping left all 73 tests green — an action that deletes a mailbox could have been recorded as safe, approved once and run for ever. Pinned now on both answers at once, so a mapping that hardcodes either fails. Two neighbours that also survived mutation are pinned with it: the empty `description` a vendor who described nothing gets, which `?? null` would have made a NULL against a NOT NULL column, and the cap's boundary, where `<` reports a truncation nothing was taken from. Tests: 73 -> 84 on the transport suite, seven of the eleven red against the unfixed code first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 102 +++++++- server/tests/composio-transport.test.ts | 332 ++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 1d18f5071..07eea5e0d 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -679,12 +679,41 @@ export async function listTools(connection: { * already made about the string was thrown away at the last line. What comes out is read by a person * off an admin page, put in front of a model, and measured by {@link cap}, and in the third of those * the padding is counted against somebody's context window. + * + * AND IT IS LOOKED FOR AT BOTH DEPTHS THE VENDOR THROWS IT AT, which is the half that made this + * function blind on five of this transport's calls. `cause.error.error.message` is the sentence + * inside a wrapper — `ComposioToolExecutionError` keeps the API error as its `cause` — but + * `@composio/core` 0.18.1 wraps only some of what it does. The auth-config and connected-account + * listings and the raw tool listing all `await this.client.*` with no try around them + * (`src/models/AuthConfigs.ts`, `src/models/ConnectedAccounts.ts`, `src/models/Tools.ts:552-555`), + * and `./composio-adapter` calls both raw deletes on the client itself — so what those five throw + * is `@composio/client`'s own `APIError`, which hangs the response body on `.error` and sets no + * `cause` at all (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:9-24`). The sentence is one + * level shallower there, and reaching past it cost the reader the vendor's own words on every one. + * + * WHAT IT COST THEM INSTEAD IS THE WHOLE REPLY. That class builds its own `message` as + * `${"${status}"} ${"${JSON.stringify(body)}"}` wherever the body has no top-level `message` + * (`src/core/error.ts:26-44`), and Composio's body puts its sentence at `error.message` — so the + * fallback to the thrown message handed `lastError`, the audit row and a model's context a status + * code followed by the entire response. See {@link VENDOR_RESPONSE_DUMP}, which refuses it. + * + * THE JUDGEMENT BELOW APPLIES AT BOTH DEPTHS, because that is why it lives in this function at all. + * A second place to read the field would otherwise be a second way past the check on the vendor's + * placeholder, on the blank string and on a `message` that is not a string — which is exactly the + * bypass this function was written to close. */ export function vendorSentence(error: unknown): string | null { - const cause = (error as { cause?: unknown } | null | undefined)?.cause; - const outer = (cause as { error?: unknown } | null | undefined)?.error; - const inner = (outer as { error?: unknown } | null | undefined)?.error; - const message = (inner as { message?: unknown } | null | undefined)?.message; + const thrown = schemaNode(error); + for (const carrier of [thrown, schemaNode(thrown?.cause)]) { + const body = schemaNode(carrier?.error); + const sentence = sentenceOf(schemaNode(body?.error)?.message); + if (sentence !== null) return sentence; + } + return null; +} + +/** One candidate field, judged the one way every depth is judged. See {@link vendorSentence}. */ +function sentenceOf(message: unknown): string | null { const sentence = typeof message === "string" ? message.trim() : ""; return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? null : sentence; } @@ -698,6 +727,40 @@ export function vendorSentence(error: unknown): string | null { */ export const VENDOR_PLACEHOLDER = /^error executing the tool\b/i; +/** + * The client's other non-sentence: a status code with the whole reply stringified behind it. + * + * `APIError` builds its `message` from the body's own `message` where there is one and otherwise + * from `JSON.stringify(body)` (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:26-44`), and + * Composio's bodies put their sentence at `error.message` instead — so the second branch is the + * common one, and it is a response dump rather than an explanation. Handed on as the fallback it + * put a trace id and a validation payload on an admin page, in `store.ts`'s audit row and in a + * model's context, which is the one thing {@link vendorSentence} exists to keep out of all three. + * + * REFUSED ON THE SAME GROUNDS THE PLACEHOLDER IS, and no wider. What is matched is a three-digit + * status followed by the opening of a JSON document, because that is the shape the client builds + * and nothing a person would write; "404 status code (no body)" and a body whose own `message` came + * through — "400 Invalid auth config id" — are both sentences, and both still pass. + */ +export const VENDOR_RESPONSE_DUMP = /^\d{3} [[{]/; + +/** + * The message a failure was THROWN with, where that is worth showing, and null where it is not. + * + * Its own function because both callers ask the identical question and one of them used to ask it + * differently: a guard that grew a third refusal on one path and not the other would put the + * vendor's response dump in front of a model or an operator depending on which door they arrived + * through, which is the divergence `callTool`'s catch already had to be corrected for once. + */ +function thrownSentence(error: unknown): string | null { + const thrown = error instanceof Error ? error.message.trim() : ""; + return thrown === "" || + VENDOR_PLACEHOLDER.test(thrown) || + VENDOR_RESPONSE_DUMP.test(thrown) + ? null + : thrown; +} + /** * What to say when the vendor reported a failure and said nothing about it. * @@ -758,17 +821,34 @@ function isSchemaMismatch(error: unknown): boolean { * half this function was missing. "Error executing the tool X" names only the thing the reader * asked for; on this path they asked to refresh an app, so it is the one fact they already had. * Falling through to the app's name at least tells them which row went wrong. + * + * AND A SENTENCE THIS DEPLOYMENT AUTHORED BEATS ANYTHING THE VENDOR SAID, which is the half it was + * missing after that. This function consulted `brokerSentence` nowhere at all, and it is the rule + * `routes.ts` follows (`brokerRefusal`, `:89-91`) and the rule {@link callTool}'s own catch was + * corrected to. `./composio-adapter`'s `askVendor` wraps the raw tool listing exactly as it wraps + * the execute, so a {@link BrokerRefusalError} arrives on this path as readily as on that one — + * and the class is the promise that makes preferring it safe: `./broker` raises one only where the + * sentence names the step that fixes the condition and is safe to show anybody who could have + * asked. A failure it cannot explain stays a plain `Error` and falls through to the vendor's own + * words below, exactly as before. + * + * WHAT IT WAS LOSING TO IS A READ ONE LEVEL SHALLOW, the same way `callTool`'s was. `vendorRefusal` + * authors only where {@link vendorSentence} of the ORIGINAL error was null, so the vendor keeps the + * last word wherever it had one; asking the same question of the WRAPPER reaches through its + * `cause` to the original and lands somewhere the adapter never judged. A remedy written for the + * exact condition was being replaced by whatever happened to sit there. */ function listingSentence(toolkit: string, error: unknown): string { + const authored = brokerSentence(error); + if (authored !== null) return authored; + if (isSchemaMismatch(error)) { return `Composio's action list for ${toolkit} did not match the shape this deployment's @composio/core accepts, so the list was not refreshed and the tools already held are untouched. That is a vendor change rather than a setting: upgrading the package is the fix.`; } - const thrown = error instanceof Error ? error.message.trim() : ""; return ( vendorSentence(error) ?? - (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) - ? `Composio did not answer with an action list for ${toolkit}.` - : thrown) + thrownSentence(error) ?? + `Composio did not answer with an action list for ${toolkit}.` ); } @@ -1115,12 +1195,8 @@ export async function callTool( ); } // The vendor's own sentence when there is one, because a generic message costs a diagnosis. - const thrown = error instanceof Error ? error.message.trim() : ""; return failure( - vendorSentence(error) ?? - (thrown === "" || VENDOR_PLACEHOLDER.test(thrown) - ? unexplained(toolName) - : thrown), + vendorSentence(error) ?? thrownSentence(error) ?? unexplained(toolName), ); } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 85cfceb2a..9aa70157d 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -94,6 +94,40 @@ function nested(message: unknown): unknown { return { cause: { error: { error: { message } } } }; } +/** + * The SAME failure with nothing wrapped around it, which is how several of these calls arrive. + * + * `@composio/client`'s `APIError` hangs the response body on `.error` and sets no `cause` at all, + * and builds its own `message` as the status code followed by that body JSON-stringified whole + * where the body has no top-level `message` — `${"${status}"} ${"${JSON.stringify(error)}"}` + * (`@composio/client` 0.1.0-alpha.76, `src/core/error.ts:9-45`). Composio's body puts its sentence + * at `error.message`, so that fallback is what every one of these throws carries in `.message`. + * + * It reaches this transport unwrapped from five calls: `@composio/core` 0.18.1 awaits + * `this.client.authConfigs.list`, `this.client.connectedAccounts.list` and `this.client.tools.list` + * with no try around them (`src/models/AuthConfigs.ts`, `src/models/ConnectedAccounts.ts`, + * `src/models/Tools.ts:552-555`), and `./composio-adapter` calls both raw deletes on the client + * itself. So the sentence sits one level shallower here than in {@link nested}, and the dump is + * what escaped in its place. + */ +function unwrapped(message: unknown): Error { + const body = { error: { message } }; + return Object.assign(new Error(`404 ${JSON.stringify(body)}`), { + status: 404, + headers: { "x-request-id": "must-not-appear" }, + error: body, + }); +} + +/** The same client error for a body with no sentence anywhere in it: status code, then the lot. */ +function dumped(body: Record): Error { + return Object.assign(new Error(`502 ${JSON.stringify(body)}`), { + status: 502, + headers: { "x-request-id": "must-not-appear" }, + error: body, + }); +} + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -334,6 +368,55 @@ describe("finding the vendor's own sentence", () => { ), ).toBe("No connected account found; error executing the tool X."); }); + + test("the sentence is found where a client error with no wrapper puts it", () => { + /* + * ONE DEPTH WAS READ AND TWO ARE THROWN. See {@link unwrapped}: five of this transport's calls + * surface `@composio/client`'s own `APIError`, which carries the body on `.error` and no + * `cause`, so `cause.error.error.message` found nothing on any of them and the vendor's + * readable sentence was skipped in favour of a status code and the whole response body. + */ + expect( + vendorSentence( + unwrapped( + "No connected account found for user ID u1 for toolkit gmail", + ), + ), + ).toBe("No connected account found for user ID u1 for toolkit gmail"); + }); + + test("every judgement this function makes applies at the shallower depth too", () => { + /* + * THE POINT OF THE JUDGEMENT LIVING HERE. It was moved into this function so that no caller + * could reach a sentence without it; a second depth read without it would be that bypass + * rebuilt. Each of these is the assertion its {@link nested} sibling above makes, asked of the + * place a client error actually puts the field. + */ + expect( + vendorSentence(unwrapped("Error executing the tool GMAIL_FETCH_EMAILS")), + ).toBeNull(); + expect( + vendorSentence(unwrapped(" error executing the tool X\n")), + ).toBeNull(); + expect(vendorSentence(unwrapped(""))).toBeNull(); + expect(vendorSentence(unwrapped(" "))).toBeNull(); + expect(vendorSentence(unwrapped(1810))).toBeNull(); + expect(vendorSentence(unwrapped(null))).toBeNull(); + expect(vendorSentence(unwrapped({ text: "a nested sentence" }))).toBeNull(); + expect(vendorSentence(unwrapped(["a sentence in a list"]))).toBeNull(); + expect(vendorSentence(unwrapped(" Gmail rejected the query.\n"))).toBe( + "Gmail rejected the query.", + ); + }); + + test("a body with no sentence in it yields nothing rather than the body", () => { + // The shape `dumped` is named for: nothing at `error.message`, so there is no sentence to find + // and the function must say so rather than reaching for whatever else the body holds. + expect( + vendorSentence(dumped({ detail: "something went wrong" })), + ).toBeNull(); + expect(vendorSentence(dumped({}))).toBeNull(); + }); }); describe("listing an app's actions", () => { @@ -814,6 +897,163 @@ describe("listing an app's actions", () => { expect(message).toContain("gmail"); }); + test("a listing refusal this deployment authored beats whatever the vendor said", async () => { + /* + * THE RULE THIS PATH NEVER ASKED ABOUT. `routes.ts` reads `brokerSentence` first and + * `vendorSentence` second, and `callTool`'s own catch was corrected to the same order; this one + * consulted `brokerSentence` nowhere at all. `./composio-adapter`'s `askVendor` wraps the raw + * tool listing exactly as it wraps the execute, so a `BrokerRefusalError` arrives here as + * readily as it arrives there — and an authored sentence is the only one that names the step + * that clears the condition. + * + * WHAT IT LOST TO IS A READ ONE LEVEL SHALLOW, the same way it did on the call path. + * `vendorRefusal` authors only where `vendorSentence` of the ORIGINAL was null, so asking the + * WRAPPER the same question lands somewhere the adapter never judged and comes back with + * whatever sits there — below, two words naming nothing, in place of a remedy. + */ + const authored = + 'Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so gmail\'s action list was not refreshed and the tools already held are untouched. A dated version is recorded when an app\'s actions are listed, so refreshing gmail\'s tools on its Plugins page replaces "latest" with a version Composio will accept.'; + + useComposioClient( + recording({ + listActions: async () => { + throw new BrokerRefusalError(authored, { + cause: { error: { error: { message: "Invalid request" } } }, + }); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + // Pinned whole rather than by a fragment, for the reason its sibling on the call path is: a + // substring check passes on a sentence joined to the vendor's or cut short of the remedy. + expect(message).toBe(authored); + expect(message).not.toContain("Invalid request"); + }); + + test("a listing that failed with no wrapper still carries the vendor's sentence", async () => { + /* + * `./composio-adapter`'s listing calls `getRawComposioTools`, which awaits + * `this.client.tools.list` with no try around it, so what lands here is `@composio/client`'s + * own error — see {@link unwrapped}. `refreshTools` writes this string into the row's + * `lastError` for an administrator to read off the Plugins page, and what it used to write was + * a status code followed by the entire response body. + */ + useComposioClient( + recording({ + listActions: async () => { + throw unwrapped("Composio holds no auth config for gmail."); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).toBe("Composio holds no auth config for gmail."); + // The error carries the whole HTTP response beside the sentence. None of it belongs on an + // admin page, in an audit row, or in a model's context. + expect(message).not.toContain("must-not-appear"); + expect(message).not.toContain("x-request-id"); + }); + + test("a listing failure whose body says nothing is not answered with the body", async () => { + /* + * WHAT ESCAPES WHEN THERE IS NO SENTENCE HAS TO BE WORTH SHOWING. `APIError` builds its message + * by JSON-stringifying the whole body behind the status code where the body has no top-level + * `message`, so the fallback to the thrown message handed an operator a response dump — the + * same thing the Zod-dump refusal above exists to stop, arriving through a different door. + */ + useComposioClient( + recording({ + listActions: async () => { + throw dumped({ + detail: [{ loc: ["body", "toolkit"], msg: "unrecognised" }], + request_id: "must-not-appear", + }); + }, + }).client, + ); + + const message = await listTools({ url: "composio://gmail" }).then( + () => "", + (error: unknown) => (error as Error).message, + ); + + expect(message).not.toContain("must-not-appear"); + expect(message).not.toContain("unrecognised"); + expect(message).not.toContain("{"); + expect(message).toContain("gmail"); + }); + + test("a destructive action is listed as destructive", async () => { + /* + * THE ONE CLASSIFICATION THAT MATTERS MOST, AND NOTHING STOOD OVER IT. `effectOf` is asserted + * on both answers directly, but every assertion that reached this mapping asserted + * `destructive: false` — so hardcoding `destructive: false` here left the whole suite green, + * and the field a Bot is gated on before it runs a dangerous action was pinned by nobody. + * + * `store.ts` records it on the `mcp_tools` row and a grant is what a person approves against + * it: an action recorded as safe that deletes a mailbox is approved once and run for ever. + */ + useComposioClient( + recording({ + listActions: async () => [ + GMAIL_READ, + { + slug: "GMAIL_DELETE_MESSAGE", + description: "Delete a message.", + tags: ["destructiveHint"], + version: "20260903_00", + }, + ], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + // Both rows, so the assertion fails on a mapping that hardcodes EITHER answer rather than + // reading the labels. + expect( + listed.map((tool) => `${tool.name}: ${tool.effect}/${tool.destructive}`), + ).toEqual([ + "GMAIL_FETCH_EMAILS: read/false", + "GMAIL_DELETE_MESSAGE: write/true", + ]); + }); + + test("an action Composio described in no words is listed with an empty description", async () => { + /* + * `description` is optional on the vendor's tool and `ListedTool`'s is not, so the mapping has + * to supply something. Nothing asserted which: `?? "no description available"`, `?? tool.name` + * and `?? null` all left the suite green, and the last of those is a row `store.ts` writes as + * NULL against a NOT NULL column. + * + * The empty string is the honest answer — the vendor said nothing, so this deployment says + * nothing rather than inventing a sentence a model will read as the action's own. + */ + useComposioClient( + recording({ + listActions: async () => [ + { slug: "GMAIL_ODD", tags: ["readOnlyHint"], version: "20260903_00" }, + ], + }).client, + ); + + const [tool] = await listTools({ url: "composio://gmail" }); + + expect(tool?.description).toBe(""); + // Asserted separately from `toEqual`, which treats a key holding `undefined` as a key that is + // not there and would accept the fallback being dropped altogether. + expect(Object.keys(tool ?? {})).toContain("description"); + }); + test("an answer that is not a list of actions throws a sentence, not a TypeError", async () => { /* * `ComposioActions` is OUR projection of the vendor, implemented by an adapter nobody has @@ -1439,6 +1679,98 @@ describe("calling one action", () => { } }); + test("a thrown client error with no wrapper is reported with its sentence", async () => { + /* + * The same correction as the listing's, on the path where the string reaches a model rather + * than an admin page. See {@link unwrapped} for which calls throw this shape; what used to be + * handed on for them was a status code followed by the whole response body, which is both the + * request id this file already refuses to pass on and somebody's context window spent on it. + */ + useComposioClient( + recording({ + execute: async () => { + throw unwrapped("Gmail rejected the query: invalid search syntax."); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toBe( + "Gmail rejected the query: invalid search syntax.", + ); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("x-request-id"); + }); + + test("a failure whose body says nothing is not answered with the body", async () => { + // The dump reaching a model, rather than an operator. `unexplained` is the right answer here + // for the reason it is the right answer to the placeholder: neither says anything the reader + // can act on, and one of them costs a context window to say it. + useComposioClient( + recording({ + execute: async () => { + throw dumped({ + detail: [{ loc: ["body", "arguments"], msg: "unrecognised" }], + request_id: "must-not-appear", + }); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + expect(result.text).toMatch(/Plugins page/); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + }); + + test("a result the exact size of the cap is not cut, and one character more is", async () => { + /* + * THE BOUNDARY, WHICH IS THE ONLY PLACE A CAP CAN BE WRONG. Every other test here measures a + * string far over the limit or far under it, so `<=` and `<` answered both of them the same + * way — and the off-by-one is the version that reports `truncated: true` beside text nothing + * was taken from, which is a lie in the field whose whole job is telling a model that what it + * is reading stops early. + */ + for (const [length, cut] of [ + [RESULT_CAP, false], + [RESULT_CAP + 1, true], + ] as const) { + useComposioClient( + recording({ + execute: async () => { + throw new Error("x".repeat(length)); + }, + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(`${length}: ${result.truncated}`).toBe(`${length}: ${cut}`); + expect(`${length}: ${result.text.length}`).toBe( + `${length}: ${cut ? CAPPED_LENGTH : RESULT_CAP}`, + ); + expect(result.text.endsWith(TRUNCATION_MARKER)).toBe(cut); + } + }); + test("a result is capped visibly rather than silently", async () => { useComposioClient( recording({ From 9a5dcd07cc06852df443086629fecf06bbc976f5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 11 Sep 2026 21:40:22 +0200 Subject: [PATCH 150/215] Withdraw only what this deployment granted, and prove the delete that revokes is the one wired up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routing in `createComposioClient` that sends both deletes to the raw client rather than to the SDK's own wrappers is the reason a revocation revokes: the wrappers hard-code their request body and cannot carry `revoke_on_delete`. Nothing in the ordinary suite reached that function — the four tests that assert the flag drive a vendor double, and the only test that built a real client is the live one, skipped in every run without a key. `delete: (id) => composio.connectedAccounts.delete(id)` type-checks clean and left 114/114 green while restoring the defect whole. It is now observable offline: both clients construct without a socket once tracking and the npm version check are off, their methods live on prototypes, and a recorder on each side of each delete makes "which function was reached" a list to assert. Four more, all on the withdrawal path: - `revoke` listed a person's accounts by app, person and "all account types" and by nothing else, so it deleted — with `revoke_on_delete`, which tears the grant up at the provider — accounts attached to authorization configs an operator built by hand in Composio's dashboard, shared ones included. `deleteAuthConfig` already refuses to delete such a config on exactly the reasoning that an operator's dashboard work is not ours to destroy; the accounts hanging off it are the same work. The listing is now scoped to `authConfigIds` of the configs `madeHere` claims, and an app with none of ours is answered without asking, so no empty filter goes on the wire. - A delete answered with no document at all resolves to `null` for a 204 and `undefined` for a JSON reply of content-length zero (`@composio/client` 0.1.0-alpha.76, `src/internal/parse.ts:16-42`), and neither can be a rejection: every `!response.ok` throws before parsing. The verdict check added for `success: false` read both as "no verdict" and turned a withdrawal that happened into a partial-withdrawal refusal. A body that arrived without its verdict still refuses. - The paging loop guarded against a repeated cursor and not against a repeated row, so a duplicated account id sent a second delete for an account already gone, read the vendor's "no such account" as a refusal, and threw over a withdrawal that had completed — identically on every retry. Ids are deduplicated, and the count in the refusal is of accounts rather than of rows. - `isConnected` answers a boolean and read every page to do it, so a cursor fault or the page ceiling threw for somebody whose first page already proved them connected — and `store.ts` deletes their connection row on anything but a `true`. `everyRowOf` now takes an `enough` predicate; the `false` still reads every page, because with no row in hand the question is genuinely unsettled. Call sites audited for the scoping change: `revoke` and `isConnected` in this file (the two callers of `accountsFor`), `configsFor`/`configsMadeHere` and its callers `ensureAuthConfig`, `authorize` and `deleteAuthConfig`, `withdrawableAccounts` and `withdrawalDeclined` and their one caller each, `everyRowOf`'s two call sites, `createComposioClient`'s eight wired members, and in `store.ts` `confirmBrokeredConnection` (the only reader of `isConnected`) and the three readers of `revoke`'s boolean, all of which write it to the trail as `vendorRevocationRequested`: `removeServer`, `disconnectBrokered` and `retireConnectionsFor`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 186 ++++++++- server/tests/composio-adapter.test.ts | 502 ++++++++++++++++++++++++- 2 files changed, 673 insertions(+), 15 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index d1c514797..8a358b9bb 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -407,12 +407,27 @@ type Listing = { * sentence the old branch could never have produced and a check that covers shapes nobody here * enumerated. What is left below are the three faults the vendor CAN hand over, all of them about * the cursor, because the cursor is the one field these transformers copy off the wire unchecked. + * + * AND A QUESTION ALREADY ANSWERED STOPS HERE, WHICH IS WHAT `enough` IS FOR. Paging made three of + * this file's answers complete and made one of them FAILABLE: {@link ComposioBroker.isConnected} + * returns a boolean, and the first page carrying a single row has settled it — no cursor Composio + * could send next, and no fiftieth page, can turn that `true` into anything else. Reading on + * anyway put the two cursor refusals and the page ceiling in front of a person whose account had + * already been found, so a fault on a page nobody needed decided the answer to a question nobody + * still had. The default reads every page, because that is what a withdrawal and a config listing + * genuinely need; a caller that says when it has enough is a caller that cannot be failed after it + * has its answer. + * + * CHECKED WHERE THE ROWS LAND AND BEFORE THE CURSOR IS LOOKED AT, deliberately. Reading the cursor + * first and stopping afterwards would keep every one of the three refusals reachable on the page + * that already answered the question, which is the whole of what this closes. */ async function everyRowOf( listing: Listing, page: ( cursor: string | undefined, ) => Promise<{ items: Row[]; nextCursor?: unknown }>, + enough: (rows: Row[]) => boolean = () => false, ): Promise { const rows: Row[] = []; const followed = new Set(); @@ -421,6 +436,7 @@ async function everyRowOf( for (;;) { const answered = await page(cursor); rows.push(...answered.items); + if (enough(rows)) return rows; /* * ABSENT AND NULL BOTH MEAN THE END, and they are the two the vendor actually sends: the @@ -640,12 +656,26 @@ function configOf( * every row (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:60`), so a * non-object row raises a `TypeError` inside the vendor's own code and never reaches this function * — see the `TypeError` row in {@link vendorRefusal}, which is where that answer is now given. + * + * ONE ID IS ONE WITHDRAWAL, HOWEVER MANY TIMES THE LISTING NAMED IT. The paging loop above guards + * against a vendor repeating a CURSOR and not against it repeating a ROW, and those are different + * faults: a page boundary crossed while an account is created or deleted, or a proxy stitching two + * overlapping pages together, hands the same account id over twice with a cursor that advanced + * normally every time. The second delete of one account then meets Composio's "there is no such + * account", which arrives here as a refusal — so {@link ComposioBroker.revoke} counted a + * withdrawal that had in fact completed as a partial one, threw over it, and left the person's + * connection row standing to be pressed again. Every retry meets the same duplicate. Deduplicating + * is not tidying the listing: it is the difference between one account and two. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, so the order the deletes go out in is still the listing's, + * which is the order {@link buildComposioClient}'s sort makes stable. */ function withdrawableAccounts( rows: { id?: unknown }[], toolkit: string, ): { ids: string[]; nameless: BrokerRefusalError[] } { const ids: string[] = []; + const alreadyNamed = new Set(); const nameless: BrokerRefusalError[] = []; rows.forEach((row, position) => { @@ -658,6 +688,8 @@ function withdrawableAccounts( ); return; } + if (alreadyNamed.has(id)) return; + alreadyNamed.add(id); ids.push(id); }); @@ -693,15 +725,30 @@ function withdrawableAccounts( * for a condition no button changes. * * NULLABLE IN THE PARAMETER THOUGH THE DECLARATION SAYS OTHERWISE. The generated client parses the - * body and returns it; a `null` body is a `null` here, and reading a field off it would be a - * `TypeError` carrying a sentence that reads like a stack trace — which is the one thing every - * refusal in this file exists not to be. It falls into the unreadable branch, where it belongs. + * body and returns it, and there are two answers for which it hands over no body at all. + * + * AND NO BODY AT ALL IS NOT A REFUSAL, WHICH IS THE CORRECTION AND THE OPPOSITE MISTAKE TO THE ONE + * ABOVE. `defaultParseResponse` resolves a 204 to `null` — "fetch refuses to read the body when the + * status code is 204" — and a JSON reply carrying `content-length: 0` to `undefined` + * (`@composio/client` 0.1.0-alpha.76, `src/internal/parse.ts:16-42`). Neither of those ever reaches + * a non-2xx: the client throws `APIError` for every `!response.ok` before parsing + * (`src/client.ts:539`), so an answer arriving here at all is Composio having accepted the request. + * A 204 is therefore the vendor saying it did the delete and has nothing to add, and the guard + * added for `success: false` read it as the one shape it could not tell apart from a failure — + * turning a withdrawal that HAPPENED into a partial-withdrawal refusal, over a grant that was in + * fact ended. That is the same lie as the one this function exists to stop, pointing the other way. + * + * WHICH IS NOT THE SAME AS A BODY THAT ARRIVED WITHOUT THE FIELD. `{}` is Composio answering with a + * document whose verdict is missing, and a document this deployment cannot read is a fact about the + * package rather than an outcome — so it stays in the unreadable branch below. What is exempted + * here is the narrower thing the client documents: no document. */ function withdrawalDeclined( answer: { success?: unknown } | null | undefined, toolkit: string, ): BrokerRefusalError | null { - const verdict = answer?.success; + if (answer === null || answer === undefined) return null; + const verdict = answer.success; if (verdict === true) return null; if (verdict === false) { return new BrokerRefusalError( @@ -1232,6 +1279,25 @@ export type ComposioVendor = { * can act through the app, and the revoke is about ending every account they can act through. */ accountType: "ALL"; + /** + * Which authorization configs the question is about, ABSENT where it is about all of them. + * + * THE TWO QUESTIONS DIFFER HERE TOO, AND ONLY ONE OF THEM MAY ACT. `authConfigIds` is a + * parameter of the listing (`ConnectedAccountListParamsSchema.authConfigIds`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:260-264`) forwarded as + * `auth_config_ids` (`src/models/ConnectedAccounts.ts:117`), and omitting it asks about every + * config in the project — this deployment's and an operator's hand-made ones alike. That is + * the right question for {@link ComposioBroker.isConnected}, which only reads whether a person + * can act through the app, and the wrong one for {@link ComposioBroker.revoke}, which deletes + * what it finds: see there for why an account on somebody else's config is not this + * deployment's to end. + * + * NEVER THE EMPTY ARRAY. A caller with no configs of its own has nothing to scope TO, and an + * empty filter is the shape most likely to be read as no filter at all by whatever is on the + * far side — which would be the unscoped listing arriving through the parameter added to + * prevent it. `revoke` answers before it asks in that case. + */ + authConfigIds?: string[]; limit: number; /** * Where the last page left off, ABSENT on the first request rather than undefined. @@ -1337,11 +1403,18 @@ export type ComposioVendor = { * — which says when it CAN appear, not that it always does — so a guard on it would turn every * withdrawal Composio accepted into a permanent failure the first time they stopped sending it, * which is the shape of mistake this file has already made twice in the other direction. + * + * NULLABLE AND OPTIONAL THOUGH THE VENDOR'S OWN DECLARATION IS NEITHER, because the generated + * client has two answers it resolves with no document: a 204 becomes `null` and a JSON reply + * carrying `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, + * `src/internal/parse.ts:16-42`). Declaring this at the vendor's `ConnectedAccountDeleteResponse` + * would be the same assertion-over-a-wire-value every other field here refuses to make, and it + * would hide the one case {@link withdrawalDeclined} has to tell from a refusal. */ delete( id: string, params: { revoke_on_delete: true }, - ): Promise<{ success?: unknown }>; + ): Promise<{ success?: unknown } | null | undefined>; }; }; @@ -1507,17 +1580,31 @@ export function buildComposioClient( * would come from — the breadth of `accountType`, the limit, and the fact that both ask about one * person and one app — so the difference between them is exactly the list of statuses and is * visible at both call sites. + * + * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the + * statuses are. `configs` is which authorization configs the answer is about — every one of them + * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on + * what it finds. `enough` is when the rows in hand already settle the question, which is true of + * a boolean the moment one row arrives and never true of a set of accounts to end. Both are + * written at the call sites below, beside the statuses, so that the whole of the difference + * between the two questions is one argument list a reader can compare. */ const accountsFor = async ( userId: string, toolkit: string, statuses: VendorAccountStatus[], + asked: { + configs?: string[]; + enough?: (rows: { id?: unknown }[]) => boolean; + } = {}, ): Promise<{ id?: unknown }[]> => { /* - * EVERY PAGE, READ HERE RATHER THAN AT EITHER CALLER, so that the two questions cannot drift on - * the one thing they do share. A truncated listing is the wrong answer to both of them for the - * same reason: `false` claims somebody has no account for an app when nobody looked at all of - * them, and a withdrawal that saw one page ends fewer grants than it reports. + * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that + * the two questions cannot drift on the one thing they do share. A truncated listing is the + * wrong answer to both of them for the same reason: `false` claims somebody has no account for + * an app when nobody looked at all of them, and a withdrawal that saw one page ends fewer + * grants than it reports. What a caller may say is that it has ENOUGH — see {@link everyRowOf} + * — which is not truncation: it is a question that has been answered. * * THE ROWS GO BACK AS ROWS, WHICH IS NARROWER THAN WHAT THIS USED TO HAND OVER. It read every * id here, and the two callers do not want the same thing: `isConnected` is a COUNT — the id is @@ -1544,10 +1631,16 @@ export function buildComposioClient( toolkitSlugs: [toolkit], statuses, accountType: "ALL", + // Spread for the reason the cursor is: an explicit `undefined` reaches the vendor's + // `parse` as a key, and "about every config" is said by not naming any. + ...(asked.configs === undefined + ? {} + : { authConfigIds: asked.configs }), limit: LISTING_LIMIT, ...(cursor === undefined ? {} : { cursor }), }), ), + asked.enough, ); return rows; }; @@ -2332,8 +2425,30 @@ export function buildComposioClient( * without an id is still an ACTIVE account: this person can act through the app, which is the * whole of what this gate asks. Reading the id here used to turn that into a refusal, so a * field this question never looks at decided its answer. + * + * AND IT STOPS AT THE FIRST ROW, WHICH IS THE SAME CORRECTION ONE LEVEL OUT. This method + * answers a boolean, and a boolean settled by page one cannot be improved by page two — but + * reading on left the answer exposed to three faults that belong to pages nobody needed: a + * cursor Composio sent as a number, a cursor it repeated, and the fiftieth page of a listing + * that will not end. Each of those threw at a person whose ACTIVE account had already been + * found and told them the vendor's shape was wrong, and `store.ts` deletes their connection + * row on a `false` — so a question that had been answered `true` was made failable by the + * machinery that made the `false` complete. Paging is still what makes the `false` honest: + * with no row yet, the next page is the only thing that can settle it, so it is read. + * + * ASKED OF EVERY CONFIG, WHICH IS WHERE THIS DIVERGES FROM `revoke` BELOW. A person whose + * only account for the app sits on a config an operator built by hand can still act through + * the app — the call that runs an action names the person and the toolkit, not the account — + * so scoping this to configs of ours would refuse somebody who is, in fact, connected. This + * method only READS; the one that acts is the one that has to be narrow. */ - return (await accountsFor(userId, toolkit, CONNECTED)).length > 0; + return ( + ( + await accountsFor(userId, toolkit, CONNECTED, { + enough: (rows) => rows.length > 0, + }) + ).length > 0 + ); }, async revoke({ userId, toolkit }): Promise { @@ -2355,8 +2470,39 @@ export function buildComposioClient( * fresh one, or a shared account beside their own — and each of them is access this * deployment's calls could run under. See {@link REVOCABLE} for why the listing here is wider * than the one behind `isConnected`. + * + * AND EVERY ACCOUNT MEANS EVERY ACCOUNT ON A CONFIG THIS DEPLOYMENT MADE, WHICH IS NARROWER + * THAN WHAT THIS USED TO DELETE. The listing was asked by person, app and "all account + * types" and by nothing else, so it returned accounts attached to authorization configs an + * operator built by hand in Composio's dashboard — for purposes this deployment knows nothing + * about, on scopes it cannot see, and, with `accountType: "ALL"`, including the SHARED ones + * that other people are acting through. Every one of those was then deleted with + * `revoke_on_delete`, which tears the grant up at Google or Slack. One person pressing + * disconnect on their own settings page ended somebody else's integration. + * + * WHICH IS THE PRINCIPLE {@link ComposioBroker.deleteAuthConfig} ALREADY STATES, ARRIVING + * ONE LEVEL DOWN. That method refuses to delete a config it cannot show is this + * deployment's, on the reasoning that an operator's dashboard work is not ours to destroy — + * and the accounts hanging off that config are the same work. {@link madeHere} is the only + * thing that tells the two apart, so it decides both. + * + * NOTHING OF OURS IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app with no + * config of this deployment's never had a connection begun through it — {@link + * ComposioBroker.authorize} mints every link against {@link configsMadeHere} and refuses + * where there is none — so there is nothing here that this deployment granted. Answering + * before the listing is also what keeps the empty filter off the wire; see `authConfigIds` + * on {@link ComposioVendor} for why an empty one must never be sent. + * + * AND `false` STILL MEANS WHAT IT SAID. The audit trail's `vendorRevocationRequested` records + * whether THIS DEPLOYMENT asked the vendor to end something, and an account on a config it + * did not make is not something it ever granted. What survives a disconnect here is an + * attachment made outside this deployment, which only the dashboard that made it can end. */ - const accounts = await accountsFor(userId, toolkit, REVOCABLE); + const ours = await configsMadeHere(toolkit); + if (ours.length === 0) return false; + const accounts = await accountsFor(userId, toolkit, REVOCABLE, { + configs: ours.map((config) => config.id), + }); /* * THE READABLE ONES GO FIRST AND THE UNREADABLE ONES ARE REPORTED AFTERWARDS. Reading the ids * of all of them before sending any delete is what made one unnameable row a permanent block @@ -2412,18 +2558,32 @@ export function buildComposioClient( * one a second press reaches, which is what "disconnecting again" is worth saying about. An * account it described with no id is not: the row will be as unnameable next time, so the * only honest instruction is the one that does not run through this page at all. + * + * AND THE DENOMINATOR IS THE ACCOUNTS, NOT THE ROWS. It was `accounts.length`, which is how + * many rows the listing handed over, and a listing that named one account twice is a + * listing with more rows than accounts — see {@link withdrawableAccounts}. "Withdrew 1 of + * this person's 2 accounts" over one account that is gone is a count nothing measured, in + * the sentence a reader is meant to act on. The two halves this call actually holds are the + * accounts it could name and the ones it could not, and their sum is the set. */ + const held = ids.length + nameless.length; const left = nameless.length === 0 ? "Disconnecting again asks only for the accounts that are left." : `Composio described ${nameless.length} of them with no id at all, so this deployment has no way to name those in a withdrawal and disconnecting again meets them unchanged: removing them in Composio's own dashboard is what ends them.`; throw new BrokerRefusalError( - `Composio withdrew ${ids.length - refused.length} of this person's ${accounts.length} accounts for ${toolkit} and did not withdraw the rest, so their access to it has not ended. ${left}`, + `Composio withdrew ${ids.length - refused.length} of this person's ${held} accounts for ${toolkit} and did not withdraw the rest, so their access to it has not ended. ${left}`, { cause: everyRefusal([...refused, ...nameless]) }, ); } - return accounts.length > 0; + /* + * THE ACCOUNTS WITHDRAWN, WHICH IS THE SAME NUMBER BY A HONESTER ROUTE. Reaching this line + * means nothing refused and nothing was nameless, so the rows and the accounts differ only + * where the listing repeated one — and `true` is a claim about having asked the vendor to + * withdraw something, which is exactly what `ids` counts. + */ + return ids.length > 0; }, }; diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 7c4de0bbf..a6f5eb9bc 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,3 +1,4 @@ +import { Composio } from "@composio/core"; import { describe, expect, test } from "bun:test"; import { BrokerRefusalError, @@ -9,7 +10,10 @@ import { LISTING_LIMIT, vendorSentence, } from "../src/plugins/composio"; -import { buildComposioClient } from "../src/plugins/composio-adapter"; +import { + buildComposioClient, + createComposioClient, +} from "../src/plugins/composio-adapter"; /** * The three facts about the adapter that a type checker cannot settle, asserted with no network. @@ -156,6 +160,36 @@ const OURS_SPARE = { status: "ENABLED", }; +/** + * The gmail config this deployment made, which is what a withdrawal is now allowed to reach. + * + * `revoke` READS THE CONFIGS BEFORE IT READS THE ACCOUNTS, which is why every fixture below that + * withdraws anything answers this listing. The account listing is scoped to the ids that come back + * from it, so a fixture that did not answer it would be describing a deployment with no config of + * its own — for which there is, correctly, nothing to withdraw — and every assertion about what the + * delete was asked would be an assertion about a call that never went out. + * + * ITS OWN CONSTANT RATHER THAN {@link OURS}, because the suffix is the whole of what the adapter + * reads and the rest of the name is the app an administrator typed. A gmail withdrawal answered + * with a config called "Linear (OpenBot)" would pass, and would leave the one fixture in this file + * that names the app it is about naming the wrong one. + */ +const OUR_GMAIL = { + id: "ac_gmail_ours", + name: "Gmail (OpenBot)", + status: "ENABLED", +}; + +/** An operator's own gmail config, carrying no suffix, which is the whole of what tells them apart. */ +const BY_HAND_GMAIL = { + id: "ac_gmail_by_hand", + name: "Gmail", + status: "ENABLED", +}; + +/** That listing as a vendor double, since every withdrawing fixture below needs the same one. */ +const ourGmailConfig = async () => ({ items: [OUR_GMAIL] }); + /** * The three sentences `authorize` can refuse with, told apart by the remedy each one prescribes. * @@ -1120,6 +1154,7 @@ describe("withdrawing one person's grants", () => { const deleted: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }] }), delete: async (...call: unknown[]) => { @@ -1139,6 +1174,7 @@ describe("withdrawing one person's grants", () => { test("a delete Composio answered `success: false` is not a withdrawal", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }] }), // A 200 whose body says the account was not deleted. The flag went out, Composio read @@ -1172,6 +1208,7 @@ describe("withdrawing one person's grants", () => { test("a delete whose verdict Composio did not send is not counted as one either", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }] }), /* @@ -1207,6 +1244,7 @@ describe("withdrawing one person's grants", () => { const asked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async (query: unknown) => { asked.push(query); @@ -1226,6 +1264,13 @@ describe("withdrawing one person's grants", () => { * about. The statuses because an unfinished consent or a lapsed token is still something a * provider is holding. `REVOKED` is the one left out: it is the only status that says the grant * is already gone, and deleting a tombstone would have this report a withdrawal that never was. + * + * AND `authConfigIds`, WHICH THIS ASSERTION USED TO SAY WAS ABSENT. It was written out as the + * whole query on the reasoning that the breadth is the point — and it is, for three of the four + * parameters. The fourth is the opposite: omitting it asks about every authorization config in + * the project, including ones an operator built by hand in Composio's dashboard, and this + * listing is the one that decides what gets deleted with `revoke_on_delete`. The assertion was + * therefore pinning the defect in place, which is why it moved rather than being relaxed. */ expect(asked).toEqual([ { @@ -1240,6 +1285,7 @@ describe("withdrawing one person's grants", () => { "INACTIVE", ], accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], limit: WHOLE_LISTING, }, ]); @@ -1298,6 +1344,7 @@ describe("withdrawing one person's grants", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], @@ -1322,6 +1369,7 @@ describe("withdrawing one person's grants", () => { test("a partial withdrawal is a failure rather than a reported disconnection", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), delete: async (id: string) => { @@ -1363,6 +1411,7 @@ describe("withdrawing one person's grants", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: null }, { id: "ca_3" }], @@ -1414,6 +1463,207 @@ describe("withdrawing one person's grants", () => { await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), ).toBe(true); }); + + /** + * AN OPERATOR'S OWN AUTHORIZATION CONFIG IS NOT THIS DEPLOYMENT'S TO EMPTY. + * + * The account listing was asked by person, by app and by "all account types" and by nothing else, + * so it returned every account Composio holds for that pair — including ones attached to a config + * an operator built by hand in their dashboard, for purposes this deployment knows nothing about, + * and including the SHARED ones other people are acting through. Each of those was then deleted + * with `revoke_on_delete`, which tears the grant up at the provider. One person pressing + * disconnect on their own settings page ended somebody else's integration, silently, and answered + * `true`. + * + * WHICH IS THE PRINCIPLE `deleteAuthConfig` ALREADY STATES ONE LEVEL UP: it refuses to delete a + * config it cannot show is this deployment's, on exactly the reasoning that an operator's + * dashboard work is not ours to destroy. The accounts hanging off that config are the same work. + * + * ASSERTED ON WHAT WENT OUT, because an implementation that asked the unscoped question and then + * deleted everything it found answers this test's `true` just as confidently. + */ + test("the withdrawal asks only about accounts on configs this deployment made", async () => { + const scoped: unknown[] = []; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + // An operator's beside ours, which is the state the whole guard is about. Out of the + // vendor's order, so a reader that took the first row would take the wrong one. + list: async () => ({ items: [BY_HAND_GMAIL, OUR_GMAIL] }), + }, + connectedAccounts: { + list: async (query: unknown) => { + scoped.push((query as { authConfigIds?: unknown }).authConfigIds); + return { items: [{ id: "ca_1" }] }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + + // The operator's config is not in the question, so no account on it can be in the answer and + // none of them can reach the delete. + expect(scoped).toEqual([[OUR_GMAIL.id]]); + expect(deleted).toEqual(["ca_1"]); + }); + + /** + * NOTHING OF OURS IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. + * + * `authorize` mints every connect link against a config this deployment made and refuses where + * there is none, so an app with none of ours never had a connection begun through it. Listing + * accounts anyway could only turn up somebody else's, and the one thing this call does with an + * account it turns up is delete it. + * + * THE ASSERTION IS THE DOUBLE. `connectedAccounts.list` is left at {@link fakeVendor}'s refusal, + * so an implementation that asked the question at all fails here by name — which is a stronger + * statement than the `false` beside it, because a listing scoped to an EMPTY set of configs would + * answer `false` too while putting a filter on the wire that the far side is free to read as no + * filter at all. + */ + test("a person's accounts are not listed where this deployment holds no config of its own", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [BY_HAND_GMAIL] }) }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + false, + ); + }); + + /** + * A WITHDRAWAL THAT HAPPENED, REPORTED AS A FAILURE — the guard for `success: false` overreaching. + * + * The installed client resolves two answers with no document at all: a 204 becomes `null` + * ("fetch refuses to read the body when the status code is 204") and a JSON reply carrying + * `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, + * `src/internal/parse.ts:16-42`). Neither can be a rejection the vendor made: every `!response.ok` + * is thrown as an `APIError` before parsing (`src/client.ts:539`), so an answer arriving at all is + * Composio having accepted the request and deleted the account. + * + * READING THAT AS "NO VERDICT" TURNED A COMPLETED WITHDRAWAL INTO A PARTIAL-WITHDRAWAL REFUSAL, + * which leaves the person's connection row standing, tells them their access has not ended, and + * has them press disconnect again — against an account that is already gone, which is a second + * fault waiting on the first. It is the same lie as the unread `success: false` before it, facing + * the other way. + * + * THE OTHER DIRECTION IS ASSERTED NEXT DOOR AND IS NOT WEAKENED BY THIS: `{}` is a document that + * arrived without its verdict, which is a fact about the package, and it still refuses. + */ + for (const { shape, answer } of [ + { shape: "a 204 carrying no content", answer: null }, + { shape: "a JSON reply of content-length zero", answer: undefined }, + ]) { + test(`a withdrawal Composio answered with ${shape} is a withdrawal`, async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (id: string) => { + deleted.push(id); + return answer; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + // And the flag still went out, so the `true` is about a delete that asked for the grant to be + // revoked rather than about one that quietly filed the account away. + expect(deleted).toEqual(["ca_1"]); + }); + } + + /** + * A ROW THE LISTING NAMED TWICE IS ONE ACCOUNT, NOT TWO. + * + * The paging loop guards against the vendor repeating a CURSOR and not against it repeating a + * ROW, and those are different faults: a page boundary crossed while accounts are being created + * or deleted, or a proxy stitching two overlapping pages together, hands the same id over twice + * with a cursor that advanced perfectly each time. The second delete then meets Composio's "there + * is no such account" — which arrives as a refusal — so a withdrawal that in fact COMPLETED was + * thrown over as a partial one, the person's connection row was left standing, and every retry + * met the same duplicate and failed in the same place. + * + * THE DOUBLE REFUSES THE SECOND DELETE OF ONE ACCOUNT, which is what the vendor does and what + * makes this test able to fail. A stub that answered both identically would be green against an + * adapter that sent the delete twice. + */ + test("an account the listing named twice is withdrawn once", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } + : { items: [{ id: "ca_1" }], nextCursor: null }, + delete: async (id: string) => { + if (deleted.includes(id)) { + throw new Error( + `Composio holds no connected account with the id ${id}.`, + ); + } + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual(["ca_1"]); + }); + + /** + * AND THE COUNT IN A REAL PARTIAL FAILURE IS OF ACCOUNTS, NOT OF ROWS. + * + * The denominator was `accounts.length`, which is how many rows the listing handed over — so a + * listing that named one account twice made the sentence a reader is meant to act on state a + * figure nothing had counted. "One of three" over two accounts is the same class of mistake as + * the page-ceiling sentence that asserted a row count it had not measured. + */ + test("a duplicated row is not a third account in the sentence a partial failure carries", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => ({ + items: [{ id: "ca_1" }, { id: "ca_1" }, { id: "ca_2" }], + }), + delete: async (id: string) => { + if (id === "ca_2") throw new Error("Composio refused this one."); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).toMatch(/withdrew 1 of this person's 2 accounts/); + expect(failure.message).not.toMatch(A_CRASH); + }); }); /** @@ -1513,6 +1763,7 @@ describe("a catalogue that might be a fragment", () => { test("each caller gets its own rows, so one of them cannot edit the cache", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, toolkits: { get: async () => [ { @@ -1766,6 +2017,9 @@ describe("what a vendor failure becomes on its way out of the seam", () => { { method: "revoke", vendor: (raise) => ({ + // The configs first, because the withdrawal reads them to find out which accounts are this + // deployment's to end before it asks for any account at all. + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }] }), delete: raise, @@ -2132,6 +2386,7 @@ describe("what the delete loop keeps of the failures it meets", () => { test("every account Composio refused is carried, not only the first", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], @@ -2162,6 +2417,7 @@ describe("what the delete loop keeps of the failures it meets", () => { test("a single refusal is still carried as itself rather than wrapped", async () => { const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), delete: async (id: string) => { @@ -2200,6 +2456,7 @@ describe("what the delete loop keeps of the failures it meets", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], @@ -2399,6 +2656,12 @@ describe("a vendor listing that is not the shape it is declared to be", () => { const sent: string[] = []; return { parts: { + authConfigs: { + list: async () => { + sent.push("authConfigs.list"); + return { items: [OUR_GMAIL] }; + }, + }, connectedAccounts: { list: async () => { sent.push("connectedAccounts.list"); @@ -2416,7 +2679,11 @@ describe("a vendor listing that is not the shape it is declared to be", () => { // delete must not be reached at all — a withdrawal of `undefined` is the request this refusal // exists to stop being made. Where SOME rows are readable the answer is different and the // test for it sits beside `revoke`: those go, and the sentence counts what was left. - asked: ["connectedAccounts.list"], + // + // The config listing goes out first because the withdrawal is scoped to this deployment's own + // configs before it asks for an account at all; it is named here rather than left out so that + // the assertion stays a statement about the whole conversation with the vendor. + asked: ["authConfigs.list", "connectedAccounts.list"], ask: ({ broker }) => broker.revoke({ userId: "user_1", toolkit: "gmail" }), authored: true, @@ -2518,6 +2785,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async (query: unknown) => { asked.push(query); @@ -2549,6 +2817,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { toolkitSlugs: ["gmail"], statuses: REVOCABLE_STATUSES, accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], limit: WHOLE_LISTING, }, { @@ -2556,6 +2825,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { toolkitSlugs: ["gmail"], statuses: REVOCABLE_STATUSES, accountType: "ALL", + authConfigIds: [OUR_GMAIL.id], limit: WHOLE_LISTING, cursor: "page_2", }, @@ -2582,6 +2852,96 @@ describe("a listing that arrived with a cursor still outstanding", () => { ).toBe(true); }); + /** + * A YES/NO QUESTION THAT PAGING MADE FAILABLE, WHICH IS THE OTHER HALF OF THE TEST ABOVE. + * + * Reading every page is what makes `isConnected`'s `false` honest, and it is also what put the + * two cursor refusals and the page ceiling in front of a person whose ACTIVE account had already + * been found. `true` is settled the moment one row arrives — no cursor Composio sends next and no + * fiftieth page can turn it into anything else — so a fault on a page nobody needed was deciding + * the answer to a question nobody still had. And the consequence is not a wasted request: + * `store.ts` DELETES this person's `composio_connections` row on anything other than a `true`, + * and the route turns the refusal into "Composio's answer could not be read" over an account that + * is right there on page one. + * + * THE THREE FAULTS ARE ASSERTED SEPARATELY, because they are three different branches of + * `everyRowOf` and an early stop that closed one of them would be green on a test that only asked + * about another. + */ + for (const { fault, pages } of [ + { + fault: "a cursor that is not a cursor", + pages: () => async () => ({ items: [{ id: "ca_1" }], nextCursor: 7 }), + }, + { + fault: "a cursor that never advances", + pages: () => async () => ({ + items: [{ id: "ca_1" }], + nextCursor: "page_2", + }), + }, + { + fault: "a cursor that advances for ever", + pages: () => { + let page = 0; + return async () => ({ + items: [{ id: `ca_${++page}` }], + nextCursor: `page_${page + 1}`, + }); + }, + }, + ]) { + test(`${fault} cannot unanswer a connection the first page proved`, async () => { + let asked = 0; + const page = pages(); + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => { + asked += 1; + return page(); + }, + }, + }), + () => 1_000_000, + ); + + expect( + await broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ).toBe(true); + // One request, because the first answer settled it. Counted rather than left implicit: an + // implementation that read on and happened not to throw would answer `true` as well, and the + // whole point is that the later pages are never reached. + expect(asked).toBe(1); + }); + } + + /** + * AND THE `false` IS STILL NOT ALLOWED TO BE A GUESS, which is what stops the fix above from + * collapsing into "read one page and answer". + * + * With no row in hand the question is genuinely unsettled, so a cursor this deployment cannot + * follow means it does not know — and saying `false` there would delete the person's row and tell + * a gate they may not act through an app they hold. The refusal is the honest answer, and it is + * the one an early stop is most likely to take away by accident. + */ + test("a cursor fault before any account has been seen is still a refusal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + list: async () => ({ items: [], nextCursor: 7 }), + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.isConnected({ userId: "user_1", toolkit: "gmail" }), + ); + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).not.toMatch(A_CRASH); + }); + test("every page of this app's configs is read, so none is left standing", async () => { const asked: unknown[] = []; const deleted: string[] = []; @@ -2655,6 +3015,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => { calls += 1; @@ -2695,6 +3056,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => { calls += 1; @@ -2729,6 +3091,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { const deleted: string[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => { calls += 1; @@ -3371,6 +3734,7 @@ describe("a field Composio padded with whitespace", () => { const deleted: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ + authConfigs: { list: ourGmailConfig }, connectedAccounts: { list: async () => ({ items: [{ id: " ca_1 " }] }), delete: async (...call: unknown[]) => { @@ -3443,3 +3807,137 @@ describe("a field Composio padded with whitespace", () => { expect(ran).toHaveLength(1); }); }); + +/** + * WHICH `delete` THE VENDOR OBJECT ACTUALLY CARRIES, asserted without a network and without a key. + * + * THIS IS THE ONE DECISION IN {@link createComposioClient} AND NOTHING REACHED IT. That function is + * described in its own comment as too thin to have a bug in, and it is — except for two lines. + * `Composio`'s own `authConfigs.delete` and `connectedAccounts.delete` hard-code the request body + * they send (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311`, + * `src/models/ConnectedAccounts.ts:532-540`), so through them `revoke_on_delete` cannot be passed at + * all and every delete soft-deletes while the grant at Google or Slack stands. Both are therefore + * satisfied from `composio.getClient()` instead, and that routing is the whole reason this + * deployment's withdrawals withdraw anything. + * + * EVERY OTHER TEST IN THIS FILE DRIVES {@link buildComposioClient}, which takes the vendor object + * already assembled — so the four that assert `revoke_on_delete` went out assert it about a double, + * and none of them can see which function `createComposioClient` put behind it. Swapping those two + * lines back to `composio.authConfigs.delete` and `composio.connectedAccounts.delete` type-checks + * and leaves this suite green while restoring the exact defect the flag was added for. The only + * thing that ever exercised the real wiring was `composio-live.test.ts`, which is skipped in every + * run that has no key — which is every ordinary run. + * + * WHAT MAKES IT OBSERVABLE OFFLINE IS THAT BOTH CLIENTS ARE CONSTRUCTIBLE WITHOUT DIALLING + * ANYTHING. `new Composio({ apiKey })` opens no socket once tracking and the npm version check are + * off, `getClient()` hands back the underlying `@composio/client` it already holds, and the methods + * of both live on their classes' prototypes. So a prototype replaced BEFORE `createComposioClient` + * runs is what the client it builds will call: the SDK's telemetry wrapper copies each method off + * the prototype at construction (`src/telemetry/Telemetry.ts:95-116`), and the raw client's + * resources carry no own properties at all. Four recorders — one on each side of each delete — turn + * "which function was reached" into a list, which is a fact about the wiring rather than about a + * request that was never made. + * + * THE IMPORT OF `@composio/core` HERE DOES NOT BREAK THE ONE-IMPORT-SITE RULE. That rule is about + * `server/src`, so that a version bump has exactly one FILE of product code to be read against; + * this is a test, and the version it is written against is the whole of what it asserts. + */ +describe("the key becoming a vendor, and which delete that vendor carries", () => { + /** One class's methods, as the object a replacement is written onto. */ + type Methods = Record Promise>; + + const methodsOf = (instance: object): Methods => + Object.getPrototypeOf(instance) as Methods; + + test("both deletes are the raw client's, and neither is the SDK's own wrapper", async () => { + /* + * A SECOND CLIENT, BUILT ONLY TO REACH THE CLASSES. Nothing is called on it: it exists because + * the prototypes are not exported, and the way to a prototype is an instance. The key is a + * string nobody will ever send anywhere, which is the point of asserting this without one. + */ + const seed = new Composio({ + apiKey: "never-dialled", + allowTracking: false, + disableVersionCheck: true, + }); + const sdkAccounts = methodsOf(seed.connectedAccounts); + const sdkConfigs = methodsOf(seed.authConfigs); + const rawAccounts = methodsOf(seed.getClient().connectedAccounts); + const rawConfigs = methodsOf(seed.getClient().authConfigs); + + const restore: { on: Methods; name: string; was: Methods[string] }[] = []; + const replace = (on: Methods, name: string, answer: Methods[string]) => { + restore.push({ on, name, was: on[name] }); + on[name] = answer; + }; + + const reached: string[] = []; + const recorder = + (whose: string): Methods[string] => + async (...call: unknown[]) => { + reached.push(`${whose} ${JSON.stringify(call)}`); + return { success: true }; + }; + + try { + /* + * BOTH SIDES OF BOTH DELETES ARE RECORDED, which is what makes the list an assertion rather + * than a spy. A recorder on the raw client alone would still fire if the SDK's wrapper were + * used, because the wrapper calls straight through to it — so what tells the two wirings + * apart is whose recorder answered, and the only way to see that is to have one on each. + */ + replace( + rawAccounts, + "delete", + recorder("the raw client's connectedAccounts.delete"), + ); + replace( + sdkAccounts, + "delete", + recorder("the SDK's own connectedAccounts.delete"), + ); + replace( + rawConfigs, + "delete", + recorder("the raw client's authConfigs.delete"), + ); + replace( + sdkConfigs, + "delete", + recorder("the SDK's own authConfigs.delete"), + ); + + // The two listings that carry each delete to its argument. Answered from memory, so this test + // reaches the network exactly as often as it reaches the live Composio account: never. + replace(sdkAccounts, "list", async () => ({ + items: [{ id: "ca_1" }], + nextCursor: null, + })); + replace(sdkConfigs, "list", async () => ({ + items: [OUR_GMAIL], + nextCursor: null, + })); + + const { broker } = createComposioClient("never-dialled"); + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + await broker.deleteAuthConfig("gmail"); + } finally { + // Restored whatever happened above, because these are the SDK's own classes and every later + // test in this process would otherwise be running against a patched vendor. + for (const { on, name, was } of restore.reverse()) on[name] = was; + } + + /* + * THE WHOLE OF WHAT WAS REACHED, IN ORDER. An implementation that routed either delete through + * the SDK's wrapper puts that wrapper's name in this list, and the equality says so; one that + * dropped the flag puts a different argument list in it. Both are the same defect seen from + * two sides, and neither is visible to any other test in this file. + */ + expect(reached).toEqual([ + `the raw client's connectedAccounts.delete ["ca_1",{"revoke_on_delete":true}]`, + `the raw client's authConfigs.delete ["${OUR_GMAIL.id}",{"revoke_on_delete":true}]`, + ]); + }); +}); From 90648e9bd6d0aaa3ccd54ab1bcf6b6aa3d33320e Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 12 Sep 2026 01:21:53 +0200 Subject: [PATCH 151/215] Send every candidate sentence through one door, and read the labels as well as the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both instances of a fix landing at the site a reviewer named and not at its siblings. Swept by class rather than by site. THE SHARED GUARD WAS NOT SHARED. `VENDOR_PLACEHOLDER` and `VENDOR_RESPONSE_DUMP` are one judgement — the vendor emitted something where an explanation belongs and none of it explains anything — and four readers were each making it themselves. The dump rule had been added to `thrownSentence` alone, so the same condition was refused where Composio threw it and passed on where Composio reported it. `passableSentence` is now the only place either rule is written, and every reader of a candidate string goes through it: - `sentenceOf`, the field `vendorSentence` reads at BOTH depths. Had the blank and placeholder rules, never the dump rule, so a status code followed by a whole body arriving at `error.message` was passed on as the vendor's own explanation. Folded into the door and deleted. - `thrownSentence`. Held all three rules, which is what let it drift from the others; it now settles only WHICH string is the candidate on a throw. - `reportedFailure`, the finding. Tested `VENDOR_PLACEHOLDER` inline against the envelope's `error` field and nothing else, so a dump in that field went to the model as the vendor's report and into `store.ts`'s audit row. The presence test stays separate from the judgement: a blank `error` means no failure was reported, an unreadable one means a failure was reported and cannot be explained, and collapsing the two would turn the second into a success. - `callTool`'s serialization catch. The one path that reached a model and the audit row with no door at all: it interpolated a raw `error.message`, judged nowhere. `JSON.stringify` calls `toJSON` on whatever the vendor put in `data`, so what it throws with is not necessarily ours. Now quoted through the door, with a clause for the case where there is nothing worth quoting — the claim that matters, that the action ran and it is this deployment that could not read the answer, is ours and survives having no reason. Checked and left alone: `brokerSentence` is a deliberate second door for authored refusals, and `./broker` never quotes vendor text into one, so the rules do not apply to it. `routes.ts:91` and `composio-adapter.ts:906` call `vendorSentence` and inherit the dump refusal, which is the outcome both document wanting. THE DESTRUCTIVE MARKER, UNPROTECTED FOR THE THIRD TIME. The `tags` guard checked the container and trusted the contents. `effectOf` decides by `Set.has("destructiveHint")`, an identity comparison no non-string element can satisfy, so `[{ name: "destructiveHint" }]` and `[["destructiveHint"]]` were not an unreadable field that throws — they were a destructive action recorded `destructive: false`, on the row that decides whether a Bot is stopped before it runs the action, with nothing about the row looking wrong. Asked of every element, because a list that is mostly labels with one malformed entry is what a vendor actually sends and is what a looser check passes. AND THE VERSION BESIDE IT. `action.version?.trim()` sits in a map outside the try; `?.` guards absence and not type, so a number or an object threw `action.version?.trim is not a function` into the row's `lastError` — the exact failure the labels guard next to it exists to prevent. Refused there rather than read as no version: an action recorded versionless is permanently uncallable behind a refresh that rewrites the same field, and committing the listing deletes the version held for every other action on the app. `null` is still absence, which is how JSON spells "no version published" and how the map has always read it. The element-level sweep found the remaining container reads already holding their contents — the action array checks each element with `schemaNode`, `isSchemaMismatch` checks each issue for `code` and `path`, and `stagesAFile` guards every branch it walks. Two fields are recorded verbatim and checked nowhere: a non-string `description` breaks `storableTools` with `42 .replaceAll is not a function`, and a non-object `inputParameters` is recorded as a schema that is not one and shown to a model. Neither is the container/contents shape and neither loses a safety flag, and the natural home for both is beside the existing `slug` check in `composio-adapter.ts` — left for that file rather than fixed from this one. Red-green on all eight new tests, and thirteen mutations of the changed guards each redden at least one: dropping any of the three rules from the door, reverting each of the four readers to judging for itself, checking the tags container without its contents, asking the element check of every rather than some, removing the version guard, widening it to refuse null, and widening the dump rule to every message opening with a status code. server/tests: 2316 pass, 5 skip, 0 fail across 138 files, 2321 registered — 2313 before, plus the eight added here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio.ts | 157 +++++++++++-- server/tests/composio-transport.test.ts | 293 ++++++++++++++++++++++++ 2 files changed, 435 insertions(+), 15 deletions(-) diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 07eea5e0d..6c9af022e 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -536,9 +536,26 @@ export async function listTools(connection: { * a write — a classification nobody can see is wrong, on a row an administrator grants from. * Defaulting either shape to "write" would make the same silent answer deliberate, so both are * refused here while a sentence can still name the action. + * + * AND THE LABELS THEMSELVES ARE READ, SO THEY HAVE TO BE LABELS. This guard checked the CONTAINER + * and trusted its CONTENTS, and that is the third distinct way this one field has been found + * unheld. {@link effectOf} decides by `Set.has("destructiveHint")`, which is an identity + * comparison: no object, nested array or number in that set can ever match it, whatever it spells. + * So `[{ name: "destructiveHint" }]` and `[["destructiveHint"]]` — the two shapes a listing + * carries when a vendor changes how it spells a label — are not an unreadable field that throws. + * They are a DESTRUCTIVE action recorded as `destructive: false`, on the row that decides whether + * a Bot is stopped before it runs the action at all, and nothing about the row looks wrong. + * + * That is the string case's silent wrong answer again, one level in and failing the dangerous + * way round rather than the cautious one, so it is answered the same way and in the same breath. + * ASKED OF EVERY ELEMENT rather than of any, because a list that is mostly labels with one + * malformed entry is the shape a vendor actually sends, and it is the one a looser check passes. */ const oddTags = actions.find( - (action) => action.tags !== undefined && !Array.isArray(action.tags), + (action) => + action.tags !== undefined && + (!Array.isArray(action.tags) || + action.tags.some((label) => typeof label !== "string")), ); if (oddTags) { throw new Error( @@ -546,6 +563,43 @@ export async function listTools(connection: { ); } + /* + * AND THE VERSION BESIDE THEM, WHICH IS READ IN THE SAME MAP AND WAS HELD BY NOBODY. + * + * `action.version?.trim()` is the read, and `?.` guards null and undefined and nothing else — so a + * number, an object, a list or a boolean throws `action.version?.trim is not a function` out of a + * map that sits OUTSIDE the try wrapping the vendor's call. That engine sentence is exactly what + * `refreshTools` writes into the row's `lastError` for an administrator to read, which is the + * failure the labels guard above exists to prevent, on the field immediately next to it. + * + * REFUSED RATHER THAN READ AS NO VERSION, which is the other candidate repair and is the wrong + * one for the reason every refusal on this path is a refusal. Recording the action with no + * version makes it permanently uncallable and sends its reader to a refresh that writes the same + * unreadable field back — the loop {@link callTool}'s version refusal already names — and + * committing the listing at all is a delete and an insert that takes the `version` of every OTHER + * action on the app with it, which no later refresh reconstructs where Composio publishes none. + * + * `null` IS NOT ONE OF THESE, and that is deliberate rather than an oversight in the check. "This + * action has no version" is a real and common state with an answer already — the action is listed + * with no version key — and `null` is how JSON spells it. The `?.` in the map has always read it + * that way; refusing it here would turn a healthy refresh into a total failure for every app that + * publishes one, which is the loss this guard is written to avoid rather than to cause. + * + * Both fields are read as `unknown` for the reason {@link reportedFailure} reads its two that way: + * the types above are this module's projection and the values are the vendor's. + */ + const oddVersion = actions.find((action) => { + const version: unknown = action.version; + return ( + version !== undefined && version !== null && typeof version !== "string" + ); + }); + if (oddVersion) { + throw new Error( + `Composio's action list for ${toolkit} described ${oddVersion.slug.trim()}'s version as something other than a version string, and the version is what this deployment has to send back to call the action at all. Nothing was refreshed and the actions already recorded for this app are kept rather than replaced by a listing whose versions could not be read.`, + ); + } + if (actions.length >= LISTING_LIMIT) { /* * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. @@ -628,6 +682,11 @@ export async function listTools(connection: { * permanently uncallable — and the refusal its caller reads names a refresh, which records * the same blank again. Recording exactly what `callTool` will send is what closes that * loop; a blank becomes no version, which is the state whose refusal says so truthfully. + * + * WHAT THE `?.` GUARDS IS ABSENCE AND NOT TYPE, which is why this line is no longer the only + * thing standing between the vendor's field and a `trim` that is not a function. A version + * that is neither absent nor a string is refused above, beside the labels, where a sentence + * can still name the action; here it cannot arrive. */ const version = action.version?.trim(); return { @@ -706,16 +765,46 @@ export function vendorSentence(error: unknown): string | null { const thrown = schemaNode(error); for (const carrier of [thrown, schemaNode(thrown?.cause)]) { const body = schemaNode(carrier?.error); - const sentence = sentenceOf(schemaNode(body?.error)?.message); + const sentence = passableSentence(schemaNode(body?.error)?.message); if (sentence !== null) return sentence; } return null; } -/** One candidate field, judged the one way every depth is judged. See {@link vendorSentence}. */ -function sentenceOf(message: unknown): string | null { +/** + * THE ONE DOOR. Whether a candidate string is worth showing anybody, and the trimmed string if so. + * + * EVERY READER OF A CANDIDATE SENTENCE IN THIS MODULE ASKS THROUGH HERE, and that is the whole + * point of it rather than a tidiness. There are four places a string is picked up and handed to a + * model, to an administrator reading `lastError` off the Plugins page, or to `store.ts`'s audit + * row: the two depths {@link vendorSentence} reaches, the message a failure was THROWN with, the + * `error` field of a resolved envelope, and the reason a serialization failed. Each of them used to + * make this judgement itself, and the judgement then drifted — which is not a hypothesis. A first + * extraction moved the blank and the placeholder rules into one place and left the `error` field + * reading `VENDOR_PLACEHOLDER` inline; {@link VENDOR_RESPONSE_DUMP} was then added to the extracted + * side only, so a status code followed by an entire response body was refused where it was thrown + * and passed on where it was reported. The fix for that is not a third copy of the rule. It is that + * there is nowhere left to put one. + * + * THREE REFUSALS, AND THEY ARE THE SAME REFUSAL. A blank string, "Error executing the tool X" and + * "502 {…}" are one condition wearing three shapes: the vendor emitted something where an + * explanation belongs and none of it explains anything. What each caller does about a null differs + * — one falls through to the next depth, one to this deployment's own words — and that is the part + * that belongs to the caller. What is NOT worth passing on does not vary by door, and the moment it + * is allowed to, the guard is decoration. + * + * A NON-STRING IS NOT A SENTENCE, which is why the parameter is `unknown` rather than `string`. The + * types in this file are its own projection of somebody else's JSON; a `message` that is a number, + * an object or a list reaches a reader as `1810` or `[object Object]`, and collapsing all of those + * to the blank case here is what stops each caller inventing its own `typeof`. + */ +function passableSentence(message: unknown): string | null { const sentence = typeof message === "string" ? message.trim() : ""; - return sentence === "" || VENDOR_PLACEHOLDER.test(sentence) ? null : sentence; + return sentence === "" || + VENDOR_PLACEHOLDER.test(sentence) || + VENDOR_RESPONSE_DUMP.test(sentence) + ? null + : sentence; } /** @@ -751,14 +840,15 @@ export const VENDOR_RESPONSE_DUMP = /^\d{3} [[{]/; * differently: a guard that grew a third refusal on one path and not the other would put the * vendor's response dump in front of a model or an operator depending on which door they arrived * through, which is the divergence `callTool`'s catch already had to be corrected for once. + * + * WHICH IS WHY THE JUDGEMENT ITSELF IS NOT HERE ANY MORE. This function once held all three + * refusals, and holding them is what let it drift from the other readers — the dump rule was added + * to this copy and to no other, so the escape it closed here stayed open on the envelope's `error` + * field and at both depths {@link vendorSentence} reads. All this owes its callers now is WHICH + * string is the candidate on a throw; {@link passableSentence} settles what any candidate is worth. */ function thrownSentence(error: unknown): string | null { - const thrown = error instanceof Error ? error.message.trim() : ""; - return thrown === "" || - VENDOR_PLACEHOLDER.test(thrown) || - VENDOR_RESPONSE_DUMP.test(thrown) - ? null - : thrown; + return passableSentence(error instanceof Error ? error.message : null); } /** @@ -1044,9 +1134,24 @@ function reportedFailure( : `${toolName} was sent to Composio and Composio answered, but this deployment could not read what it said about the call: the answer's error was neither a sentence nor null, which is all Composio's own schema permits it to be, so nothing here can tell whether the action ran.`; } + /* + * THE SAME DOOR THE THROWN MESSAGE GOES THROUGH, which is the correction, and it is the one this + * whole extraction was made for. This branch tested {@link VENDOR_PLACEHOLDER} inline while + * {@link thrownSentence} had grown {@link VENDOR_RESPONSE_DUMP} beside it — so one condition, an + * unreadable non-explanation in the `error` field, was answered two different ways depending on + * whether Composio threw it or reported it in a 200, and on this side a status code followed by + * the whole response body went to the model as the vendor's report and into `store.ts`'s audit + * row beside it. + * + * THE PRESENCE TEST STAYS SEPARATE FROM THE JUDGEMENT, because the two answer different + * questions and only one of them is the door's. A blank `error` means the vendor reported NO + * failure and the flag below decides the outcome; a non-blank one this deployment will not pass + * on means the vendor reported a failure it cannot explain, which is exactly {@link unexplained}. + * Collapsing both to null here would turn the second into a success. + */ const sentence = reported === null ? "" : reported.trim(); if (sentence !== "") { - return VENDOR_PLACEHOLDER.test(sentence) ? unexplained(toolName) : sentence; + return passableSentence(sentence) ?? unexplained(toolName); } if (typeof outcome !== "boolean") { @@ -1230,10 +1335,32 @@ export async function callTool( try { return resultOf(answer.data); } catch (error) { + /* + * THE LAST PATH THAT REACHED A MODEL WITHOUT PASSING THE DOOR. This quoted a raw `error.message` + * into the sentence `store.ts` records, asking nothing of it — the one refusal in this module + * that read a candidate string and judged it nowhere. + * + * WHAT IT CAN BE HANDED IS NOT ONLY OURS. `resultOf`'s own throw is this file's sentence and + * the engine's circular-reference and length errors are the engine's, but `JSON.stringify` + * calls `toJSON` on whatever the vendor put in `data`, so a throw out of there arrives wearing + * whatever the vendor's object felt like throwing — the same class of string refused four lines + * above, arriving through the one reader that was not asking. + * + * A REASON IT WILL NOT QUOTE STILL LEAVES A FINISHED SENTENCE, which is why the fallback is a + * clause rather than nothing. What this refusal has to carry is which of the two events it was: + * the action ran, Composio answered, and it is this deployment that could not read the answer. + * That claim is ours and holds whether or not there is a reason worth repeating. + * + * `String(error)` KEEPS THE NON-`Error` THROW READABLE, which {@link thrownSentence} does not + * do and should not: there the alternative is the vendor's own sentence one level in, and here + * there is no other candidate at all. + */ + const why = + passableSentence( + error instanceof Error ? error.message : String(error), + ) ?? "the reason it failed with is not one this deployment will pass on"; return failure( - `${toolName} ran and Composio answered, but this deployment could not turn that answer into text: ${ - error instanceof Error ? error.message : String(error) - }`, + `${toolName} ran and Composio answered, but this deployment could not turn that answer into text: ${why}`, ); } } diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 9aa70157d..4b3dd2aba 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -128,6 +128,20 @@ function dumped(body: Record): Error { }); } +/** + * The dump AS A BARE STRING, for the places one arrives already unwrapped from its Error. + * + * Taken off {@link dumped} rather than written out a second time, so the two cannot drift: what the + * envelope's `error` field and a candidate sentence are tested against is byte-for-byte the string + * `@composio/client` builds. A vendor gateway that proxies an upstream status and body into the + * field its own schema spells a sentence produces exactly this, and so does one that puts its + * client's message there — the shape is what is refused, not the route it took. + */ +const RESPONSE_DUMP = dumped({ + detail: [{ loc: ["body", "arguments"], msg: "unrecognised" }], + request_id: "must-not-appear", +}).message; + function recording(answers: Partial = {}): { client: ComposioActions; calls: Recorded[]; @@ -417,6 +431,39 @@ describe("finding the vendor's own sentence", () => { ).toBeNull(); expect(vendorSentence(dumped({}))).toBeNull(); }); + + test("a response dump found where the sentence belongs is not a sentence either", () => { + /* + * THE OTHER HALF OF THE JUDGEMENT, WHICH ONLY ONE OF THE THREE READERS WAS MAKING. The dump + * refusal was added to the thrown-message guard and to nothing else, so a status code followed + * by a whole response body arriving AT `error.message` — the field this function reaches for — + * was passed on as the vendor's own explanation, at both depths. The test above only covers a + * body with no `message` at all; it says nothing about a `message` that is itself a dump, which + * is what a gateway proxying an upstream reply puts there. + * + * Refused for the reason the placeholder is: it is not an explanation, and it costs a request + * id in a model's context and an audit row the size of a response body to say so. + */ + expect(vendorSentence(nested(RESPONSE_DUMP))).toBeNull(); + expect(vendorSentence(unwrapped(RESPONSE_DUMP))).toBeNull(); + }); + + test("a status code with a real sentence after it is still a sentence", () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE, asserted so that widening it reddens something. What the + * module refuses is a three-digit status followed by the OPENING OF A JSON DOCUMENT, because + * that is the string the client builds and nothing a person writes. A status code followed by + * words is the client's other branch — the body's own `message` came through — and it is the + * most useful sentence on this path; refusing it would replace a named cause with advice to + * check a connection that is fine. + */ + expect(vendorSentence(nested("400 Invalid auth config id"))).toBe( + "400 Invalid auth config id", + ); + expect(vendorSentence(unwrapped("404 status code (no body)"))).toBe( + "404 status code (no body)", + ); + }); }); describe("listing an app's actions", () => { @@ -1248,6 +1295,135 @@ describe("listing an app's actions", () => { } }); + test("a list whose labels are not labels records no action as safe", async () => { + /* + * THE CONTAINER WAS CHECKED AND ITS CONTENTS WERE TRUSTED, which is the third distinct way this + * one field has been found unheld. `Array.isArray` settles that `tags` can be iterated and says + * nothing about what comes out, and `effectOf` decides by `Set.has("destructiveHint")` — an + * identity comparison that no non-string element can ever satisfy. So a destructive action + * whose labels arrive as objects, as nested arrays or as numbers is recorded with + * `destructive: false`. + * + * WHICH IS THE SAME SILENT WRONG ANSWER THE STRING CASE ABOVE IS REFUSED FOR, and worse in the + * direction it fails. `new Set("readOnlyHint")` at least loses a READ, and an action wrongly + * called a write is only asked about too often. This loses the DESTRUCTIVE flag, and that field + * is what decides whether a Bot is stopped before it runs the action at all — nobody sees it is + * wrong, because a row that says "not destructive" looks exactly like an action that is not. + * + * `["readOnlyHint", 7]` IS HERE BECAUSE THE HOLE IS PER-ELEMENT. A list that is mostly labels is + * the shape a vendor actually sends when one entry is malformed, and a guard written as "are + * any of these strings" rather than "are all of these strings" would pass it. + */ + for (const tags of [ + [["destructiveHint"]], + [{ name: "destructiveHint" }], + [123], + [null], + ["readOnlyHint", 7], + ]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_DELETE_DRAFT", description: "Delete.", tags }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + (listed) => + `the listing was committed, as ${JSON.stringify( + listed.map((tool) => [tool.name, tool.destructive]), + )}`, + (error: unknown) => (error as Error).message, + ); + + const named = JSON.stringify(tags); + // NAMED IN THE SUCCESS ARM, so the failure message says what was recorded rather than only + // that something was. The wrong answer this test exists for is a specific pair — + // ["GMAIL_DELETE_DRAFT",false] — and a reader of a red run should not have to go and find it. + expect(`${named}: ${outcome}`).not.toContain("the listing was committed"); + expect(outcome).toContain("GMAIL_DELETE_DRAFT"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not iterable|is not a function/i); + } + }); + + test("a version that is not a version breaks the listing rather than the runtime", async () => { + /* + * THE FIELD BESIDE THE LABELS, WITH THE FAILURE THE LABELS' GUARD EXISTS TO PREVENT. `version` + * is read as `action.version?.trim()` in a map that sits OUTSIDE the try wrapping the vendor's + * call, and `?.` guards nothing but null and undefined — so a number, an object or a boolean + * throws `action.version?.trim is not a function` out of `listTools`, and that engine sentence + * is what `refreshTools` writes into the row's `lastError` for an administrator to read. + * + * REFUSED RATHER THAN TREATED AS NO VERSION, which is the other candidate repair and the wrong + * one. An action recorded with no version is permanently uncallable and its refusal names a + * refresh — which would write the same unreadable field back — and committing the listing + * deletes the version already held for every OTHER action on the app, which no later refresh + * reconstructs where Composio publishes none. Keeping what is held is what every sibling + * refusal on this path does. + */ + for (const version of [5, { major: 1 }, ["20260903_00"], true]) { + useComposioClient( + recording({ + listActions: async () => + [ + GMAIL_READ, + { slug: "GMAIL_ODD_VERSION", tags: ["readOnlyHint"], version }, + ] as unknown as ComposioAction[], + }).client, + ); + + const outcome = await listTools({ url: "composio://gmail" }).then( + () => "the listing was committed", + (error: unknown) => (error as Error).message, + ); + + const named = JSON.stringify(version); + expect(`${named}: ${outcome}`).not.toBe( + `${named}: the listing was committed`, + ); + expect(outcome).toContain("GMAIL_ODD_VERSION"); + expect(outcome).toContain("gmail"); + expect(outcome).not.toMatch(/is not a function|undefined is not/i); + } + }); + + test("an absent version is still absent rather than unreadable", async () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE. "Composio published no version" is a real and common state + * this file already has an answer for — the action is listed with no version key — and `null` + * is how JSON spells it. The `?.` in the map has always read null that way, so refusing it + * would turn a healthy refresh into a total failure for every app that publishes one, which is + * exactly the loss the refusal above is written to avoid. + */ + useComposioClient( + recording({ + listActions: async () => + [ + { slug: "GMAIL_NO_VERSION", tags: ["readOnlyHint"] }, + { + slug: "GMAIL_NULL_VERSION", + tags: ["readOnlyHint"], + version: null, + }, + ] as unknown as ComposioAction[], + }).client, + ); + + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed.map((tool) => tool.name)).toEqual([ + "GMAIL_NO_VERSION", + "GMAIL_NULL_VERSION", + ]); + for (const tool of listed) { + expect(Object.keys(tool)).not.toContain("version"); + } + }); + test("a vendor complaint that merely carries issues is not answered with an upgrade", async () => { /* * `isSchemaMismatch` duck-types on the PRESENCE of an `issues` array, and an array under that @@ -2021,6 +2197,48 @@ describe("calling one action", () => { expect(result.text).toContain("GMAIL_FETCH_EMAILS"); }); + test("the serialization refusal quotes a reason through the same door as the rest", async () => { + /* + * THE ONE PATH THAT REACHED A MODEL AND THE AUDIT ROW WITHOUT PASSING THE DOOR AT ALL. + * + * Every other refusal in this module reads its candidate sentence through the judgement that + * refuses the vendor's placeholder and the response dump. This one interpolated a raw + * `error.message` — whatever `JSON.stringify` threw with — straight into the sentence + * `store.ts` records, with nothing asked of it. A `toJSON` is the vendor's own hook on the + * vendor's own data, so what it throws with is theirs; the class of message is the same one + * refused four lines away, and the guard is not a guard if the string can walk round it. + * + * WHAT THE REFUSAL STILL HAS TO SAY is which of the two events this was: the action ran and + * Composio answered, and it is this deployment that could not read the answer. That claim is + * this file's own and survives having no reason to quote. + */ + useComposioClient( + recording({ + execute: async () => + answered({ + attachment: { + toJSON() { + throw new Error(RESPONSE_DUMP); + }, + }, + }), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toMatch(/could not turn that answer into text/i); + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + }); + test("an answer reporting an error while claiming success is a failure", async () => { /* * `ToolExecuteResponseSchema` spells `error` and `successful` as two independent required @@ -2238,6 +2456,81 @@ describe("calling one action", () => { expect(reported.text).toContain("Plugins page"); }); + test("a reported error that is a response dump is refused like the placeholder", async () => { + /* + * THE ESCAPE THE EXTRACTION WAS MEANT TO CLOSE, LEFT OPEN ON THE ONE CALLER THAT WAS NOT MOVED. + * + * `thrownSentence` was given both refusals — the vendor's placeholder AND the status code + * followed by a stringified body — precisely so that no path could grow one without the other. + * This path never went through it: it tested `VENDOR_PLACEHOLDER` against the envelope's `error` + * field itself, so the second refusal simply did not exist here, and a dump arriving in that + * field went to the model as the vendor's own report and into `store.ts`'s audit row beside it. + * + * BOTH FLAG VALUES, because the dump is equally unreadable under either and the branch that + * judges the sentence is reached under both. `successful: true` beside a non-empty `error` is + * already a reported failure by this module's own rule, so the only question left is what is + * said about it — and a request id and a validation payload are not it. + */ + for (const successful of [false, true]) { + useComposioClient( + recording({ + execute: async () => + answered( + { messages: [{ id: "m1" }] }, + { successful, error: RESPONSE_DUMP }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + const named = `successful: ${successful}`; + expect(`${named}: ${result.isError}`).toBe(`${named}: true`); + // Everything the dump would have carried into a model's context and the audit row. + expect(result.text).not.toContain("must-not-appear"); + expect(result.text).not.toContain("unrecognised"); + expect(result.text).not.toContain("{"); + // And what is said instead names the action and the one thing the reader can do about it, + // which is what every sibling on this path answers an unreadable report with. + expect(result.text).toContain("GMAIL_FETCH_EMAILS"); + expect(result.text).toMatch(/Plugins page/); + // The data must not be handed over as content beside a reported failure either. + expect(result.text).not.toContain("m1"); + } + }); + + test("a reported error that is a status code and words is still passed on", async () => { + /* + * THE LIMIT ON THE REFUSAL ABOVE, on this path as well as on `vendorSentence`'s. A dump is a + * status followed by the opening of a JSON document; a status followed by the body's own + * sentence is the client's other branch and is the most useful thing a reader gets. Widening + * the refusal to every message that opens with three digits would swap a named cause for + * advice about a connection that is working, so it has to redden something. + */ + useComposioClient( + recording({ + execute: async () => + answered( + {}, + { successful: false, error: "400 Invalid auth config id" }, + ), + }).client, + ); + + const result = await callTool( + { url: "composio://gmail", actorId: "user_asker" }, + "GMAIL_FETCH_EMAILS", + { __version: "20260903_00" }, + ); + + expect(result.isError).toBe(true); + expect(result.text).toBe("400 Invalid auth config id"); + }); + test("a successful flag that is not a boolean is not read as a success", async () => { /* * `successful !== false` ASKED ONE QUESTION OF A FIELD WITH THREE ANSWERS. The schema spells From b2ccc593433e0307f47a434bd4cc4c2ae78bf2a4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 12 Sep 2026 01:31:11 +0200 Subject: [PATCH 152/215] Refuse a withdrawal over a config this deployment cannot claim, and sweep the class on both paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review rounds have fixed a defect on the connected-accounts path and left the same defect standing on the auth-configs path, one function away. Each fix below is swept across both paths and every third site that shares its shape. REVOKE AND DELETEAUTHCONFIG DISAGREED ABOUT ONE CONDITION. `revoke` answered `false` where no config carries this deployment's suffix, on reasoning that cannot tell an app that never had a config of ours from one an operator RENAMED in Composio's dashboard — after which our own live grants read as somebody else's, the connection row is deleted and the trail records that no withdrawal was asked for. `deleteAuthConfig` refuses in exactly that state. Its reading is the right one, so `revoke` now refuses too, with the same count, the same suffix and the same one act in a dashboard as the remedy. `false` survives only where Composio holds nothing at all for the app. Sites: `revoke` fixed; `deleteAuthConfig` already correct; `authorize` already refuses in both readings and its shared remedy is pinned by a deliberate assertion, so only its unreadable-listing branch is new; `ensureAuthConfig` documents creating beside somebody else's config as the intended outcome and is unchanged. A REPEATED AUTH-CONFIG ROW WAS TWO CONFIGS. The accounts path deduplicates by id and this one did not, so a row the listing named twice sent a second delete, met Composio's "there is no such auth config", and turned a removal that had COMPLETED into a partial one that refused for ever. It also inflated the count in two refusals and sent the same id twice in the withdrawal's filter. Sites: `readableConfigs` fixed; `withdrawableAccounts` already correct; the catalogue examined and left — nothing acts per row there and no sentence counts them. ONE UNREADABLE CONFIG ROW BLOCKED A DISCONNECT FOR EVER. Reading the configs threw on the first row it could not check, ahead of any delete, and the row is as unreadable on every retry — the precise pattern the accounts path was corrected for last wave. The rows are now partitioned: every config this file can decide about is acted on, and the rest are counted in a refusal naming the dashboard rather than the button. Sites: `readableConfigs` fixed and its four callers each say what they do with the remainder — `deleteAuthConfig` drops ours first and reports after, `revoke` withdraws first and reports after, `authorize` proceeds on a readable config of ours and otherwise refuses with its own sentence, `ensureAuthConfig` alone refuses rather than creating a second config beside a row that may already be ours. `withdrawableAccounts` already correct; `appOf` and `actionOf` examined and left, both being reads that block nothing. AN EMPTY CURSOR KILLED EVERY APP AT ONCE. All four list responses in the installed client declare `next_cursor?: string | null`, so "" is type-legal, survives the transformers' `?? null`, and became a hard refusal — taking down revoke, authorize, ensureAuthConfig, deleteAuthConfig and isConnected for every app for as long as the vendor sent it. A cursor names a position and the blank one names none; it is what the first request omits, so no second request could reach anything the first did not. It is now the end of the listing, which is the rule `textOf` already applies to every other identifier here. A cursor that is not a string is still the refusal it was. A GUARD WAS DELETED ON A PREMISE THAT DOES NOT HOLD. Running 0.18.1: `("crm").id` is undefined and not a throw, so a string, number or boolean category survives `transformToolkitListResponse` and reaches the mapping as `{ slug: undefined, name: undefined }` — which was then refused as a category whose NAME was missing, about a value that was a string. The guard is restored, the entry declared at what the vendor's dereference actually promises, and three comments corrected to what the experiment shows. The same over-reach, handed over from the agent in `composio.ts` and verified here: `ToolSchema.parse` is a fact about one call in one version and not about this file's own seam, so a `description` of 42 crossed into `./store` and became a bare "42.replaceAll is not a function" — a crash where this file owes a sentence. Sites: `description`, `inputParameters` and `version` all checked in `actionOf`; `version` was a third live site neither report named. `tags` left to `./composio`, which already refuses a non-list where it reads them. Also: `sent()` judged emptiness on the untrimmed value while `textOf` judged the trimmed one, so a padded blank id was refused for being empty and then described as "a string"; and the quiet-removal test held no assertion at all. Two assertions pinned defects and are changed deliberately, each with its reasoning in place: `revoke` answering `false` over a config it cannot claim, and the removal deleting nothing at all beside an unreadable row. Red-green on every behavioural change. Mutation-checked: nine mutations, each reddening only its own tests. --- server/src/plugins/composio-adapter.ts | 516 ++++++++++++++++++----- server/tests/composio-adapter.test.ts | 550 ++++++++++++++++++++++++- 2 files changed, 942 insertions(+), 124 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 8a358b9bb..f02ee7d97 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1,8 +1,8 @@ import { Composio } from "@composio/core"; import { type BrokerApp, - type ComposioBroker, BrokerRefusalError, + type ComposioBroker, } from "./broker"; import { type ComposioAction, @@ -56,7 +56,6 @@ import { * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for * what is done when it is in fact missing. * - * THIS IS THE ONE ROW BELOW WHOSE FIELDS ARE AS NARROW AS THE SDK'S SCHEMA, AND THE REASON IS THAT * THIS IS THE ONE ROW THE SDK ACTUALLY VALIDATES. `Tools.transformToolCases` ends in * `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing parse * rather than the warn-only `transform()` every other listing here goes through — and both calls @@ -64,14 +63,25 @@ import { * whose `slug` is missing, or whose `toolkit` is present without a `slug`, does not arrive as a * malformed row: it arrives as a `ZodError`. `ToolkitSchema` spells that inner `slug` required * (`src/types/tool.types.ts:12-16`), so `toolkit?: { slug: string }` is a guarantee this file may - * rest on, unlike every declaration under it. + * rest on. + * + * AND THE THREE FIELDS THIS FILE HANDS ON ARE STILL `unknown`, WHICH IS WHERE THAT ARGUMENT STOPPED + * BEING TRUE. It was taken one step too far: the parse is a fact about `getRawComposioTools` in one + * version of one package, and what these declarations govern is {@link ComposioVendor} — the seam a + * test satisfies with a literal and the shape the next version will be read against. Running it + * shows the gap is not academic: a `description` of 42 crosses into `./composio` as the `string` + * this said it was and reaches `.replaceAll` in `./store` as a bare `TypeError`. A declaration is + * an assertion and not a check, so the three values {@link actionOf} carries across are declared + * at what the wire can hold and checked there. `slug` stays narrow because `ToolSchema` requires it + * AND {@link actionOf} checks it anyway; `tags` stays narrow because `./composio` refuses a + * non-list where it reads them. */ type VendorTool = { slug: string; - description?: string; - inputParameters?: Record; + description?: unknown; + inputParameters?: unknown; tags?: string[]; - version?: string; + version?: unknown; toolkit?: { slug: string }; }; @@ -111,14 +121,24 @@ type VendorTool = { * string. {@link appOf} refuses that. * * EVERY WIRE-VALUED FIELD IS `unknown` AND EVERY STRUCTURAL ONE IS NOT, which is the split the whole - * file now turns on. `meta`, its `categories` array and each entry in it are built by + * file now turns on. `meta` and its `categories` array are built by * `transformToolkitListResponse` itself (`src/utils/transformers/toolkits.ts:21-34`) — it reads - * `item.meta.categories` and maps each category into `{ slug, name }` — so a row with no meta, a - * `categories` that is not a list, and a category that is not an object all fail INSIDE that - * function and reach nothing here. Those three are guarantees. What the same lines copy across - * verbatim — the name, the slug, the description, the logo, each category's name and the count — is - * worth exactly what the wire is worth, so all of it is `unknown` or nullable and {@link appOf} - * says what it does with each. + * `item.meta.categories` and maps each category into `{ slug, name }` — so a row with no meta and a + * `categories` that is not a list both fail INSIDE that function and reach nothing here. Those two + * are guarantees, and they were verified rather than reasoned about: a `meta` of null and a + * `categories` of "crm" each raise a `TypeError` from that line when 0.18.1 is actually run. + * + * AND THE THIRD ONE WAS NOT, WHICH IS WHY THE ENTRY IS `unknown`. This said that "a category that + * is not an object" fails there too, and running it says otherwise: `category.id` on the string + * "crm" is `undefined` and not a throw, so a string, a number or a boolean in that list survives + * the map — as `{ slug: undefined, name: undefined }`, which reaches {@link appOf} wearing the + * shape of a category whose name Composio omitted. Only null and undefined die there. The claim was + * load-bearing for a guard that was deleted on the strength of it, so the entry is declared at what + * the vendor's own dereference actually promises, which is nothing. + * + * What the same lines copy across verbatim — the name, the slug, the description, the logo, each + * category's name and the count — is worth exactly what the wire is worth, so all of it is + * `unknown` or nullable and {@link appOf} says what it does with each. */ type VendorToolkit = { slug?: unknown; @@ -126,7 +146,7 @@ type VendorToolkit = { meta: { description?: unknown; logo?: unknown; - categories?: { name?: unknown }[]; + categories?: unknown[]; toolsCount?: unknown; }; }; @@ -153,7 +173,7 @@ type VendorToolkit = { * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and * "disabled" are different facts and only one of them is worth telling an operator. * - * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link configOf} makes + * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link readableConfigs} makes * that check, so the declaration no longer claims more than the wire promises. It is the one of the * three whose absence sends a request: a delete named with `undefined` asks Composio to remove * whatever it cares to, and this deployment then records that the app was withdrawn. @@ -245,12 +265,24 @@ const VENDOR_SHAPE_REMEDY = * `lastError` and put in front of a model. The shape is the part that is safe to say and is also * the only part that helps: a reader who knows a list arrived where an object belongs knows which * vendor change they are looking at. + * + * AND THE EMPTINESS IT REPORTS IS THE ONE THE CALLER JUDGED, WHICH IT WAS NOT. Every caller reaches + * this function on the branch {@link textOf} sent it down, and `textOf` decides on the TRIMMED + * value while this tested `value === ""` — so a padded blank, which is the shape a wire value + * actually arrives in, was refused for being empty and then described as "a string". "Composio sent + * a string where the id belongs" is a sentence with no finding in it: a string is what an id IS, so + * the reader is told the field was right and the call refused anyway. The two branches now agree on + * what blank means, and they say which of the two blanks arrived, because an id that is three + * spaces and an id that is absent are different things to go looking at in a dashboard. */ function sent(value: unknown): string { if (value === undefined) return "nothing"; if (value === null) return "null"; if (Array.isArray(value)) return "a list"; - if (value === "") return "an empty string"; + if (typeof value === "string") { + if (value === "") return "an empty string"; + return value.trim() === "" ? "a string of blank space" : "a string"; + } if (typeof value === "object") return "an object"; return `a ${typeof value}`; } @@ -439,18 +471,40 @@ async function everyRowOf( if (enough(rows)) return rows; /* - * ABSENT AND NULL BOTH MEAN THE END, and they are the two the vendor actually sends: the - * auth-config schema spells the field nullable and the connected-account one spells it nullish, - * and a transformer that met no `next_cursor` writes null. Anything else is a field this - * deployment cannot follow, and reading it as the end would be the exact mistake this function - * exists to prevent — one page treated as the whole answer, by a reader that had been told - * otherwise in a way it did not understand. + * ABSENT AND NULL BOTH MEAN THE END. The auth-config schema spells the field nullable and the + * connected-account one spells it nullish, and a transformer that met no `next_cursor` writes + * `response.next_cursor ?? null`. + * + * AND SO DOES A CURSOR WITH NOTHING IN IT, WHICH IS THE CORRECTION AND WAS THE COSTLIEST + * REFUSAL IN THIS FILE. The claim above used to be that absent and null are "the two the vendor + * actually sends" — which is not something this deployment can know, and the installed types + * say otherwise: all four list responses declare `next_cursor?: string | null` + * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, + * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal + * on the wire, and `?? null` does not catch it. It therefore arrived here, failed + * {@link textOf}, and became a refusal — one that kills `revoke`, `authorize`, + * `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` for EVERY app at once, permanently, + * over a field whose whole content is that there is nothing in it. + * + * A CURSOR NAMES A POSITION, AND THE BLANK ONE NAMES NONE. It is exactly what this loop sends + * when it has no position — the first request omits the field — so following it would ask for + * page one again, and the repeat guard below would then answer the vendor's empty string with + * a sentence accusing it of sending the same page twice. There is no reading of `""` under + * which a second request could reach anything the first did not. So it is the end of the + * listing, which is the same rule {@link textOf} already applies to every other identifier + * here, applied to the one field that had been left out of it. + * + * WHICH IS NOT COERCION, AND THE DIFFERENCE IS THE TEST BELOW IT. A cursor that is a number, an + * object or a list is a position this deployment cannot express and CANNOT rule out being real, + * so it is still the refusal it always was: one page read as the whole answer is the mistake + * this function exists to prevent. What changed is only the string that says nothing. */ const next = answered.nextCursor; if (next === undefined || next === null) return rows; const follow = textOf(next); if (follow === null) { + if (typeof next === "string") return rows; throw new BrokerRefusalError( `Composio sent ${sent(next)} where the cursor to the next page of ${listing.noun} belongs, so ${listing.consequence}: there are more of them than arrived and no cursor this deployment can ask for the rest with. ${VENDOR_SHAPE_REMEDY}`, ); @@ -529,11 +583,17 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { } /* - * `meta` IS TAKEN AS AN OBJECT RATHER THAN CHECKED FOR ONE, and the reason is one line of vendor - * code: `transformToolkitListResponse` reads `item.meta.categories` while it builds this row - * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:27`), so a row whose meta is - * absent or null raises there and is answered as the vendor-shape fault it is. The refusal that - * used to stand here could not be reached by any answer Composio can send. + * `meta` IS TAKEN AS AN OBJECT RATHER THAN CHECKED FOR ONE, and the reason is two lines of vendor + * code rather than the one this used to cite. `transformToolkitListResponse` reads + * `item.meta.categories` while it builds this row (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27`), so a meta that is absent or null raises there — and + * that is ALL the read rules out, which is less than was claimed for it: running 0.18.1 with a + * meta of "hello" or of 5 raises nothing, because `.categories` on a primitive is `undefined`. + * What makes every meta arriving here an object is the next line, which spreads it into a fresh + * literal (`:24-38`). That is a guarantee about the transformer and not about the wire, and its + * cost is written down where it lands: a primitive meta arrives as the spread of itself, so the + * app shows no description, no logo, no categories and no count — four absences this function + * treats as an app that published none, because nothing that reaches here can tell them apart. */ const meta = row.meta; @@ -552,18 +612,41 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { } /* - * THE LIST AND EACH ENTRY IN IT ARE THE TRANSFORMER'S OWN CONSTRUCTION, so neither is checked and - * both used to be. `item.meta.categories?.map(category => ({ slug: category.id, name: - * category.name }))` is what fills this field (`src/utils/transformers/toolkits.ts:27-30`): a - * `categories` that is not a list has no `.map` and dies there, a category that is null throws on - * `.id`, and everything that survives is an object this file did not have to hope for. What the - * line copies verbatim is the NAME, which is why that is the one thing still read. + * THE LIST IS THE TRANSFORMER'S OWN CONSTRUCTION AND THE ENTRIES IN IT ARE NOT, which is a line + * that was drawn in the wrong place and has been moved back. `item.meta.categories?.map(category + * => ({ slug: category.id, name: category.name }))` is what fills this field + * (`src/utils/transformers/toolkits.ts:27-30`), and a `categories` that is not a list has no + * `.map` and dies there — that half held up when it was run. + * + * THE OTHER HALF DID NOT, AND IT IS WHY THE ENTRY GUARD IS BACK. It was deleted on the stated + * ground that "a category that is null throws on `.id`, and everything that survives is an + * object". Only the first clause is true. `("crm").id` is `undefined`, not a throw, and so is + * `(7).id` and `(true).id` — so a primitive in that list survives the map and comes out the other + * side as `{ slug: undefined, name: undefined }`. What that cost was not a crash but a LIE: the + * whole catalogue was refused with "Composio sent nothing where the name of gmail's category 1 + * belongs", about a value that was the string "crm". An operator reading that goes looking in a + * dashboard for a category with a missing name, and there is no such category. + * + * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, and the two faults get the two sentences they + * are. Today's transformer rebuilds every entry, so this branch stands at the seam + * {@link ComposioVendor} declares rather than in front of the live SDK path — which is the right + * place for it, because the rebuild is one version's behaviour and the declaration is what the + * next version will be read against. A guard whose absence was argued from a dereference that + * does not dereference is not a guard anything should rest on again. + * + * What the transformer copies verbatim is the NAME, which is why that is the one field read. */ const categories = (meta.categories ?? []).map((entry, index) => { - const label = textOf(entry.name); + const at = `${slug}'s category ${index + 1}`; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new BrokerRefusalError( + `Composio sent ${sent(entry)} where ${at} belongs, and a category is an object carrying the word a person picks an app by. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const label = textOf((entry as { name?: unknown }).name); if (label === null) { throw new BrokerRefusalError( - `Composio sent ${sent(entry.name)} where the name of ${slug}'s category ${index + 1} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, + `Composio sent ${sent((entry as { name?: unknown }).name)} where the name of ${at} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, ); } return label; @@ -595,7 +678,7 @@ type CheckedAuthConfig = { }; /** - * One auth-config row checked into something worth deciding on, or a refusal saying why not. + * One app's auth configs split into the ones a decision can be made about and the ones it cannot. * * EVERY ROW IS CHECKED AND NOT ONLY THE ONES THAT TURN OUT TO BE OURS, because which ones are ours * is precisely what the name decides. A row whose name cannot be read cannot be sorted into "made @@ -604,29 +687,73 @@ type CheckedAuthConfig = { * config beside it and splits one app's connections in two; read as ours, * {@link ComposioBroker.deleteAuthConfig} deletes an object nobody here chose and every account * anybody had connected against it. + * + * PARTITIONED RATHER THAN THROWN, WHICH IS THE SAME CORRECTION {@link withdrawableAccounts} + * ALREADY CARRIES ONE LEVEL DOWN, ARRIVING HERE A WAVE LATE. This checked every row on the way out + * of the listing and threw on the first one it could not read, so a single unreadable config row + * was a permanent block on everything behind it: a person pressing disconnect got the same throw + * every time, for ever, because the row will be exactly as unreadable on the next attempt and + * nothing they can reach changes it. That is not the safe end of the trade — it is the SAME defect + * as a false success, pointing the other way, which is the finding the accounts path was corrected + * for and this one was left standing on. + * + * WHAT A CALLER IS OWED IS BOTH HALVES: every config this file CAN decide about, decided about, and + * a refusal counting the ones it cannot. Which half matters differs per caller and is therefore + * settled at each of the four rather than here — a withdrawal acts on what it can name and reports + * the rest, a creation must not put a second config beside a row that might already be ours, and a + * connect link minted against a readable config of ours is right whatever else the listing held. + * + * AND ONE ID IS ONE CONFIG, HOWEVER MANY TIMES THE LISTING NAMED IT — the other half + * {@link withdrawableAccounts} had and this did not. The paging loop guards against a repeated + * CURSOR and not a repeated ROW, and a page boundary crossed while a config is being created, or a + * proxy stitching two overlapping pages together, hands one id over twice with the cursor + * advancing normally each time. The second delete of one config then meets Composio's "there is no + * such auth config", which arrives as a refusal — so {@link ComposioBroker.deleteAuthConfig} + * counted a removal that had in fact completed as a partial one and refused to finish removing the + * app, every time, over a duplicate that is still there on the retry. It also inflates the count + * both that method and {@link ComposioBroker.authorize} put in front of an operator, and sends the + * same id twice in the withdrawal's `authConfigIds` filter. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, exactly as it does for accounts; the caller sorts on the id + * afterwards, so the order two callers see is the same one either way. */ -function configOf( - row: VendorAuthConfig, - position: number, +function readableConfigs( + rows: VendorAuthConfig[], toolkit: string, -): CheckedAuthConfig { - const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; +): { configs: CheckedAuthConfig[]; unreadable: BrokerRefusalError[] } { + const configs: CheckedAuthConfig[] = []; + const alreadyRead = new Set(); + const unreadable: BrokerRefusalError[] = []; - const id = textOf(row.id); - if (id === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a deletion names. Nothing was sent, because a delete without one asks Composio to remove whatever it cares to while this deployment records that the app was withdrawn. ${VENDOR_SHAPE_REMEDY}`, - ); - } + rows.forEach((row, position) => { + const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; - const name = textOf(row.name); - if (name === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.name)} where the name of ${at} belongs, and the name is the only thing that says whether this deployment made a config or an operator built it by hand in Composio's dashboard. Neither guess is safe: one splits this app's connections across two configs, and the other deletes a config nobody here chose along with every account connected against it. ${VENDOR_SHAPE_REMEDY}`, - ); - } + const id = textOf(row.id); + if (id === null) { + unreadable.push( + new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of ${at} belongs, and the id is the whole of what a deletion names. Nothing was sent for that row, because a delete without one asks Composio to remove whatever it cares to while this deployment records that the app was withdrawn. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + + const name = textOf(row.name); + if (name === null) { + unreadable.push( + new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and the name is the only thing that says whether this deployment made a config or an operator built it by hand in Composio's dashboard. Neither guess is safe: one splits this app's connections across two configs, and the other deletes a config nobody here chose along with every account connected against it. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } - return { id, name, status: row.status }; + if (alreadyRead.has(id)) return; + alreadyRead.add(id); + configs.push({ id, name, status: row.status }); + }); + + return { configs, unreadable }; } /** @@ -773,17 +900,29 @@ function withdrawalDeclined( * which `./composio`'s `isSchemaMismatch` already recognises and answers with the same package * remedy. Five refusals stood here for those five shapes and not one of them could be reached. * - * THE SCHEMA IS PREFERRED RATHER THAN COPIED, which is what deleting them means. Re-running - * `ToolSchema` here would be the same parse a second time and could no more fail than the branches - * it replaced; what makes the schema worth resting on is that the SDK enforces it, and - * {@link VendorTool} is that enforcement written down as the type this function takes. - * - * WHAT THE SCHEMA DOES NOT SETTLE IS THE ONE CHECK LEFT. `slug: z.string()` is satisfied by the + * AND FIVE WAS TWO TOO MANY, WHICH IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD IN + * {@link appOf} AND IS CORRECTED THE SAME WAY. "The SDK validates this" is a fact about + * `getRawComposioTools` in one version, and it is not a fact about {@link ComposioVendor}, which is + * the seam this function actually sits on and the shape a test satisfies with a literal. Running it + * settles what the difference costs: a `description` of 42 and an `inputParameters` of + * "not-a-schema" both travel through here untouched into {@link ComposioAction}, whose declared + * types say they cannot. What they reach is not a refusal. `storableTools` writes + * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")` + * (`./store`), so a description or a version that is not a string is a bare + * "42.replaceAll is not a function" thrown from outside every vendor `try` in this file — a crash + * wearing a refusal's clothes, which is the one outcome none of this file's sentences may become. + * An `inputParameters` that is not an object is quieter and worse: it is stored as the app's input + * schema and then shown to a model as Composio's own. + * + * SO THE THREE WIRE VALUES THIS FILE HANDS ON ARE CHECKED HERE, BESIDE THE SLUG, and `tags` is not + * — `./composio` already refuses a `tags` that is not a list where it reads them, and a check on + * both sides of one seam is a check nobody maintains. + * + * WHAT THE SCHEMA DOES NOT SETTLE IS THE FOURTH. `slug: z.string()` is satisfied by the * empty string, and an action's slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL * and half that table's primary key, it is what a grant points at, and it is what a later call * sends back to Composio. An empty one is a row that cannot be written and a call that names - * nothing, so it is refused here — the only shape of this row a passing `ToolSchema.parse` still - * admits. + * nothing, so it is refused here. * * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not * answered to a route. `./composio` records them in an app's `lastError` for an administrator to @@ -803,16 +942,50 @@ function actionOf( ); } + /* + * ABSENT IS AN ANSWER AND PRESENT-AND-WRONG IS NOT, which is the same split {@link appOf} makes + * over a catalogue row. Composio genuinely publishes actions with no description and no version, + * and one that publishes no parameters at all arrives with none — the SDK normalizes a `{}` to + * absent before parsing. Each of those reaches `./composio` as the absence it is and is defaulted + * where a column has a default. What is refused is the other thing: a field that is THERE and is + * not what this file has told `./composio` it is. + */ + const description = row.description; + if (description !== undefined && typeof description !== "string") { + throw new Error( + `Composio sent ${sent(description)} where the description of ${at} belongs, and that value is written into this app's tools and read back as text, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const inputParameters = row.inputParameters; + if ( + inputParameters !== undefined && + (typeof inputParameters !== "object" || + inputParameters === null || + Array.isArray(inputParameters)) + ) { + throw new Error( + `Composio sent ${sent(inputParameters)} where the input schema of ${at} belongs, and this deployment stores that value as the action's schema and shows it to a model as Composio's own. An action offered with a schema that is not one is a call nothing can get right, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const version = row.version; + if (version !== undefined && typeof version !== "string") { + throw new Error( + `Composio sent ${sent(version)} where the version of ${at} belongs, and the version is what a later call to this action asks Composio for, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* * Mapped field by field rather than spread, so what crosses the seam is the four things * `./composio` documents and not whatever else the vendor's tool object happens to carry. */ return { slug, - description: row.description, - inputParameters: row.inputParameters, + description, + inputParameters: inputParameters as Record | undefined, tags: row.tags, - version: row.version, + version, }; } @@ -1460,7 +1633,7 @@ const STATUSES_NAMED = 5; * one line honest. A predicate cannot refuse — it answers true or false — so reading a name the * vendor may not have sent here could only ever have meant silently answering `false`, and `false` * from this function means "somebody else's config": untouched by a removal, and satisfying the - * check that stops a second one being created. {@link configOf} is where the absence becomes a + * check that stops a second one being created. {@link readableConfigs} is where the absence becomes a * sentence instead, upstream of every caller. */ function madeHere(config: CheckedAuthConfig): boolean { @@ -1658,9 +1831,10 @@ export function buildComposioClient( * THE UNCLAIMED ROWS ARE RETURNED RATHER THAN DROPPED HERE, which is the part that moved. The * filter used to live on the way out, so "no configs at all" and "configs, none of them ours" * reached every caller as the same empty array — and telling those two apart is the whole of what - * {@link ComposioBroker.deleteAuthConfig} was missing. The two callers that only ever want ours - * go on asking for exactly that through {@link configsMadeHere}, which is now one line over this - * one rather than the only way in. + * {@link ComposioBroker.deleteAuthConfig} was missing, and then of what + * {@link ComposioBroker.revoke} was missing a wave later, on the same distinction, one function + * away. The `ours` filter is still offered, beside rather than instead of the rest, because a + * caller that can only see its own configs is a caller that cannot notice the other two states. * * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure * being fixed here is two calls resolving different rows; a total order on the id makes the @@ -1674,8 +1848,24 @@ export function buildComposioClient( * container or the same build of ICU. An order that two machines can disagree about is not an * order two callers agree on. `<` is the same total order everywhere, which is the only property * asked of it here. + * + * THREE PARTS RATHER THAN A LIST, AND THAT IS A GUARD AGAINST THIS FILE'S OWN HISTORY. Each of + * the four callers below has to answer three different questions about one listing — what is + * ours, what is standing that is not, and what could not be read at all — and every defect this + * function has been corrected for was one caller answering one of them while its neighbour, one + * function away, answered it differently or not at all. A shape that hands over only `ours` + * lets a caller not notice the other two; this one cannot be destructured without saying so. */ - const configsFor = async (toolkit: string): Promise => { + const configsFor = async ( + toolkit: string, + ): Promise<{ + /** Every readable row, ours and anybody else's alike, deduplicated and in one order. */ + held: CheckedAuthConfig[]; + /** The subset of `held` carrying {@link CONFIG_SUFFIX}, which is what this deployment claims. */ + ours: CheckedAuthConfig[]; + /** One refusal per row that could be sorted into neither, which is never nothing. */ + unreadable: BrokerRefusalError[]; + }> => { /* * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two * callers below are wrong in the two opposite directions {@link madeHere} describes: @@ -1708,22 +1898,16 @@ export function buildComposioClient( * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The * filter's question IS the name, so a row checked only once it had been kept would be a row * sorted by a field nobody had read — see {@link madeHere} for what each of the two guesses - * costs. Every row therefore passes {@link configOf} first, including the ones that turn out to - * belong to an operator's own dashboard work. + * costs. Every row therefore passes {@link readableConfigs} first, including the ones that turn + * out to belong to an operator's own dashboard work. */ - return rows - .map((row, position) => configOf(row, position, toolkit)) - .sort((one, other) => - one.id < other.id ? -1 : one.id > other.id ? 1 : 0, - ); + const { configs, unreadable } = readableConfigs(rows, toolkit); + const held = configs.sort((one, other) => + one.id < other.id ? -1 : one.id > other.id ? 1 : 0, + ); + return { held, ours: held.filter(madeHere), unreadable }; }; - /** The subset of {@link configsFor} this deployment can claim, which is what two callers want. */ - const configsMadeHere = async ( - toolkit: string, - ): Promise => - (await configsFor(toolkit)).filter(madeHere); - /** * Ask for every one of them and answer with what refused, rather than stopping at the first. * @@ -2097,8 +2281,24 @@ export function buildComposioClient( * than orphaned, because both carry the suffix and `deleteAuthConfig` takes every one of * ours. */ - const existing = await configsMadeHere(toolkit); - if (existing.length > 0) return; + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length > 0) return; + + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS. This is the one of the four + * callers that must NOT act on what it can name and report the rest — see + * {@link readableConfigs} for why the other three do. What it would be acting on is a + * CREATION, and a config created beside a row that is in fact ours under a name Composio sent + * unreadably is the second config this whole method exists to prevent: two populations of + * connections for one app, and a removal later that drops half of them. Finding nothing of + * ours in a listing this deployment could not read is not the same as finding nothing. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of them is already its own is not something it can tell — and the app was not enabled, rather than a second config being created beside one that may already be there. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } /* * THE OUTCOME NO LONGER CLAIMS NOTHING WAS CREATED, BECAUSE THIS CALL CANNOT KNOW THAT. @@ -2173,8 +2373,7 @@ export function buildComposioClient( * the removal with nothing in this deployment naming them, and an administrator is told the * app was withdrawn. */ - const held = await configsFor(toolkit); - const ours = held.filter(madeHere); + const { held, ours, unreadable } = await configsFor(toolkit); if (ours.length === 0 && held.length > 0) { /* * REPORTED, NOT DELETED AND NOT SWALLOWED, AND THE THIRD OPTION IS THE ONLY HONEST ONE. @@ -2185,7 +2384,7 @@ export function buildComposioClient( * connected against it — as it is to be ours under a new name. Nothing in the row tells * them apart, which is why nothing here chooses. * - * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link configOf} AND NOT THE SAME + * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link readableConfigs} AND NOT THE SAME * CASE. There the ambiguity is about ADDRESSING: the field that decides ownership did not * arrive, so no row can be sorted and the removal cannot begin. Here every name arrived and * every row is legible; what is in doubt is whether this deployment's own config is among @@ -2227,7 +2426,7 @@ export function buildComposioClient( vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), ), ); - if (refused.length > 0) { + if (refused.length > 0 || unreadable.length > 0) { /* * LOUD, because the caller is `removeServer` and the thing it is in the middle of is taking * an app away from everybody. A config left standing is a live grant that the removal was @@ -2237,10 +2436,32 @@ export function buildComposioClient( * again finishes the job rather than repeating it. Every refusal the loop met travels as * `cause` — see {@link everyRefusal} — because the count is deliberately all the sentence * says, which leaves the reasons nowhere else to live. + * + * AND AN UNREADABLE ROW LANDS HERE RATHER THAN AHEAD OF THE DELETES, which is the half that + * moved. Every config of ours is dropped first and the rows that could not be sorted are + * reported after — {@link readableConfigs} says why at length: refusing before the first + * delete meant one unreadable row made an app permanently unremovable, with the readable + * configs of ours standing the whole time. It is still a refusal, so `removeServer` does + * not delete the app's row and the app stays on its Plugins page. + * + * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES. A config Composio refused is one a second press + * reaches. A row it described with no id or no name is not — it will be exactly as + * unreadable next time — so the only instruction that helps names the dashboard rather than + * the button the operator just pressed. */ + const removed = ours.length - refused.length; + const left: string[] = []; + if (refused.length > 0) { + left.push("Removing it again asks only for what is left."); + } + if (unreadable.length > 0) { + left.push( + `Composio described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so nothing here can tell whether one of those is this deployment's own under an answer it could not read: reading them in Composio's own dashboard is what says whether anything this deployment made is still standing.`, + ); + } throw new BrokerRefusalError( - `Composio removed ${ours.length - refused.length} of this deployment's ${ours.length} authorization configs for ${toolkit} and refused the rest, so the app has not been fully withdrawn. Removing it again asks only for what is left.`, - { cause: everyRefusal(refused) }, + `Composio removed ${removed} of this deployment's ${ours.length} authorization configs for ${toolkit} and the app has not been fully withdrawn. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...unreadable]) }, ); } }, @@ -2273,8 +2494,28 @@ export function buildComposioClient( * connection is a lasting attachment to whatever config it was made against, so guessing here * is not a guess that can be corrected later. */ - const ours = await configsMadeHere(toolkit); + const { ours, unreadable } = await configsFor(toolkit); if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND THE REMEDIES ARE + * DIFFERENT PEOPLE'S. The sentence below sends an administrator to remove the app and add + * it again, which is right where the listing was legible and said there is none of ours — + * and wrong here, because the row that could not be sorted may BE ours, in which case + * removing the app meets {@link ComposioBroker.deleteAuthConfig}'s own refusal and adding + * it again is refused by `ensureAuthConfig` for the same reason. Nobody should be sent + * round a loop that cannot close. + * + * WHAT IS NOT DIFFERENT IS THAT NOTHING IS MINTED. A link is a lasting attachment to one + * particular config, so a person is never sent anywhere on the strength of a listing this + * deployment could not read — see {@link readableConfigs}, where a config of OURS that was + * read is enough to go on whatever else the listing held. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } throw new BrokerRefusalError( `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, ); @@ -2486,19 +2727,53 @@ export function buildComposioClient( * and the accounts hanging off that config are the same work. {@link madeHere} is the only * thing that tells the two apart, so it decides both. * - * NOTHING OF OURS IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app with no - * config of this deployment's never had a connection begun through it — {@link - * ComposioBroker.authorize} mints every link against {@link configsMadeHere} and refuses - * where there is none — so there is nothing here that this deployment granted. Answering - * before the listing is also what keeps the empty filter off the wire; see `authConfigIds` - * on {@link ComposioVendor} for why an empty one must never be sent. + * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app Composio + * holds no configs for never had a connection begun through it — {@link + * ComposioBroker.authorize} mints every link against a config of this deployment's and + * refuses where there is none — so there is nothing here that this deployment granted. + * Answering before the listing is also what keeps the empty filter off the wire; see + * `authConfigIds` on {@link ComposioVendor} for why an empty one must never be sent. + * + * BUT "NOTHING OF OURS" IS NOT THAT STATE, AND ANSWERING `false` TO IT WAS THE SEVENTH ROUTE + * TO A REVOCATION THAT DID NOT REVOKE. The reasoning above is sound about an app with no + * configs and cannot tell that app from this one: an operator renames a config in Composio's + * dashboard — drops the suffix, or edits the app's title past it — and this deployment's own + * live grants read as somebody else's work. `false` then means "there was nothing to + * withdraw", `store.ts` writes `vendorRevocationRequested: false` into the audit trail and + * deletes the `composio_connections` row, and the person's grant stands at Google with + * nothing in this deployment naming it. The trail records that no withdrawal was even asked + * for, which is the one direction nobody thinks to check. * - * AND `false` STILL MEANS WHAT IT SAID. The audit trail's `vendorRevocationRequested` records - * whether THIS DEPLOYMENT asked the vendor to end something, and an account on a config it - * did not make is not something it ever granted. What survives a disconnect here is an - * attachment made outside this deployment, which only the dashboard that made it can end. + * {@link ComposioBroker.deleteAuthConfig} REFUSES IN EXACTLY THIS STATE, and two halves of + * one operation cannot disagree about one condition. Its reading is the right one and this is + * the half that moves: the app's configs are legible, none of them carries the suffix, and + * nothing in the row says whether that is an operator's own work or ours under a title + * somebody edited. A refusal leaves the connection row standing, which is what keeps the + * person's grant findable, and names the same one act in a dashboard that the removal does. + * + * `false` STILL MEANS WHAT IT SAID, and now only where it is true: Composio holds nothing for + * this app, so this deployment granted nothing through it. + */ + const { held, ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0 && held.length > 0) { + throw new BrokerRefusalError( + `Disconnecting ${toolkit} found none of this deployment's own authorization configs at Composio, and Composio holds ${held.length} for ${toolkit} whose name does not carry ${CONFIG_SUFFIX} — so nothing was withdrawn and this person's access has not ended, rather than their connection being forgotten here while their grant stands. Nothing here can tell one of ours, renamed in Composio's dashboard, from an operator's own work. If it is this deployment's, this person's grants on it are live, and renaming it to end with ${CONFIG_SUFFIX} lets disconnecting again withdraw them. If it is an operator's, this deployment granted nothing through it and only that dashboard can end what it holds.`, + ); + } + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS — the same distinction one + * field further in. A config whose id or name Composio sent unreadably cannot be put in the + * account listing's filter, so any grant of this person's sitting on it is invisible to + * everything below and a `false` would be the same false trail entry as the rename above. + * Where there IS something of ours it is withdrawn first and this is reported afterwards, for + * the reason {@link readableConfigs} gives; here there is nothing to withdraw first. */ - const ours = await configsMadeHere(toolkit); + if (ours.length === 0 && unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so whether this person holds a grant on one of this deployment's own could not be told and nothing was withdrawn. Their access has not been shown to end. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } if (ours.length === 0) return false; const accounts = await accountsFor(userId, toolkit, REVOCABLE, { configs: ours.map((config) => config.id), @@ -2535,7 +2810,7 @@ export function buildComposioClient( if (declined !== null) throw declined; }); - if (refused.length > 0 || nameless.length > 0) { + if (refused.length > 0 || nameless.length > 0 || unreadable.length > 0) { /* * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which @@ -2566,14 +2841,33 @@ export function buildComposioClient( * the sentence a reader is meant to act on. The two halves this call actually holds are the * accounts it could name and the ones it could not, and their sum is the set. */ - const held = ids.length + nameless.length; - const left = - nameless.length === 0 - ? "Disconnecting again asks only for the accounts that are left." - : `Composio described ${nameless.length} of them with no id at all, so this deployment has no way to name those in a withdrawal and disconnecting again meets them unchanged: removing them in Composio's own dashboard is what ends them.`; + const accountsHeld = ids.length + nameless.length; + const left: string[] = []; + if (nameless.length > 0) { + left.push( + `Composio described ${nameless.length} of them with no id at all, so this deployment has no way to name those in a withdrawal and disconnecting again meets them unchanged: removing them in Composio's own dashboard is what ends them.`, + ); + } else if (refused.length > 0) { + left.push( + "Disconnecting again asks only for the accounts that are left.", + ); + } + /* + * AND A CONFIG ROW THAT COULD NOT BE READ IS ITS OWN CLAUSE, because it is a fact about a + * different thing from all of the above. Every sentence before this one counts ACCOUNTS + * that were looked at; this one says that the set of accounts looked at may not have been + * the set — a config of this deployment's, sitting behind a row whose id or name Composio + * sent unreadably, is one the listing was never scoped to. The grants already withdrawn + * stay withdrawn, which is why this arrives after them rather than instead of them. + */ + if (unreadable.length > 0) { + left.push( + `Composio also described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so any grant of theirs on one of those was never in the question and disconnecting again meets the same answer: reading those configs in Composio's own dashboard is what says whether anything is left.`, + ); + } throw new BrokerRefusalError( - `Composio withdrew ${ids.length - refused.length} of this person's ${held} accounts for ${toolkit} and did not withdraw the rest, so their access to it has not ended. ${left}`, - { cause: everyRefusal([...refused, ...nameless]) }, + `Composio withdrew ${ids.length - refused.length} of this person's ${accountsHeld} accounts for ${toolkit} and their access to it has not been shown to end. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...nameless, ...unreadable]) }, ); } diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index a6f5eb9bc..1224b7911 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1,5 +1,5 @@ -import { Composio } from "@composio/core"; import { describe, expect, test } from "bun:test"; +import { Composio } from "@composio/core"; import { BrokerRefusalError, brokerSentence, @@ -1034,6 +1034,178 @@ describe("telling this deployment's auth configs from anybody else's", () => { expect(deleted).toEqual([]); }); + /** + * A CONFIG ROW THE LISTING NAMED TWICE IS ONE CONFIG, NOT TWO — the twin of the account dedupe + * that landed a wave earlier, one function away, and was not brought here. + * + * The paging loop guards against the vendor repeating a CURSOR and not against it repeating a + * ROW: a page boundary crossed while a config is created, or a proxy stitching two overlapping + * pages together, hands one id over twice with the cursor advancing perfectly each time. The + * second delete of that config then meets Composio's "there is no such auth config", which + * arrives as a refusal — so a removal that had in fact COMPLETED was counted as partial, + * `removeServer` never deleted the app's row, and every retry met the same duplicate and failed + * in the same place. An app in that state can never be removed. + * + * THE DOUBLE REFUSES THE SECOND DELETE OF ONE CONFIG, which is what the vendor does and what + * makes this test able to fail. A stub that answered both identically would be green against an + * adapter that sent the delete twice. + */ + test("a config the listing named twice is removed once", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async (query: unknown) => + (query as { cursor?: string }).cursor === undefined + ? { items: [OURS], nextCursor: "page_2" } + : { items: [OURS], nextCursor: null }, + delete: async (id: string) => { + if (deleted.includes(id)) { + throw new Error( + `Composio holds no auth config with the id ${id}.`, + ); + } + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + expect(deleted).toEqual(["ac_ours"]); + }); + + /** + * AND THE COUNT IN A REAL PARTIAL REMOVAL IS OF CONFIGS, NOT OF ROWS — the same pairing the + * account dedupe has, for the same reason: an inflated denominator is a figure nothing measured, + * in the one sentence an operator is meant to act on. + */ + test("a duplicated row is not a third config in the sentence a partial removal carries", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [OURS, OURS, OURS_SPARE] }), + delete: async (id: string) => { + if (id === "ac_ours_spare") { + throw new Error("Composio refused that one."); + } + }, + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch( + /removed 1 of this deployment's 2 authorization configs for linear/, + ); + }); + + /** + * A ROW THAT COULD NOT BE READ MUST NOT BLOCK THE CONNECTION IT HAS NOTHING TO DO WITH. + * + * Reading the configs used to throw on the first row it could not check, so one unreadable row + * refused every call that reads this listing — including this one, where a config of ours was + * read, is ENABLED, and is the right thing to attach somebody to whatever else the listing held. + * The person is told the vendor's shape is wrong about an app they can perfectly well connect. + */ + test("an unreadable row does not stop a connection against the config that was readable", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_nameless", status: "ENABLED" }, OURS], + }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + await broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }); + + expect(linked).toEqual([ + ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + ]); + }); + + /** + * AND WHERE THERE IS NOTHING OF OURS TO GO ON, THE REMEDY IS NOT THE ONE FOR AN APP WITH NO + * CONFIG. "Remove the app and add it again" is a loop that cannot close in this state: the + * removal meets its own refusal over the same unreadable row, and so does the enable. The + * sentences are told apart by their remedies here for the reason {@link NO_CONFIG_REMEDY} exists. + */ + test("a listing this deployment cannot read is not an app with no config", async () => { + const linked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + }, + connectedAccounts: { + link: async (...call: unknown[]) => { + linked.push(call); + return { redirectUrl: "https://backend.composio.dev/s/a-link" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.authorize({ + userId: "user_1", + toolkit: "linear", + returnUrl: RETURN_URL, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + // And nobody was sent anywhere: a link is a lasting attachment to one config, so it is never + // minted off a listing this deployment could not read. + expect(linked).toEqual([]); + }); + + /** + * AND NOTHING IS CREATED BESIDE A ROW THAT MIGHT ALREADY BE OURS, which is the one caller of the + * four that must refuse rather than act on what it can name. A second config is not a duplicate + * but a SPLIT: two populations of connections for one app, and a removal later that drops half. + */ + test("a row this deployment cannot read stops a second config being created", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(created).toEqual([]); + }); + test("an app Composio holds no configs for at all is still a quiet removal", async () => { /* * THE HALF THE REFUSAL ABOVE MUST NOT SWALLOW. Removing an app has to be able to happen twice: @@ -1042,12 +1214,27 @@ describe("telling this deployment's auth configs from anybody else's", () => { * In each of those the end state is the one that was asked for, so a throw would report a * failure to somebody who got exactly what they wanted — and an implementation that reached * green above by refusing whenever it deleted nothing would do precisely that. + * + * AND IT SAYS SO RATHER THAN LEAVING IT TO BE INFERRED, which it did not: this test held no + * assertion at all. What it actually pinned was "the call did not throw", which a reader has to + * reconstruct from the absence of an `expect` — and the other half, that nothing was deleted, + * rested on {@link fakeVendor}'s stub refusing, where a call that WAS made and a call that was + * not both end the test the same way round. Both halves are stated here. */ + const deleted: unknown[] = []; const { broker } = buildComposioClient( - fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + delete: async (...call: unknown[]) => { + deleted.push(call); + }, + }, + }), ); - await broker.deleteAuthConfig("linear"); + await expect(broker.deleteAuthConfig("linear")).resolves.toBeUndefined(); + expect(deleted).toEqual([]); }); test("a disabled config of ours stops a second one being created", async () => { @@ -1442,6 +1629,55 @@ describe("withdrawing one person's grants", () => { expect((failure.cause as Error).message).toMatch(/id/); }); + /** + * AND A CONFIG ROW THIS DEPLOYMENT CANNOT READ MUST NOT STOP IT EITHER — the same correction as + * the account above, one listing earlier, where it was still live after that one landed. + * + * Reading the configs threw on the first row it could not check, and the withdrawal reads them + * BEFORE it reads a single account. So one unreadable config row meant a person could never + * withdraw anything for that app: every press met the same row and the same throw, ahead of the + * first delete, with their readable grants standing the whole time. What they are owed is the + * withdrawal of every grant this deployment can reach and a sentence about what it could not. + */ + test("a config row that could not be read does not stop the grants behind the one that could", async () => { + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: "ac_gmail_nameless" }, OUR_GMAIL], + }), + }, + connectedAccounts: { + list: async () => ({ items: [{ id: "ca_1" }] }), + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + () => 1_000_000, + ); + + const failure = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), + ); + + // The grant on the config that WAS readable is gone, which is the half a throw ahead of the + // loop threw away. + expect(deleted).toEqual(["ca_1"]); + // And still a failure, because a grant of theirs may sit on the config that could not be read + // and nothing here ever asked about it — so `store.ts` must not delete the row that names this + // person's connection. + expect(failure).toBeInstanceOf(BrokerRefusalError); + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message).toMatch(/withdrew 1 of this person's 1 accounts/); + expect(failure.message).toMatch(/authorization configs for gmail/); + // And the reason that row could not be sorted travels as `cause`, because the sentence above is + // deliberately a count and leaves the reasons nowhere else to live. + expect((failure.cause as Error).message).toMatch(/name/); + }); + /** * THE GATE IS A COUNT AND WAS ASKING FOR AN ID IT NEVER USES. * @@ -1516,12 +1752,12 @@ describe("withdrawing one person's grants", () => { }); /** - * NOTHING OF OURS IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. + * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. * * `authorize` mints every connect link against a config this deployment made and refuses where - * there is none, so an app with none of ours never had a connection begun through it. Listing - * accounts anyway could only turn up somebody else's, and the one thing this call does with an - * account it turns up is delete it. + * there is none, so an app Composio holds no configs for never had a connection begun through it. + * Listing accounts anyway could only turn up somebody else's, and the one thing this call does + * with an account it turns up is delete it. * * THE ASSERTION IS THE DOUBLE. `connectedAccounts.list` is left at {@link fakeVendor}'s refusal, * so an implementation that asked the question at all fails here by name — which is a stronger @@ -1529,16 +1765,53 @@ describe("withdrawing one person's grants", () => { * answer `false` too while putting a filter on the wire that the far side is free to read as no * filter at all. */ - test("a person's accounts are not listed where this deployment holds no config of its own", async () => { + test("a person's accounts are not listed where Composio holds no config for the app", async () => { + const { broker } = buildComposioClient( + fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + false, + ); + }); + + /** + * AND "NONE OF OURS" IS NOT THAT STATE — THE SEVENTH ROUTE TO A REVOCATION THAT DID NOT REVOKE. + * + * THIS TEST ASSERTED THE `false`, AND THE `false` WAS THE DEFECT. It stood on the reasoning above + * — none of ours means nothing was ever granted through this app — which is sound about an app + * Composio holds no configs for and cannot tell that app from this one. An operator renames a + * config in Composio's dashboard, dropping the suffix or editing the app's title past it, and + * this deployment's own live grants read as somebody else's work: `store.ts` then writes + * `vendorRevocationRequested: false` into the audit trail and deletes the `composio_connections` + * row, so the person's grant stands at the provider with nothing naming it and the trail records + * that no withdrawal was even asked for. The assertion is changed on purpose, and the case it + * used to cover — Composio holding nothing at all — is asserted next door, where it is true. + * + * `deleteAuthConfig` HAS REFUSED IN EXACTLY THIS STATE SINCE THE WAVE BEFORE THIS ONE, which is + * what makes this a disagreement rather than a judgement call: two halves of removing an app's + * access read one condition two different ways, one function apart. + * + * THE DOUBLE IS STILL THE OTHER HALF OF THE ASSERTION. `connectedAccounts.list` is left refusing, + * so an implementation that answered this by listing somebody else's accounts — and then deleting + * what it found — fails here by name. + */ + test("an app whose configs no longer carry this deployment's name is not a quiet disconnection", async () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { list: async () => ({ items: [BY_HAND_GMAIL] }) }, }), ); - expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( - false, + const refusal = await failureOf( + broker.revoke({ userId: "user_1", toolkit: "gmail" }), ); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + // The count standing and the marker that would have claimed it, which is the same pair + // `deleteAuthConfig` hands an operator for the same state and the same one act in a dashboard. + expect(refusal.message).toMatch(/Composio holds 1 for gmail/); + expect(refusal.message).toMatch(/\(OpenBot\)/); }); /** @@ -3051,6 +3324,91 @@ describe("a listing that arrived with a cursor still outstanding", () => { expect(refusal.message).toMatch(/where the cursor to the next page/); }); + /** + * A CURSOR WITH NOTHING IN IT IS THE END OF THE LISTING, NOT A REFUSAL — AND THE VENDOR'S OWN + * TYPES PERMIT IT. + * + * All four list responses in the installed client declare `next_cursor?: string | null` + * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, + * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal + * on the wire; both transformers write `response.next_cursor ?? null`, which does not catch it; + * and it therefore reached the guard and became a hard refusal. What that refusal takes down is + * not one call: `revoke`, `authorize`, `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` + * all read one of these two listings, so every app in the deployment stops working at once, for + * as long as the vendor sends it — over a field whose whole content is that it has none. + * + * AND THERE IS NO SECOND REQUEST IT COULD HAVE MEANT. An empty cursor is exactly what this loop + * sends when it has no position: the first request omits the field. Following it asks for page + * one again, which is why the alternative reading ends in the loop guard next door accusing + * Composio of answering the same page twice — a complaint about the vendor for something this + * deployment did. + * + * THE ASSERTION IS ON WHAT WENT OUT AS WELL AS ON WHAT CAME BACK. An implementation that read the + * empty cursor as the end and ALSO sent a second request would answer identically here without + * the call count, and it is the second request that is the defect. + */ + for (const { shape, cursor } of [ + { shape: "an empty string", cursor: "" }, + { shape: "a string of blank space", cursor: " " }, + ]) { + test(`a cursor Composio sent as ${shape} ends the listing rather than refusing it`, async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: ourGmailConfig }, + connectedAccounts: { + list: async () => { + calls += 1; + return { items: [{ id: "ca_1" }], nextCursor: cursor }; + }, + delete: async (id: string) => { + deleted.push(id); + return WITHDRAWN; + }, + }, + }), + ); + + expect(await broker.revoke({ userId: "user_1", toolkit: "gmail" })).toBe( + true, + ); + expect(deleted).toEqual(["ca_1"]); + expect(calls).toBe(1); + }); + } + + /** + * AND THE SAME ON THE CONFIG LISTING, WHICH IS THE HALF THIS FILE KEEPS FORGETTING. + * + * One cursor guard serves both listings, so a fix written against the accounts path is a fix + * everywhere — and a test written only against the accounts path is a test that cannot tell the + * difference. Removing an app reads this listing and nothing else, so an empty cursor refused + * here is an app nobody can withdraw. + */ + test("an empty cursor ends the config listing too, so the app can still be removed", async () => { + let calls = 0; + const deleted: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + calls += 1; + return { items: [OURS], nextCursor: "" }; + }, + delete: async (id: string) => { + deleted.push(id); + }, + }, + }), + ); + + await broker.deleteAuthConfig("linear"); + + expect(deleted).toEqual(["ac_ours"]); + expect(calls).toBe(1); + }); + test("a cursor that never advances is refused rather than followed for ever", async () => { let calls = 0; const deleted: string[] = []; @@ -3394,6 +3752,159 @@ describe("what a malformed field of a row actually costs", () => { expect(refusal.message).toMatch(/tools already held are untouched/); }); + /** + * A CATEGORY THAT IS NOT A CATEGORY IS DESCRIBED AS WHAT ARRIVED, NOT AS A MISSING NAME. + * + * The non-object guard here was deleted on the stated ground that the SDK dereferences a category + * and dies before this file sees one. Running `@composio/core` 0.18.1 says otherwise: `("crm").id` + * is `undefined` and not a throw, so a string, a number or a boolean in that list survives + * `transformToolkitListResponse` — only null and undefined die there. What the refusal then said + * was "Composio sent nothing where the name of gmail's category 1 belongs", about a value that + * was the string "crm", which sends an operator looking in a dashboard for a category with a + * missing name. There is no such category. + * + * THE ASSERTION IS ON THE TWO SENTENCES BEING DIFFERENT, because a test that only required a + * refusal was green over the whole defect: the catalogue was refused either way, and what was + * wrong was what the operator was told. + */ + for (const { fault, entry, names } of [ + { fault: "a string", entry: "crm", names: /a string/ }, + { fault: "a number", entry: 7, names: /a number/ }, + ]) { + test(`a category that arrived as ${fault} is named as one rather than as a missing name`, async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + get: async () => [ + { slug: "gmail", name: "Gmail", meta: { categories: [entry] } }, + ], + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/gmail's category 1/); + expect(refusal.message).toMatch(names); + // And NOT the sentence for a category object whose name Composio omitted, which is the other + // fault and the other thing to go looking at. + expect(refusal.message).not.toMatch(/Composio sent nothing/); + }); + } + + /** + * A BLANK IDENTIFIER IS DESCRIBED AS BLANK RATHER THAN AS "A STRING". + * + * {@link textOf} decides emptiness on the TRIMMED value and `sent` tested `value === ""`, so the + * two disagreed about exactly one shape — the padded blank, which is the one a wire value + * actually arrives in. A config id of three spaces was refused for being empty and then described + * as "a string", which is a sentence with no finding in it: a string is what an id IS, so the + * reader is told the field was right and the call refused anyway. + */ + test("a config id that is nothing but spaces is not reported as a string", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ + items: [{ id: " ", name: "Linear (OpenBot)", status: "ENABLED" }], + }), + }, + }), + ); + + const refusal = await failureOf(broker.deleteAuthConfig("linear")); + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect((refusal.cause as Error).message).toMatch(/blank space/); + expect((refusal.cause as Error).message).not.toMatch( + /Composio sent a string where/, + ); + }); + + /** + * THE THREE ACTION FIELDS THIS FILE HANDS ON, AND WHAT EACH BECOMES WHERE IT LANDS. + * + * The four deleted guards next door were deleted correctly — `ToolSchema.parse` really does raise + * a `ZodError` for them — and the argument was then taken one step too far, past the fields + * {@link ComposioAction} promises to `./composio` and `./store`. The parse is a fact about + * `getRawComposioTools` in one version of one package; the declaration is what the seam a test + * satisfies with a literal, and what the next version is read against, actually rest on. + * + * WHAT THAT COSTS IS NOT A REFUSAL, WHICH IS WHY IT BELONGS HERE. `storableTools` writes + * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")`, so a + * description or a version that is not a string is a bare "42.replaceAll is not a function" + * thrown from outside every vendor `try` in the adapter — a crash where this file's whole + * contract is a sentence. An `inputParameters` that is not an object is quieter: it is stored as + * the action's input schema and shown to a model as Composio's own. + * + * EACH ASSERTS THE FIELD IT IS ABOUT, because a table of refusals that only required a refusal + * would pass with one guard standing in for three. + */ + for (const { fault, row, names } of [ + { + fault: "a description that is not text", + row: { slug: "GMAIL_FETCH_EMAILS", description: 42 }, + names: /description/, + }, + { + fault: "an input schema that is not an object", + row: { slug: "GMAIL_FETCH_EMAILS", inputParameters: "not-a-schema" }, + names: /input schema/, + }, + { + fault: "a version that is not text", + row: { slug: "GMAIL_FETCH_EMAILS", version: 20_260_903 }, + names: /version/, + }, + ]) { + test(`an action with ${fault} stops the listing rather than crossing the seam`, async () => { + const { actions } = buildComposioClient( + fakeVendor({ tools: { getRawComposioTools: async () => [row] } }), + ); + + const failure = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(failure.message).not.toMatch(A_CRASH); + expect(failure.message).toMatch(names); + // The tools already recorded for the app are untouched, which is what makes refusing the + // right answer rather than a worse outage than the one being avoided. + expect(failure.message).toMatch(/tools already held are untouched/); + }); + } + + /** + * AND AN ACTION THAT PUBLISHES NONE OF THEM IS ORDINARY. Composio genuinely ships actions with no + * description and no version, and one with no parameters at all arrives with none — the SDK + * normalizes `{}` to absent before parsing. Absence is a fact about the action; present-and-wrong + * is a fact about the answer. A guard that could not tell them apart would refuse most of the + * catalogue. + */ + test("an action that publishes no description, schema or version is still listed", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + getRawComposioTools: async () => [{ slug: "GMAIL_FETCH_EMAILS" }], + }, + }), + ); + + expect( + await actions.listActions("gmail", { limit: WHOLE_LISTING }), + ).toEqual([ + { + slug: "GMAIL_FETCH_EMAILS", + description: undefined, + inputParameters: undefined, + tags: undefined, + version: undefined, + }, + ]); + }); + test("a nameless config stops the removal rather than being left standing", async () => { const deleted: unknown[] = []; const { broker } = buildComposioClient( @@ -3418,9 +3929,22 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf(broker.deleteAuthConfig("linear")); expect(refusal).toBeInstanceOf(BrokerRefusalError); expect(refusal.message).toMatch(/name/); - // Not even the row that WAS readable: a listing this deployment cannot sort is not a listing to - // act on half of, because a half-done removal reported as done is the state being avoided. - expect(deleted).toEqual([]); + /* + * AND THE CONFIG THAT WAS READABLE IS GONE, WHICH THIS ASSERTED THE OPPOSITE OF ON PURPOSE AND + * IS CHANGED ON PURPOSE. It required `[]` — not even the row that WAS readable — on the ground + * that a half-done removal reported as done is the state being avoided. The first half of that + * is right and the second half does not describe this: the call still refuses, so `removeServer` + * never deletes the app's row and nothing is reported as done. What the old shape actually + * bought was a permanent block. The unreadable row is unreadable on every retry, so the app + * could never be removed at all while a config of ours held live grants the whole time — which + * is the defect the accounts path was corrected for one wave earlier, sitting one function + * away. Every config this deployment CAN name goes, and the row it cannot is what the refusal + * counts. + */ + expect(deleted).toEqual([["ac_ours", { revoke_on_delete: true }]]); + // And the remedy for the row that is left is the dashboard rather than the button just pressed, + // because the next press meets exactly the same unreadable row. + expect(refusal.message).toMatch(/Composio's own dashboard/); }); test("a config with no id stops the removal, and no delete is sent", async () => { From 62f7331be50e1d3f126280a4f5a6327cb358e420 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 12 Sep 2026 19:59:25 +0200 Subject: [PATCH 153/215] Follow Composio's cursor on both listings, rather than refuse a page the catalogue always fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app picker refused on every call. `fetchDirectory` asked for 1000 apps, met 1000, and threw — because a full page and a truncated one are the same array and it believed no second request could tell them apart. Composio publishes 1540 toolkits, so the condition was true every time and an operator saw no apps at all. `./composio` made the same refusal of a full action listing, one file over, for the same stated reason. The reasoning was sound and the premise was about the WRAPPER. `@composio/client` carries `cursor` on both list params and `next_cursor` on both responses; what has no cursor is `ToolListParamsSchema`, and what drops one is `transformToolkitListResponse`. Both listings now read the raw client — which this file already does for the two deletes — and go through `everyRowOf` beside the auth-config and connected-account listings, with the guards that already carried: a cursor that cannot be followed, a cursor that does not advance, and a page ceiling, each refusing with an authored sentence rather than handing over what it had. The ceiling moves from 50 pages to 200. Fifty was argued from two narrow listings where a second page is extraordinary; the catalogue's second page is the ordinary case, and 50 pages of the vendor's own default page of twenty is 1000 rows — today's catalogue exactly, a ceiling sitting on top of a healthy answer rather than above it. What the wrapper was doing is kept: `toolkit_versions` is passed as the literal its default is, because it decides the version a later call sends back, and `important` is named nowhere, because the wrapper set it whenever a toolkit query gave no limit and narrowed the answer silently. What it was doing that was a loss is gone: `ToolSchema.parse` stripped every schema key it did not name, which `ComposioAction.inputParameters` recorded as unavoidable and named this exact call as the fix for. And what the wrapper was doing for free now has to be done here. Its transformers dereferenced every answer, so a malformed envelope died inside the vendor's package; a generated client parses the body and returns it. So `pageOf` refuses an answer that is not a page and an `items` that is not a list of rows, `appOf` refuses a row and a `meta` that are not objects and a `categories` that is not a list, and `actionOf` refuses a row that is not an object — every one of them a shape that would otherwise have read as an app that publishes nothing. Two assertions were changed deliberately. The adapter's "a full page is refused rather than held for ten minutes" and the transport's "a listing that filled the biggest page the SDK can ask for is not called complete" both pinned the refusal; each is inverted at the same boundary rather than deleted, and says in the test why. Verified read-only against the live vendor: 1540 apps over two pages, all slugs distinct, Gmail among them with its description, logo, category and count of 62; Gmail's own listing answers 64 actions with their versions, tags and schemas. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 789 ++++++++++++++++-------- server/src/plugins/composio.ts | 142 +++-- server/tests/composio-adapter.test.ts | 610 ++++++++++++++---- server/tests/composio-transport.test.ts | 57 +- 4 files changed, 1118 insertions(+), 480 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index f02ee7d97..edd164d09 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -30,15 +30,22 @@ import { * anywhere in this file is a defect, not an optimisation, and it will not look like one: the * session API is the shortest path to most of what this file does the long way. * - * EVERY LISTING PASSES AN EXPLICIT LIMIT. Composio's default page is 20, which is smaller than the - * number of actions Gmail alone publishes, and for a tool listing the default does a second thing - * as well: `getRawComposioTools` sets `important=true` whenever a toolkit query arrived with no - * limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so an - * omitted limit silently narrows the answer to the vendor's own "important" subset and nothing in - * the result says a filter was applied. A limit is therefore not tuning; it is the difference - * between the list and a fragment of it that reads exactly like the list. {@link LISTING_LIMIT} is - * the documented ceiling, and `./composio` explains why one page at the ceiling is the largest - * listing this SDK can express at all. + * EVERY LISTING PASSES AN EXPLICIT LIMIT AND EVERY LISTING IS READ TO THE END OF ITS CURSOR. + * Composio's default page is 20, which is smaller than the number of actions Gmail alone publishes, + * and through the SDK's tool wrapper the default did a second thing as well: `getRawComposioTools` + * set `important=true` whenever a toolkit query arrived with no limit, no tags and no search + * (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so an omitted limit silently narrowed + * the answer to the vendor's own "important" subset and nothing in the result said a filter had + * been applied. {@link LISTING_LIMIT} is the documented page ceiling and therefore the fewest round + * trips a listing can be read in. + * + * AND THE LIMIT IS A PAGE RATHER THAN THE ANSWER, WHICH IS WHAT CHANGED. Two of the four listings + * here used to meet a full page and refuse it, because the SDK wrapper around them could not ask + * for a second — one takes no cursor, the other drops the response's. That was never true of the + * vendor: both raw endpoints carry `cursor` and `next_cursor`, and Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so the catalogue refusal fired on every call and the app picker + * showed an operator nothing at all. All four now go through {@link everyRowOf}, which follows the + * cursor until the vendor stops offering one and refuses rather than truncates when it cannot. * * THE API KEY NEVER LEAVES THIS FILE. It arrives as {@link createComposioClient}'s only argument, * goes straight into the vendor's constructor, and is held from there on by the vendor's client @@ -49,32 +56,28 @@ import { */ /** - * One tool as the vendor hands it over, in as much detail as anything here reads. + * One tool as the SDK's SINGLE-TOOL call hands it over, in as much detail as anything here reads. * * Declared structurally rather than imported as `Tool`, for the same reason the seams it feeds are * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for * what is done when it is in fact missing. * - * THIS IS THE ONE ROW THE SDK ACTUALLY VALIDATES. `Tools.transformToolCases` ends in - * `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing parse - * rather than the warn-only `transform()` every other listing here goes through — and both calls - * this file makes run through it (`:561` for the listing, `:719` for the single tool). So a tool - * whose `slug` is missing, or whose `toolkit` is present without a `slug`, does not arrive as a - * malformed row: it arrives as a `ZodError`. `ToolkitSchema` spells that inner `slug` required - * (`src/types/tool.types.ts:12-16`), so `toolkit?: { slug: string }` is a guarantee this file may - * rest on. - * - * AND THE THREE FIELDS THIS FILE HANDS ON ARE STILL `unknown`, WHICH IS WHERE THAT ARGUMENT STOPPED - * BEING TRUE. It was taken one step too far: the parse is a fact about `getRawComposioTools` in one - * version of one package, and what these declarations govern is {@link ComposioVendor} — the seam a - * test satisfies with a literal and the shape the next version will be read against. Running it - * shows the gap is not academic: a `description` of 42 crosses into `./composio` as the `string` - * this said it was and reaches `.replaceAll` in `./store` as a bare `TypeError`. A declaration is - * an assertion and not a check, so the three values {@link actionOf} carries across are declared - * at what the wire can hold and checked there. `slug` stays narrow because `ToolSchema` requires it - * AND {@link actionOf} checks it anyway; `tags` stays narrow because `./composio` refuses a - * non-list where it reads them. + * THIS IS THE ONE ROW THE SDK STILL VALIDATES, AND IT IS NO LONGER TWO. `Tools.transformToolCases` + * ends in `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing + * parse rather than the warn-only `transform()` every other answer here goes through, and + * `getRawComposioToolBySlug` runs it (`:719`). The LISTING used to as well (`:561`) and does not + * any more: it reads {@link VendorToolRow} off the raw client, because the wrapper that ran the + * parse is also the wrapper that could not be paged. So `ToolkitSchema` spelling that inner `slug` + * required (`src/types/tool.types.ts:12-16`) is a guarantee this declaration may still rest on, + * and it is a guarantee about exactly one call. + * + * WHAT THIS FILE HANDS ON IS STILL `unknown`, WHICH IS WHERE THAT ARGUMENT ALWAYS STOPPED BEING + * TRUE. A parse is a fact about one method of one version of one package, and what these + * declarations govern is {@link ComposioVendor} — the seam a test satisfies with a literal and the + * shape the next version will be read against. Running it shows the gap is not academic: a + * `description` of 42 crosses into `./composio` as the `string` this said it was and reaches + * `.replaceAll` in `./store` as a bare `TypeError`. A declaration is an assertion and not a check. */ type VendorTool = { slug: string; @@ -86,69 +89,72 @@ type VendorTool = { }; /** - * One catalogue row as the vendor hands it over. + * One tool as the LISTING hands it over, which is the wire's own spelling and nobody's parse. + * + * SNAKE_CASE BECAUSE THIS IS COMPOSIO'S ANSWER RATHER THAN THE SDK'S RESTATEMENT OF IT. The listing + * reads `client.tools.list` directly — see {@link ComposioVendor} for why it has to — so nothing + * renames `input_parameters` on the way here and nothing runs `ToolSchema` over it. Both halves of + * that are deliberate. The rename was never a service: `transformToolCases` re-spelled the field + * and `ToolSchema.parse` then STRIPPED every schema key its `ParametersSchema` did not name — + * `if`, `then`, `else`, `examples`, every `x-` extension at the root, and `deprecated` and + * `contentEncoding` per property (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-174`) — + * before any caller could see them. What a model is shown is now what Composio published; see + * {@link ComposioAction.inputParameters}, where that loss was written down as unavoidable. + * + * AND EVERY FIELD IS DECLARED AT WHAT THE WIRE CAN HOLD, because with the parse gone there is + * nothing between Composio and {@link actionOf} at all. `slug` widens for exactly that reason: + * `ToolSchema` required it and nothing does now, and `actionOf` was already checking it anyway. + * `tags` stays narrow on the same argument it always stood on, which never involved the parse — + * `./composio` refuses a `tags` that is not a list of labels where it reads them, container and + * contents both, and a check on both sides of one seam is a check nobody maintains. + */ +type VendorToolRow = { + slug?: unknown; + description?: unknown; + input_parameters?: unknown; + tags?: string[]; + version?: unknown; +}; + +/** + * One catalogue row as the vendor hands it over, which is the wire's own spelling and nobody's map. * * `meta` is where all of it lives and every field of it is optional, which is not the SDK being * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. * See {@link ComposioBroker.listApps} below for what each absence becomes. * - * WIDER THAN THE SDK'S OWN SCHEMA, BECAUSE THE SCHEMA IS NOT ENFORCED ON THIS PATH. `transform()` - * validates with `safeParse` and, where that fails, logs a warning and `return transformed` — the - * unvalidated object — anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`). So - * `ToolKitItemSchema` spelling `name` required and `toolsCount` a number describes the answer - * Composio means to send rather than the one this file has to be able to read: a row whose `name` - * is null, or whose count arrives as a string, reaches the map below exactly as it came off the - * wire and the only thing that noticed was a log line. A TypeScript interface over a wire value is - * an assertion and not a check, so the least this one can do is assert something true. - * - * `meta` ITSELF IS NOT WIDENED, AND THAT IS A FINDING RATHER THAN AN OVERSIGHT. - * `transformToolkitListResponse` builds each row's meta itself and reads `item.meta.categories` - * while doing so (`src/utils/transformers/toolkits.ts:21-34`), so a row that carries no meta never - * arrives here at all — the read throws and `Toolkits.getToolkits` rethrows it as - * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:76-82`). Every row that does arrive carries - * a meta object. Its `categories` are objects for the same reason, the transformer constructing - * each one; the values inside them are copied across verbatim and are therefore worth exactly what - * the wire is worth. - * - * `description` AND `toolsCount` ARE `unknown` RATHER THAN A WIDER UNION, because the wire can put - * anything in them and a union would be another guess. `unknown` is the type that forces the - * reader to say what it does with a value it has not checked, which is the whole point. - * - * `slug` IS AS WIDE AS `name`, AND FOR THE SAME REASON. `ToolKitItemSchema` spells it required and - * the warn-only `transform()` above copies it across whatever it turns out to be, exactly as it does - * the name. A slug is the only name this deployment has for an app — it is what enabling one records - * and what every later call names — so the one it must not quietly become is `undefined` read as a - * string. {@link appOf} refuses that. - * - * EVERY WIRE-VALUED FIELD IS `unknown` AND EVERY STRUCTURAL ONE IS NOT, which is the split the whole - * file now turns on. `meta` and its `categories` array are built by - * `transformToolkitListResponse` itself (`src/utils/transformers/toolkits.ts:21-34`) — it reads - * `item.meta.categories` and maps each category into `{ slug, name }` — so a row with no meta and a - * `categories` that is not a list both fail INSIDE that function and reach nothing here. Those two - * are guarantees, and they were verified rather than reasoned about: a `meta` of null and a - * `categories` of "crm" each raise a `TypeError` from that line when 0.18.1 is actually run. - * - * AND THE THIRD ONE WAS NOT, WHICH IS WHY THE ENTRY IS `unknown`. This said that "a category that - * is not an object" fails there too, and running it says otherwise: `category.id` on the string - * "crm" is `undefined` and not a throw, so a string, a number or a boolean in that list survives - * the map — as `{ slug: undefined, name: undefined }`, which reaches {@link appOf} wearing the - * shape of a category whose name Composio omitted. Only null and undefined die there. The claim was - * load-bearing for a guard that was deleted on the strength of it, so the entry is declared at what - * the vendor's own dereference actually promises, which is nothing. - * - * What the same lines copy across verbatim — the name, the slug, the description, the logo, each - * category's name and the count — is worth exactly what the wire is worth, so all of it is - * `unknown` or nullable and {@link appOf} says what it does with each. + * SNAKE_CASE AND `unknown` THROUGHOUT, BECAUSE THE TRANSFORMER THIS WAS WRITTEN AGAINST IS GONE. + * The catalogue reads `client.toolkits.list` directly — see {@link ComposioVendor} for why it has + * to — so `transformToolkitListResponse` no longer stands between Composio and {@link appOf}, and + * three things it was doing have to be accounted for rather than assumed. + * + * IT RENAMED, so the count is `tools_count` here and the category's own word is `name` + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-435`). That rename was the one + * thing in this projection easiest to get silently wrong — a count read off the wrong key is a + * plausible zero rather than an error — which is why {@link appOf} is where it is read and why a + * test asserts the figure rather than the field. + * + * IT REBUILT `meta` AND THE `categories` LIST, spreading each into a fresh literal and mapping + * every entry (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:21-34`). Those were + * this file's two structural guarantees and they were real: a `meta` of null and a `categories` of + * "crm" each raised a `TypeError` from that line when 0.18.1 was run, which is why neither was + * declared `unknown` and neither was checked. Nothing raises now. A `meta` that is a string reads + * as an app with no description, no logo, no categories and no count, and a `categories` that is a + * string reads as an app in no category — two silent, plausible answers about a real app. Both are + * declared at what the wire can hold and both are refused in {@link appOf}. + * + * AND IT NEVER VALIDATED, which is the part that does not change. `transform()` checks with + * `safeParse` and, where that fails, logs a warning and returns the unvalidated object anyway + * (`src/utils/transform.ts:26-36`), so `ToolKitItemSchema` spelling `name` required and the count a + * number always described the answer Composio MEANS to send rather than the one that arrived. Every + * wire-valued field was already `unknown` on that argument and stays so: the slug, which is the + * only name this deployment has for an app; the name, which is the only thing to show a person + * choosing between apps; the description, the logo, each category's word, and the count. */ type VendorToolkit = { slug?: unknown; name?: unknown; - meta: { - description?: unknown; - logo?: unknown; - categories?: unknown[]; - toolsCount?: unknown; - }; + meta?: unknown; }; /** @@ -326,17 +332,23 @@ function named(value: unknown): string { * settles rather than reasons about. Every list transformer dereferences the answer before * returning it: `response.items.map(...)` in `transformAuthConfigListResponse` * (`src/utils/transformers/authConfigs.ts:79`), in `transformConnectedAccountListResponse` - * (`connectedAccounts.ts:113`) and in `getRawComposioTools` (`models/Tools.ts:561`), and - * `item.meta.categories` inside the catalogue's own map (`toolkits.ts:27`). So a container of the - * wrong shape — null, a bare list where an envelope belongs, an `items` that is a string — dies - * inside the vendor's code and NEVER arrives here. Each of those guards was therefore a branch no - * input could reach, sitting where the next reader would take it for the thing keeping them safe. + * (`connectedAccounts.ts:113`). So a container of the wrong shape — null, a bare list where an + * envelope belongs, an `items` that is a string — dies inside the vendor's code and NEVER arrives + * here. Each of those guards was therefore a branch no input could reach, sitting where the next + * reader would take it for the thing keeping them safe. * * WHAT KEEPS THEM SAFE IS ONE LAYER DOWN NOW. The vendor's crash is a bare `TypeError`, and * {@link vendorRefusal} translates it into a sentence naming what did not happen and the one act * that changes it — which catches every malformed container, including the shapes nobody here * thought to enumerate. * + * AND IT IS TWO OF THE FOUR LISTINGS NOW RATHER THAN ALL OF THEM, WHICH IS THE COST OF PAGING THE + * OTHER TWO. The catalogue and the action listing read `@composio/client` directly — a generated + * client that parses the body and returns it — so nothing dereferences their answers before this + * file does. The same two shapes therefore reach this code rather than dying in the vendor's, and + * {@link pageOf} answers them at exactly those two call sites. The argument above still holds + * everywhere it is made: a guard is written where an input can reach it and nowhere else. + * * SO THE DECLARATIONS BELOW ARE READ AT THEIR TYPES, and each one says which vendor line makes it * true. The fields inside them stay `unknown`, because the warn-only `transform()` really does copy * those across whatever they turn out to be. @@ -369,24 +381,45 @@ function textOf(value: unknown): string | null { /** * How many pages of one listing this deployment will read before it stops and says so. * - * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. Both listings paged - * below are narrow — one app's authorization configs, or one person's accounts for one app — at - * {@link LISTING_LIMIT} rows a page, so a second page is already extraordinary and a fiftieth is - * not a data set. What it is is a cursor that keeps being handed back, which without a ceiling is - * a request that never returns: a person waiting on a page they pressed disconnect from, and a - * process holding every row it has read so far. + * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. What it guards is a + * cursor that keeps being handed back, which without a ceiling is a request that never returns: a + * person waiting on a page they pressed disconnect from, and a process holding every row it has + * read so far. * * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling * again, one order of magnitude further out and harder to notice. * + * IT WAS 50, AND 50 WAS ARGUED FROM TWO NARROW LISTINGS THAT ARE NO LONGER THE ONLY ONES. The + * number was justified by one app's authorization configs and one person's accounts for one app: + * at {@link LISTING_LIMIT} rows a page a second page is extraordinary there and a fiftieth is not + * a data set. The app CATALOGUE is not that listing. Composio publishes more than + * {@link LISTING_LIMIT} toolkits today, so it is a listing whose SECOND page is the ordinary case, + * and a ceiling reasoned about from the narrow two would be sitting on top of a healthy answer + * rather than above it. + * + * 200 IS CHOSEN AGAINST THE PAGE THE VENDOR MIGHT ACTUALLY SEND rather than the one asked for, and + * that is the whole of the arithmetic. Asking for {@link LISTING_LIMIT} does not oblige Composio to + * answer with it — the cursor is documented as "a base64 encoded string of the page and limit" + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:469-478`), so the page size is the + * vendor's to settle. At the page asked for, 200 is 200,000 rows, two orders of magnitude past any + * catalogue Composio has published. At the vendor's OWN default page of twenty it is 4,000 rows, + * which still clears today's catalogue with room — where 50 pages of twenty is 1,000, which is + * today's catalogue exactly, and a ceiling that lands on the real answer is a healthy vendor turned + * into a refusal. And 200 sequential requests is still a request that ends. + * + * ONE NUMBER FOR ALL FOUR LISTINGS, because the two narrow ones lose nothing by it: they refuse a + * runaway cursor after 200 pages instead of 50, and there is no state in which a real answer to + * either of those questions is even a second page. A per-listing ceiling would be a second number + * to reason about in exchange for tightening a bound that nothing genuine approaches. + * * AND IT IS THE NUMBER OF PAGES THAT ARE READ, WHICH IS NOT WHAT IT USED TO BE. The test stood * ahead of the line recording the page it was counting, so the set held one fewer than had - * arrived and the refusal fired on the fifty-FIRST page while telling its reader fifty. A ceiling - * is a number somebody reasons about; stating one and doing another makes it the one number here - * nobody can check. + * arrived and the refusal fired on the two-hundred-FIRST page while telling its reader two hundred. + * A ceiling is a number somebody reasons about; stating one and doing another makes it the one + * number here nobody can check. */ -const PAGE_CEILING = 50; +const PAGE_CEILING = 200; /** * The listing a refusal is about, as the two clauses every sentence below is built from. @@ -405,12 +438,21 @@ type Listing = { /** * EVERY ROW OF A LISTING THE VENDOR PAGES, or a refusal rather than a fragment read as the whole. * - * WHY THIS PAGES WHERE THE CATALOGUE REFUSES, which is the one decision worth writing down here. - * `fetchDirectory` below meets a full page and refuses, and `./composio` does the same with a full - * action listing, and both say why in the same words: the SDK offers no cursor to ask for a second - * page with, so an answer at the ceiling and an answer past it are indistinguishable and no second - * request could tell them apart. That is a refusal born of an inexpressible request rather than a - * house style. Here the request IS expressible: `AuthConfigListParamsSchema` and + * EVERY LISTING IN THIS FILE COMES THROUGH HERE NOW, AND TWO OF THEM USED TO REFUSE INSTEAD. This + * paragraph said that `fetchDirectory` met a full page and refused, that `./composio` did the same + * with a full action listing, and that both were right to: "the SDK offers no cursor to ask for a + * second page with, so an answer at the ceiling and an answer past it are indistinguishable and no + * second request could tell them apart". The reasoning was sound and the premise was wrong. It was + * a fact about the WRAPPER — `ToolListParamsSchema` names no cursor and + * `transformToolkitListResponse` drops the response's — and never about the request, which the raw + * client has always been able to compose: both list params carry `cursor` and both responses carry + * `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:467-478` and + * `:322-326`, `resources/tools.d.ts:421-432` and `:200-204`). The cost of the mistake was not + * theoretical: Composio publishes more than {@link LISTING_LIMIT} toolkits, so the catalogue + * refusal fired on the first call every time and the app picker showed an operator nothing at all. + * See {@link ComposioVendor}, where both of those listings now name the raw client. + * + * THE OTHER TWO WERE ALWAYS EXPRESSIBLE THROUGH THE WRAPPER: `AuthConfigListParamsSchema` and * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, * `src/types/authConfigs.types.ts:124-131`, `src/types/connectedAccounts.types.ts:259-266`), both * models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`) and @@ -430,15 +472,19 @@ type Listing = { * as a key, and an explicit undefined is not something this file needs to make the SDK have an * opinion about. * - * THERE WAS A FOURTH REFUSAL AND IT IS GONE, which is worth saying because it was the one that read - * as the most important. It stood at the top of the loop for a page that is not an envelope at all, - * and no answer could reach it: both transformers begin `response.items.map(...)` + * THERE WAS A FOURTH REFUSAL HERE AND IT MOVED RATHER THAN DIED, which is the correction the raw + * client forces. It stood at the top of this loop for a page that is not an envelope at all, and + * for the two SDK listings no answer can reach it: both transformers begin `response.items.map(...)` * (`src/utils/transformers/authConfigs.ts:79`, `connectedAccounts.ts:113`), so a null, a bare list - * and an `items` that is not one all raise a `TypeError` inside the vendor's own code. That is now - * answered where it happens — see the `TypeError` row in {@link vendorRefusal} — which is both a - * sentence the old branch could never have produced and a check that covers shapes nobody here - * enumerated. What is left below are the three faults the vendor CAN hand over, all of them about - * the cursor, because the cursor is the one field these transformers copy off the wire unchecked. + * and an `items` that is not one all raise a `TypeError` inside the vendor's own code, answered + * where it happens — see the `TypeError` row in {@link vendorRefusal}. Nothing dereferences the + * answer on the two RAW listings, so for those the same shapes reach this loop, and + * `rows.push(...answered.items)` over a string is "string is not iterable" with nothing in it a + * person can act on. {@link pageOf} is where that is answered, at the two call sites that need it, + * rather than as a branch every listing pays for and two of them cannot reach. + * + * What is left below are the three faults every one of the four can hand over, all of them about + * the cursor, because the cursor is the one field nothing on any of these paths checks. * * AND A QUESTION ALREADY ANSWERED STOPS HERE, WHICH IS WHAT `enough` IS FOR. Paging made three of * this file's answers complete and made one of them FAILABLE: {@link ComposioBroker.isConnected} @@ -540,6 +586,50 @@ async function everyRowOf( } } +/** + * ONE PAGE OFF THE RAW CLIENT, CHECKED FOR BEING A PAGE, in the shape {@link everyRowOf} reads. + * + * THE TWO SDK LISTINGS DO NOT NEED THIS AND THE TWO RAW ONES CANNOT DO WITHOUT IT. Every answer the + * wrapper hands over has already been dereferenced inside the vendor's package — both transformers + * open with `response.items.map(...)` — so a malformed envelope there is a `TypeError` raised + * inside `@composio/core` and translated by {@link vendorRefusal} with a sentence about a package + * upgrade. `@composio/client` is a generated client: it parses the body and returns it. Nothing + * looks at `items` before this file does. + * + * SO THE TWO SHAPES THE WRAPPER USED TO CATCH ARE CAUGHT HERE, and they are the same two: + * an answer that is not an envelope, and an `items` that is not a list of rows. Both would + * otherwise reach `rows.push(...answered.items)` as a bare `TypeError` naming a vendor field — + * the crash-wearing-a-refusal's-clothes that every sentence in this file exists not to be. + * + * OUTSIDE {@link askVendor} RATHER THAN INSIDE IT, deliberately, and it is the reason this is a + * function rather than four lines at each call site. That wrapper goes around the `await vendor.*` + * AND NOTHING ELSE, which is what makes "a throw reaching `vendorRefusal` came out of the vendor's + * code" true by construction; a refusal composed here is this file's own reading, and it already + * carries an authored sentence. + * + * THE CURSOR IS RENAMED AND NOT READ. `next_cursor` is the wire's spelling and `nextCursor` is what + * {@link everyRowOf} looks for, and it is carried across as `unknown` — every check on it belongs + * there, where the three faults it can carry are enumerated and answered together for all four + * listings. + */ +async function pageOf( + listing: Listing, + ask: () => Promise<{ items?: unknown; next_cursor?: unknown } | null>, +): Promise<{ items: Row[]; nextCursor?: unknown }> { + const answered = await ask(); + if (answered === null || typeof answered !== "object") { + throw new BrokerRefusalError( + `Composio sent ${sent(answered)} where a page of ${listing.noun} belongs, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (!Array.isArray(answered.items)) { + throw new BrokerRefusalError( + `Composio sent ${sent(answered.items)} where the rows of ${listing.noun} belong, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return { items: answered.items as Row[], nextCursor: answered.next_cursor }; +} + /** * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. * @@ -556,18 +646,32 @@ async function everyRowOf( * is not what it is declared to be. A count that arrived as the string "63" is not a count, and * `Number(x)` over it would turn a vendor change into a plausible figure nobody would question. * - * WHAT IS NOT CHECKED IS THE ROW'S SHAPE, AND THAT IS THE SDK RATHER THAN AN OVERSIGHT. Two + * THE ROW'S SHAPE IS CHECKED AGAIN, AND THAT IS THE RAW CLIENT RATHER THAN A CHANGE OF MIND. Two * refusals used to open this function — one for a row that is not an object, one for a row with no - * meta — and neither could be reached: `transformToolkitListResponse` reads `item.meta.categories` - * while building each row (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:27`), and - * `Toolkits.getToolkits` wraps the whole thing in a try that rethrows everything as - * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`). So a catalogue this deployment - * cannot read never becomes a row here at all; it becomes that vendor class, whose bare "Failed to - * fetch toolkits" and whose key-or-status-page remedy are what the route's default already says. + * meta — and they were deleted because neither could be reached: `transformToolkitListResponse` + * read `item.meta.categories` while building each row (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27`) and `Toolkits.getToolkits` rethrew everything as + * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`), so a catalogue this deployment + * could not read never became a row here at all. The catalogue does not go through that function + * any more — see {@link ComposioVendor} — and both shapes now arrive. + * + * BOTH ARE BACK RATHER THAN LEFT TO THE FIELD READS, and the reason is the category guard three + * screens down, which was deleted on the same reasoning and restored after it was RUN. `("gmail") + * .slug` is `undefined` and not a throw, so a row that is a bare string would be refused with + * "Composio sent nothing where the slug of row 1 of Composio's app catalogue belongs" — a sentence + * that sends an operator looking in a dashboard for an app with a missing slug, about an answer + * that had no app in it. A guard whose absence is argued from a dereference that does not + * dereference is not a guard anything should rest on. */ function appOf(row: VendorToolkit, position: number): BrokerApp { const at = `row ${position + 1} of Composio's app catalogue`; + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} where ${at} belongs, and a catalogue row is an object carrying an app's slug and name. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const slug = textOf(row.slug); if (slug === null) { throw new BrokerRefusalError( @@ -583,19 +687,39 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { } /* - * `meta` IS TAKEN AS AN OBJECT RATHER THAN CHECKED FOR ONE, and the reason is two lines of vendor - * code rather than the one this used to cite. `transformToolkitListResponse` reads - * `item.meta.categories` while it builds this row (`@composio/core` 0.18.1, - * `src/utils/transformers/toolkits.ts:27`), so a meta that is absent or null raises there — and - * that is ALL the read rules out, which is less than was claimed for it: running 0.18.1 with a - * meta of "hello" or of 5 raises nothing, because `.categories` on a primitive is `undefined`. - * What makes every meta arriving here an object is the next line, which spreads it into a fresh - * literal (`:24-38`). That is a guarantee about the transformer and not about the wire, and its - * cost is written down where it lands: a primitive meta arrives as the spread of itself, so the - * app shows no description, no logo, no categories and no count — four absences this function - * treats as an app that published none, because nothing that reaches here can tell them apart. + * `meta` IS CHECKED FOR BEING AN OBJECT, WHICH IT DID NOT USED TO BE AND NOW HAS TO BE. + * + * The argument for taking it on trust was `transformToolkitListResponse`: it read + * `item.meta.categories` while building each row and then spread the result into a fresh literal + * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:24-38`), so a meta that was + * absent or null raised there and every meta that did arrive was an object whatever the wire had + * sent. That was a guarantee about the transformer, it was written down as one, and the catalogue + * no longer goes through it — see {@link ComposioVendor}, where the toolkit listing names the raw + * client because the wrapper could not be paged. + * + * WHAT THE ABSENT GUARD WOULD COST IS FOUR SILENT ABSENCES RATHER THAN A CRASH, which is the + * worse of the two. `meta.description` off the string "productivity" is `undefined`, not a throw, + * and so are the logo, the categories and the count — so a row this file cannot read would show + * on an administrator's screen as a real app that publishes nothing, indistinguishable from the + * many that genuinely publish little. Absent IS an answer here, which is exactly why a meta that + * is present and is not a meta cannot be allowed to look like one. */ - const meta = row.meta; + const rawMeta = row.meta; + if ( + typeof rawMeta !== "object" || + rawMeta === null || + Array.isArray(rawMeta) + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.meta)} where ${slug}'s description, logo, categories and action count belong. Every one of those is a thing an app is allowed to publish none of, so a row whose metadata is not readable at all would show as a real app that publishes nothing rather than as the answer this deployment could not read. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const meta = rawMeta as { + description?: unknown; + logo?: unknown; + categories?: unknown; + tools_count?: unknown; + }; const description = meta.description ?? ""; if (typeof description !== "string") { @@ -612,31 +736,35 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { } /* - * THE LIST IS THE TRANSFORMER'S OWN CONSTRUCTION AND THE ENTRIES IN IT ARE NOT, which is a line - * that was drawn in the wrong place and has been moved back. `item.meta.categories?.map(category - * => ({ slug: category.id, name: category.name }))` is what fills this field - * (`src/utils/transformers/toolkits.ts:27-30`), and a `categories` that is not a list has no - * `.map` and dies there — that half held up when it was run. + * NEITHER THE LIST NOR THE ENTRIES IN IT ARE ANYBODY'S CONSTRUCTION NOW, so both are checked. * - * THE OTHER HALF DID NOT, AND IT IS WHY THE ENTRY GUARD IS BACK. It was deleted on the stated - * ground that "a category that is null throws on `.id`, and everything that survives is an - * object". Only the first clause is true. `("crm").id` is `undefined`, not a throw, and so is - * `(7).id` and `(true).id` — so a primitive in that list survives the map and comes out the other - * side as `{ slug: undefined, name: undefined }`. What that cost was not a crash but a LIE: the - * whole catalogue was refused with "Composio sent nothing where the name of gmail's category 1 - * belongs", about a value that was the string "crm". An operator reading that goes looking in a - * dashboard for a category with a missing name, and there is no such category. + * The list used to be the transformer's: `item.meta.categories?.map(category => ({ slug: + * category.id, name: category.name }))` (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27-30`) has no `.map` for a `categories` that is not a + * list, and that half genuinely held when it was run. It does not hold through the raw client, + * where nothing maps this field at all — a `categories` of "productivity" would simply read as an + * app in no category, which is a state real apps are in. * - * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, and the two faults get the two sentences they - * are. Today's transformer rebuilds every entry, so this branch stands at the seam - * {@link ComposioVendor} declares rather than in front of the live SDK path — which is the right - * place for it, because the rebuild is one version's behaviour and the declaration is what the - * next version will be read against. A guard whose absence was argued from a dereference that - * does not dereference is not a guard anything should rest on again. + * THE ENTRY GUARD WAS ALREADY BACK, AND FOR A REASON WORTH KEEPING IN VIEW. It had been deleted + * on the ground that "a category that is null throws on `.id`, and everything that survives is an + * object". Only the first clause was ever true: `("crm").id` is `undefined`, not a throw, and so + * is `(7).id` — so a primitive survived the map as `{ slug: undefined, name: undefined }`, and + * the whole catalogue was refused with "Composio sent nothing where the name of gmail's category + * 1 belongs" about a value that was the string "crm". An operator reading that goes looking in a + * dashboard for a category with a missing name, and there is no such category. * - * What the transformer copies verbatim is the NAME, which is why that is the one field read. + * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, twice over, and the three faults get the three + * sentences they are. The vendor's own word is `name` on both spellings of this row + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:425-435`), which is why that is + * the one field read. */ - const categories = (meta.categories ?? []).map((entry, index) => { + const listed = meta.categories ?? []; + if (!Array.isArray(listed)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong, and the catalogue shows an app's categories as the words a person chooses by. An app in no category is ordinary; a list of them that is not a list is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const categories = listed.map((entry: unknown, index: number) => { const at = `${slug}'s category ${index + 1}`; if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { throw new BrokerRefusalError( @@ -652,10 +780,19 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { return label; }); - const actionCount = meta.toolsCount ?? 0; + /* + * `tools_count` IS THE WIRE'S OWN KEY AND `toolsCount` WAS THE WRAPPER'S RESTATEMENT OF IT + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-408`; + * `@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:34`). Reading the old key off the + * new answer is the one mistake in this function that would not look like one: every count would + * be `undefined`, every count would default to zero, and every app in the picker would say it + * publishes no actions — a plausible figure on a page whose whole job is to show one. Which is + * why it is a number a test asserts rather than a field a type checks. + */ + const actionCount = meta.tools_count ?? 0; if (typeof actionCount !== "number" || !Number.isFinite(actionCount)) { throw new BrokerRefusalError( - `Composio sent ${sent(meta.toolsCount)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, + `Composio sent ${sent(meta.tools_count)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, ); } @@ -890,23 +1027,25 @@ function withdrawalDeclined( /** * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. * - * THIS IS THE ONE ROW HERE THAT THE VENDOR REALLY DOES VALIDATE, AND THE FILE USED TO CHECK IT - * TWICE. `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the + * THE VENDOR USED TO VALIDATE THIS ROW AND NO LONGER DOES, WHICH IS WHY THE GUARDS ARE ALL HERE. + * `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the * warn-only `transform()` every other listing goes through (`@composio/core` 0.18.1, - * `src/models/Tools.ts:193`) — and both calls this file makes run through it (`:561` for the - * listing, `:719` for the single tool). So a tool whose description is a number, whose - * `inputParameters` is a string of JSON, whose tags are not all labels, whose version is a number, - * or which is not an object at all, does not arrive as a malformed row: it arrives as a `ZodError`, - * which `./composio`'s `isSchemaMismatch` already recognises and answers with the same package - * remedy. Five refusals stood here for those five shapes and not one of them could be reached. - * - * AND FIVE WAS TWO TOO MANY, WHICH IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD IN - * {@link appOf} AND IS CORRECTED THE SAME WAY. "The SDK validates this" is a fact about - * `getRawComposioTools` in one version, and it is not a fact about {@link ComposioVendor}, which is - * the seam this function actually sits on and the shape a test satisfies with a literal. Running it - * settles what the difference costs: a `description` of 42 and an `inputParameters` of - * "not-a-schema" both travel through here untouched into {@link ComposioAction}, whose declared - * types say they cannot. What they reach is not a refusal. `storableTools` writes + * `src/models/Tools.ts:193`) — and the listing used to run through it (`:561`). It does not any + * more: `getRawComposioTools` is the one method of the vendor's tool model that cannot be paged, + * so the listing reads `client.tools.list` and {@link VendorToolRow} is the wire's own shape. Five + * refusals once stood here for five shapes that parse caught — a description that is a number, an + * input schema that is a string of JSON, tags that are not all labels, a version that is a number, + * and a row that is not an object at all — and each was deleted as unreachable. Four of the five + * had already come back, on the argument below. The fifth, the container, comes back with this + * change, because there is nothing at all between Composio and this function now. + * + * THE ARGUMENT THAT BROUGHT THE OTHER FOUR BACK IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD + * IN {@link appOf}, CORRECTED THE SAME WAY. "The SDK validates this" is a fact about one method of + * one version, and it is not a fact about {@link ComposioVendor}, which is the seam this function + * actually sits on and the shape a test satisfies with a literal. Running it settles what the + * difference costs: a `description` of 42 and an input schema of "not-a-schema" both travel through + * here untouched into {@link ComposioAction}, whose declared types say they cannot. What they reach + * is not a refusal. `storableTools` writes * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")` * (`./store`), so a description or a version that is not a string is a bare * "42.replaceAll is not a function" thrown from outside every vendor `try` in this file — a crash @@ -929,12 +1068,25 @@ function withdrawalDeclined( * read on its Plugins page, and `listingSentence` passes an authored message through untouched. */ function actionOf( - row: VendorTool, + row: VendorToolRow, position: number, toolkit: string, ): ComposioAction { const at = `row ${position + 1} of Composio's action list for ${toolkit}`; + /* + * THE CONTAINER, FOR THE REASON {@link appOf}'S ROW GUARD IS BACK ONE FUNCTION UP. `ToolSchema` + * refused a row that is not an object and nothing does now. Read without it, `("GMAIL").slug` is + * `undefined` rather than a throw, so a listing of bare strings would be refused with "Composio + * sent nothing where the slug of row 1 ... belongs" — an administrator sent to look for an action + * with a missing name, about an answer that had no action in it. + */ + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new Error( + `Composio sent ${sent(row)} where ${at} belongs, and an action is an object carrying the name a call to it uses, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const slug = textOf(row.slug); if (slug === null) { throw new Error( @@ -957,7 +1109,13 @@ function actionOf( ); } - const inputParameters = row.inputParameters; + /* + * `input_parameters` IS THE WIRE'S KEY AND `inputParameters` WAS THE WRAPPER'S — see + * {@link VendorToolRow}. Reading the old one off the new answer would put no schema on any + * action, which `./composio` treats as the ordinary case of an action that publishes none: every + * tool would reach a model with an open schema and nothing would report a fault. + */ + const inputParameters = row.input_parameters; if ( inputParameters !== undefined && (typeof inputParameters !== "object" || @@ -1316,10 +1474,56 @@ function everyRefusal(refused: unknown[]): unknown { */ export type ComposioVendor = { tools: { - getRawComposioTools(query: { - toolkits: string[]; + /** + * Every action of one app, one page at a time, THROUGH THE RAW CLIENT RATHER THAN THE WRAPPER. + * + * `getRawComposioTools` was here and could not be paged. `ToolListParamsSchema` names no cursor + * field at all (`@composio/core` 0.18.1, `src/types/tool.types.ts:257-266`) and the method ends + * `tools.items.map(...)` (`src/models/Tools.ts:561`), dropping the response's `next_cursor` + * before any caller sees it — so through the wrapper a page at {@link LISTING_LIMIT} and a + * listing longer than one were the same array, and `./composio` refused rather than commit a + * fragment as the whole truth about an app. The raw client is the difference: `ToolListParams` + * carries `cursor`, "a base64 encoded string of the page and limit", and `ToolListResponse` + * carries `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:421-432` and + * `:200-204`). The request IS expressible, and {@link everyRowOf} makes it. + * + * WHAT THE WRAPPER WAS DOING THAT THIS HAS TO KEEP DOING IS TWO PARAMETERS AND ONE NO-OP. + * `toolkit_versions` is the SDK's own `toolkitVersions` config, which defaults to "latest" + * (`src/utils/config-defaults/ConfigDefaults.node.ts`) and was forwarded on every listing it + * made (`src/models/Tools.ts:548`); it decides which `version` each action comes back with, + * which is the value a later call sends back to Composio, so it is passed here as the literal + * the default is. `important` is the one this file already knew about and is now simply not + * named: the wrapper set it to "true" whenever a toolkit query carried no limit (`:505-515`), + * which NARROWS the answer to a featured subset that nothing in the answer declares. And the + * no-op is `applyDefaultSchemaModifiers`, which returns its argument untouched unless + * `dangerouslyAllowAutoUploadDownloadFiles` is on (`:242-248`); it defaults off + * (`ConfigDefaults.node.ts`) and {@link createComposioClient} does not turn it on — which is + * the premise `./composio`'s file-upload guard is written on. + * + * AND `ToolSchema.parse` IS GONE WITH IT, WHICH IS A GAIN AND A COST, BOTH WRITTEN DOWN + * ELSEWHERE. The gain is that the parse STRIPPED every schema key its `ParametersSchema` did + * not name before any caller could see it — see {@link ComposioAction.inputParameters}, where + * that loss was recorded as unavoidable and where the fix was named as this exact call. The + * cost is that nothing validates the row: {@link actionOf} carries every check now, including + * the container, and {@link pageOf} carries the envelope. + */ + list(query: { + toolkit_slug: string; limit: number; - }): Promise; + toolkit_versions: "latest"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** + * `unknown` BECAUSE NOTHING HAS LOOKED AT IT, which is the difference between this listing + * and the two the SDK still serves. Their transformers open `response.items.map(...)`, so a + * malformed envelope dies inside the vendor's package; a generated client parses the body and + * returns it. {@link pageOf} is where that is answered. + */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; getRawComposioToolBySlug( slug: string, options?: { version?: string }, @@ -1334,7 +1538,45 @@ export type ComposioVendor = { ): Promise; }; toolkits: { - get(query: { limit: number; sortBy: "usage" }): Promise; + /** + * The app catalogue, one page at a time, THROUGH THE RAW CLIENT FOR THE SAME REASON. + * + * AND THIS IS THE ONE THAT WAS BROKEN IN FRONT OF PEOPLE. `Toolkits.getToolkits` does forward a + * `cursor` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:68`) — so the wrapper could ASK + * for a second page — but `transformToolkitListResponse` returns `response.items.map(...)`, a + * bare array (`src/utils/transformers/toolkits.ts:16-43`), so there is no cursor to put in it. + * One half of a pager is not a pager, and `fetchDirectory` refused a full page because it could + * not tell a complete catalogue from a truncated one. Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so that refusal fired on the FIRST call, every time, and an + * operator opening the app picker saw no apps at all. `ToolkitListResponse` carries + * `next_cursor` and `ToolkitListParams` carries `cursor` (`@composio/client` 0.1.0-alpha.76, + * `resources/toolkits.d.ts:322-326`, `:467-478`), so the whole request was expressible the + * entire time. + * + * `sort_by` IS THE WIRE'S SPELLING OF WHAT WAS `sortBy`, and it still matters even though the + * listing is no longer finite. What it buys now is order rather than coverage: the rows reach a + * picker in the order Composio thinks people want them, and the page ceiling in + * {@link PAGE_CEILING} is the one case left where being cut off is possible at all. + * + * NO SEARCH TERM, WHICH IS THE SAME DECISION FOR A DIFFERENT REASON THAN BEFORE. It used to be + * that the SDK's params named no search field and the parse stripped what it did not name, so a + * term passed here would vanish before the request. `ToolkitListParams` DOES name `search` + * (`:480-484`), so that is no longer the argument. The argument is that the catalogue is held + * for ten minutes and searched in this process by both of its callers — the picker and the + * enable route's slug check — and a per-term request would be a per-term cache. Searching over + * the held rows is this deployment's own job and stays so. + */ + list(query: { + limit: number; + sort_by: "usage"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** `unknown` for the reason the action listing's is — see {@link pageOf}. */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; }; authConfigs: { list(query: { @@ -1974,61 +2216,49 @@ export function buildComposioClient( let heldDirectory: { at: number; apps: Promise } | null = null; /** - * The catalogue as the vendor answers it, mapped to the rows an administrator chooses from. + * The catalogue as the vendor answers it, EVERY PAGE OF IT, mapped to the rows a person picks from. + * + * IT USED TO BE ONE PAGE AND A REFUSAL, AND THAT REFUSAL IS THE BUG THIS FUNCTION WAS FIXED FOR. + * It asked for {@link LISTING_LIMIT} rows, met exactly that many, and threw — because a full page + * and a truncated one are the same array and it believed no second request could tell them apart. + * Composio publishes more than {@link LISTING_LIMIT} toolkits, so the condition was true on every + * call and the app picker showed an operator nothing at all, with a sentence explaining why a + * partial directory would be worse. The reasoning was right and the premise was false: the raw + * client has a cursor for this listing and always did. See {@link ComposioVendor} and + * {@link everyRowOf}. * - * ONE PAGE AT THE CEILING, SORTED BY USAGE, AND NO SEARCH TERM. + * SORTED BY USAGE, AND NO SEARCH TERM — see {@link ComposioVendor}, where both are argued now + * that neither is holding a finite page together. * - * The order matters because the page is finite: at the ceiling the apps most likely to be wanted - * are the ones that must not be the ones cut off. The absent search term is the subtler half — - * the SDK's list params name no search field - * (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, `src/types/toolkit.types.ts:9-15`) - * and the parse strips what it does not name, so a term passed here would vanish before the - * request and leave a caller believing they had filtered a list nobody filtered. Searching the - * catalogue is this deployment's own job, over the rows below. + * THROWN FROM INSIDE THE FETCH, WHICH IS WHAT KEEPS A FAILURE OUT OF THE CACHE. Every refusal + * below — a cursor that cannot be followed, a page that is not a page, a row that cannot be read + * — rejects the promise `listApps` holds, and `listApps` drops an entry whose request rejected. + * A fragment committed here would be a fragment served for ten minutes to BOTH callers: an + * administrator searching for an app past the cut is told nothing matched, and the enable route, + * which checks a slug against this same directory, tells them a real app is not one Composio + * lists. */ const fetchDirectory = async (): Promise => { - /* - * TAKEN AS THE LIST IT IS DECLARED TO BE, which is the one answer in this file whose container - * the vendor settles TWICE OVER. `Toolkits.getToolkits` returns - * `transformToolkitListResponse(result)`, whose body is `response.items.map(...)` — so it is an - * array or it threw — and the whole method sits inside a try that rethrows anything at all as - * `ComposioToolkitFetchError` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:70-82`). A - * container refusal used to stand here for a null or an envelope; neither can arrive, and a - * malformed catalogue reaches a reader as that vendor class, whose own message is the bare - * "Failed to fetch toolkits" and whose remedy genuinely is the key or the status page — which - * is why {@link vendorRefusal} deliberately leaves it to the route's default answer. - */ - const toolkits = await askVendor( - { outcome: "the app catalogue was not read", app: null }, - () => vendor.toolkits.get({ limit: LISTING_LIMIT, sortBy: "usage" }), + const listing: Listing = { + noun: "Composio's app catalogue", + consequence: "the directory was not shown", + }; + const toolkits = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { outcome: "the app catalogue was not read", app: null }, + () => + vendor.toolkits.list({ + limit: LISTING_LIMIT, + sort_by: "usage", + // Spread rather than an explicit undefined, for the reason `everyRowOf` gives: a + // `cursor: undefined` is a key on the wire, and "the first page" is said by omission. + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), ); - if (toolkits.length >= LISTING_LIMIT) { - /* - * A FULL PAGE IS NOT A COMPLETE CATALOGUE, and this deployment cannot find out which it is. - * The same refusal `./composio` makes of a full action listing, for the same reason and one - * line of vendor code apart. - * - * `LISTING_LIMIT` is the largest page the toolkit endpoint allows, and while its params do - * name a `cursor` (`ToolkitsListParamsSchema`, `@composio/core` 0.18.1, - * `src/types/toolkit.types.ts:9-15`) there is nothing to put in it: the SDK's - * `ToolKitListResponse` is a bare array (`:53`) and `transformToolkitListResponse` drops the - * response's `next_cursor` before any caller sees it. So a catalogue of exactly this many - * apps and one with more of them answer identically here, and there is no second request that - * could tell them apart. - * - * COMMITTED AS COMPLETE IT WOULD BE CACHED AS COMPLETE, which is what makes this worse than - * one short answer. The fragment is held for ten minutes and served to both callers, so an - * administrator searching for an app past the cut is told nothing matched, and the enable - * route — which checks a slug against this same directory — tells them a real app "is not an - * app Composio lists". Thrown from inside the fetch so the failure is never the thing that - * gets held: `listApps` drops an entry whose request rejected. - */ - throw new BrokerRefusalError( - `Composio answered with ${toolkits.length} apps, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. A partial directory is not shown, because an app missing from it reads exactly like an app Composio does not publish.`, - ); - } - /* * Each absence becomes the value that reads honestly on an administrator's screen, and each * PRESENT field that is not what it is declared to be becomes a refusal — see {@link appOf}, @@ -2052,50 +2282,60 @@ export function buildComposioClient( const actions: ComposioActions = { async listActions(toolkit, page): Promise { /* - * A LIMIT THAT CANNOT TRAVEL IS THE VENDOR'S DEFAULT WEARING THE CALLER'S NAME. + * A PAGE OF NOTHING IS NOT A PAGE, AND THE REASON IT IS REFUSED HAS MOVED. * - * `getRawComposioTools` composes its request with `...(limit ? { limit } : {})` - * (`@composio/core` 0.18.1, `src/models/Tools.ts:536`), and its schema spells the field - * `z.number().optional()` with no floor (`src/types/tool.types.ts:257`). So a zero is not - * sent short — it is not sent at all: the request goes out with no limit, Composio applies - * its own page of twenty, and twenty rows come back looking exactly like everything a small - * app publishes. Nothing downstream can see it. `./composio` measures the answer against - * {@link LISTING_LIMIT} to catch a page that might be a fragment, and twenty is nowhere near - * it, so a refresh commits the vendor's default as the whole truth about the app and deletes - * every action past it from `mcp_tools` while reporting success. + * It used to be a fact about the wrapper: `getRawComposioTools` composed its request with + * `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a + * schema spelling the field `z.number().optional()` with no floor + * (`src/types/tool.types.ts:257`), so a zero was not sent short — it was not sent at all, and + * Composio's own page of twenty came back looking exactly like everything a small app + * publishes. The raw client passes a zero through, so what a zero means now is Composio's to + * say rather than a silent substitution. * - * REFUSED RATHER THAN DEFAULTED, because the page is required on this seam precisely so that - * no layer supplies one quietly — and a caller asking for no rows is a caller with a fault, - * which is a thing to report rather than a thing to correct on their behalf. + * IT IS STILL REFUSED, AND THE ARGUMENT IS THE ONE THAT DID NOT DEPEND ON THE WRAPPER. The + * page is required on this seam precisely so that no layer supplies one quietly; a caller + * asking for no rows is a caller with a fault, and a fault is a thing to report rather than a + * thing to correct on their behalf. What changed with paging is that the limit is now a PAGE + * SIZE rather than the whole listing — {@link everyRowOf} reads on until the cursor stops — + * so a small one costs requests rather than actions. A zero would cost every request there + * is, or none. */ if (!Number.isInteger(page.limit) || page.limit < 1) { throw new Error( - `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. This deployment's @composio/core drops a limit it reads as falsy rather than refusing it, so the request would have gone out with no limit at all and come back as the vendor's own default page — indistinguishable from everything ${toolkit} publishes.`, + `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. The page a listing asks for is required on this seam so that no layer supplies one quietly, and a request for no rows is a fault to report rather than one to correct on a caller's behalf.`, ); } /* - * The caller's page is passed through rather than defaulted here, because the seam made - * `page` required precisely so that no layer could quietly supply one. See the module - * comment on what an omitted limit does beyond truncating. - * - * AND THE ANSWER IS TAKEN AS THE LIST IT IS DECLARED TO BE, for the reason the catalogue - * above is. `getRawComposioTools` answers `[]` where the client returned nothing and - * otherwise returns `tools.items.map(tool => this.transformToolCases(tool))` - * (`@composio/core` 0.18.1, `src/models/Tools.ts:557-561`), so it is an array or the `.map` - * threw — and the refusal that used to stand here for a non-array was a branch the SDK made - * unreachable. + * EVERY PAGE, WHICH IS WHAT THIS SEAM COULD NOT DO UNTIL THE LISTING LEFT THE WRAPPER. + * `./composio` used to meet a listing at {@link LISTING_LIMIT} and refuse it, for the reason + * the catalogue above used to: an app with exactly that many actions and one with more of + * them answer identically, and committing the second deletes every action past the cut from + * `mcp_tools` under a refresh that reported success. That refusal is gone with this, and the + * guard it cannot be confused with — `store.ts`'s empty-listing guard — stays where it is. */ - const tools = await askVendor( - { - outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, - app: toolkit, - }, - () => - vendor.tools.getRawComposioTools({ - toolkits: [toolkit], - limit: page.limit, - }), + const listing: Listing = { + noun: `${toolkit}'s actions`, + consequence: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + }; + const tools = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { + outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + app: toolkit, + }, + () => + vendor.tools.list({ + toolkit_slug: toolkit, + limit: page.limit, + // The SDK's own default, forwarded on every listing it made, and the thing that + // decides which `version` each action carries. See {@link ComposioVendor}. + toolkit_versions: "latest", + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), ); return tools.map((row, position) => actionOf(row, position, toolkit)); @@ -2892,14 +3132,18 @@ export function buildComposioClient( * and a private field of the vendor's client thereafter, and no path out of this module carries it * — see the module comment. * - * IT IS NO LONGER ONE LINE, AND THE REASON IS THE TWO DELETES. `Composio` used to satisfy - * {@link ComposioVendor} whole, passed straight in. It cannot any more: its own `authConfigs.delete` - * and `connectedAccounts.delete` send a hard-coded empty body and therefore cannot ask for the - * upstream revocation, which is the difference between ending somebody's access and filing it away. - * The underlying `@composio/client` takes the parameter, so those two members are satisfied from - * `getClient()` and the rest from the SDK's own models. A wrapper per member rather than a spread, - * so that the arrow's own type checks against the shape above — a vendor method whose signature - * drifted would fail here rather than at the call site. + * IT IS NO LONGER ONE LINE, AND THE REASON WAS THE TWO DELETES AND IS NOW ALSO THE TWO LISTINGS. + * `Composio` used to satisfy {@link ComposioVendor} whole, passed straight in. It cannot any more, + * for two separate faults in the same wrapper. Its own `authConfigs.delete` and + * `connectedAccounts.delete` send a hard-coded empty body and therefore cannot ask for the upstream + * revocation, which is the difference between ending somebody's access and filing it away. And its + * `tools.getRawComposioTools` and `toolkits.get` cannot be paged — one takes no cursor, the other + * drops the response's — which is the difference between a catalogue and the first thousand rows + * of one, and which showed an operator an empty app picker. The underlying `@composio/client` + * answers all four, so those members are satisfied from `getClient()` and the rest from the SDK's + * own models. A wrapper per member rather than a spread, so that the arrow's own type checks + * against the shape above — a vendor method whose signature drifted would fail here rather than at + * the call site. * * NO NEW IMPORT, WHICH IS WHY THE ONE-IMPORT-SITE RULE SURVIVES THIS. `getClient()` is public on the * SDK's own object and the client's types are inferred from it; `@composio/client` is not named @@ -2922,8 +3166,15 @@ export function createComposioClient(apiKey: string): { const client = composio.getClient(); return buildComposioClient({ - tools: composio.tools, - toolkits: composio.toolkits, + tools: { + // The listing is the raw client's because the wrapper has no cursor; the single-tool read and + // the execute stay the SDK's, because both are one call about one action and neither pages. + list: (query) => client.tools.list(query), + getRawComposioToolBySlug: (slug, options) => + composio.tools.getRawComposioToolBySlug(slug, options), + execute: (slug, body) => composio.tools.execute(slug, body), + }, + toolkits: { list: (query) => client.toolkits.list(query) }, authConfigs: { list: (query) => composio.authConfigs.list(query), create: (toolkit, options) => diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts index 6c9af022e..dc606fca7 100644 --- a/server/src/plugins/composio.ts +++ b/server/src/plugins/composio.ts @@ -44,24 +44,25 @@ import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; export const VERSION_ARG = "__version"; /** - * How many actions one listing asks for, which is as many as this SDK can be made to answer with. + * How many rows one PAGE of a listing asks for, which is as many as Composio will answer with. * * A NUMBER RATHER THAN NO NUMBER, because omitting it is not "no opinion". Composio's page defaults - * to 20 and Gmail publishes 63 actions, so an omitted limit truncates — and worse, it NARROWS: - * `getRawComposioTools` sets `important=true` whenever the query named toolkits and gave no limit, - * no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the short - * answer is a filtered one and nothing in it says a filter was applied. Passing a limit is what - * turns the flag off. - * - * 1000 BECAUSE THAT IS THE CEILING, not because it is generous. The REST parameter documents "max - * allowed is 1000" (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:441-444`), and the - * core SDK exposes no way to go past it: `ToolListParamsSchema` has no cursor field, and - * `getRawComposioTools` reads `tools.items` and drops the response's `next_cursor`. So one page at - * the ceiling is not a page — it is the whole listing, and the only listing expressible here. - * - * Which is why {@link listTools} refuses a page that came back FULL. At the ceiling a complete - * answer and a truncated one are the same array, and there is no second request that could tell - * them apart. + * to 20 and Gmail publishes 63 actions, so an omitted limit truncates — and through the SDK wrapper + * it also NARROWED: `getRawComposioTools` set `important=true` whenever the query named toolkits + * and gave no limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), + * so the short answer was a filtered one and nothing in it said a filter had been applied. + * + * 1000 BECAUSE THAT IS THE CEILING, not because it is generous. Both REST parameters document "max + * allowed is 1000" (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:441-444`, + * `resources/toolkits.d.ts:483-486`), so this is the fewest round trips a listing can be read in. + * + * IT IS A PAGE AND NOT THE LISTING, WHICH IS WHAT CHANGED AND WHY THE REFUSAL BELOW IS GONE. This + * used to say that one page at the ceiling "is not a page — it is the whole listing, and the only + * listing expressible here", and {@link listTools} refused a page that came back FULL on the + * strength of it. That was a fact about the wrapper rather than about the vendor: `ToolListParams` + * and `ToolkitListParams` both carry a `cursor` and both responses carry a `next_cursor`, and + * `./composio-adapter` follows them to the end. A listing of exactly this many rows is now an app + * with a lot of actions, and nothing is truncated by it. */ export const LISTING_LIMIT = 1000; @@ -70,30 +71,29 @@ export type ComposioAction = { slug: string; description?: string; /** - * The action's JSON Schema as `@composio/core` re-spells it, which is NOT as Composio published it. + * The action's JSON Schema AS COMPOSIO PUBLISHED IT, which for two waves it was not. * - * This used to say "as they spell it", and that claim travelled: whatever lands in this field is - * what {@link listTools} puts in front of a model as the vendor's own schema. The SDK parses the - * response through `ToolSchema`, and its `ParametersSchema` is a plain `z.object` with no - * passthrough (`@composio/core` 0.18.1, `src/types/tool.types.ts:134-174`), so every key it does - * not name is dropped before anything here can see it. At the schema ROOT that is `if`, `then`, - * `else`, `examples` and every `x-` extension. Per property, `JSONSchemaPropertySchema` (`:77-131`) - * does keep `if`/`then`/`else`/`examples`, but names neither `deprecated` nor `contentEncoding`, - * so both of those go. + * THIS FIELD USED TO ARRIVE SHORT, AND THE FIX WAS NAMED HERE BEFORE IT WAS MADE. Whatever lands + * in it is what {@link listTools} puts in front of a model as the vendor's own schema, and + * `./composio-adapter` filled it from `tools.getRawComposioTools` — the call that ends in + * `ToolSchema.parse`. Its `ParametersSchema` is a plain `z.object` with no passthrough + * (`@composio/core` 0.18.1, `src/types/tool.types.ts:134-174`), so every key it did not name was + * dropped before anything here could see it: `if`, `then`, `else`, `examples` and every `x-` + * extension at the schema ROOT, and `deprecated` and `contentEncoding` per property + * (`JSONSchemaPropertySchema`, `:77-131`). This comment recorded that as unavoidable and named + * the one place it could be avoided — "that same file, by reading `client.tools.list` directly + * and never running `ToolSchema` over the answer". * - * WHY THE CLAIM WAS DROPPED RATHER THAN THE LOSS FIXED. The strip happens inside the vendor's own - * parse, upstream of every byte this module receives, so there is nothing here to restore a key - * from — "stop losing them" is not an option this file has. The loss is live rather than - * theoretical: `./composio-adapter` fills this field from `tools.getRawComposioTools`, which is - * the call that runs the parse. The one place it could be avoided is that same file, by reading - * `client.tools.list` directly and never running `ToolSchema` over the answer — a decision about - * the vendor's types, belonging where the vendor's types belong, and one somebody can now make in - * a file that exists. What this module can honestly promise is the narrower thing: it adds - * nothing to this schema and removes nothing from it, so what the SDK handed over is exactly what - * a model is shown. + * THAT IS NOW WHAT THE ADAPTER DOES, FOR A REASON THAT HAD NOTHING TO DO WITH THIS FIELD. The + * wrapper is the one method of the vendor's tool model that cannot be paged, so the listing moved + * to the raw client to follow Composio's cursor — and the keys came back as a side effect. What + * this module promises is unchanged and is now worth more: it adds nothing to this schema and + * removes nothing from it, so what a model is shown is what the vendor published. * - * Absent for the occasional action that publishes none — and equally for one that published `{}`, - * which the SDK normalizes to absent before parsing (`src/models/Tools.ts:76-93`). + * Absent for the occasional action that publishes none. An action that published `{}` now arrives + * as `{}` rather than as absent — the SDK normalized that away before parsing + * (`src/models/Tools.ts:76-93`) and nothing does now — which `./store` records as the open schema + * an empty one is. */ inputParameters?: Record; /** Behaviour labels mixed in with topical ones. See {@link effectOf}. */ @@ -140,13 +140,19 @@ export type ComposioResult = { */ export type ComposioActions = { /** - * Every action of one app, for a page the CALLER has to name. + * Every action of one app, read to the end, in pages the CALLER has to name the size of. * * `page` is required rather than optional, and that is the whole point of it being here. The - * previous signature took the toolkit alone, so an adapter had nothing to pass a limit through + * original signature took the toolkit alone, so an adapter had nothing to pass a limit through * and the SDK's default applied — 20 rows, silently narrowed to the vendor's "important" subset. - * A required argument makes the narrowed listing a thing a caller has to ask for on purpose - * instead of a thing they get by leaving something out. See {@link LISTING_LIMIT}. + * A required argument makes the page a thing a caller asks for on purpose instead of a thing they + * get by leaving something out. See {@link LISTING_LIMIT}. + * + * WHAT IT NO LONGER BOUNDS IS THE ANSWER. `./composio-adapter` follows Composio's cursor until + * the vendor stops offering one, so this names how many rows each request carries and not how + * many actions can come back — which is why the full-page refusal that used to stand in + * {@link listTools} is gone, and why an implementation that reads one page and stops is a defect + * nothing about the signature would report. */ listActions( toolkit: string, @@ -388,11 +394,14 @@ function stagesAFile(schema: unknown): boolean { * nobody" cases throw, and they throw SEPARATELY, because one sends an operator to this * deployment's configuration and the other to the row's url. * - * WHAT IS DELIBERATELY NOT REFUSED IS THE VENDOR'S OWN EMPTY PAGE, and it is worth saying why, - * because `@composio/core` does manufacture one: `getRawComposioTools` ends `if (!tools) { return - * []; }` (0.18.1, `src/models/Tools.ts:553-557`), so a response it could not read arrives here as - * the same `[]` an app with no actions would send. That is a real hazard and it is answered a - * layer down rather than here — `store.ts`'s own empty-listing guard keeps every recorded action, + * WHAT IS DELIBERATELY NOT REFUSED IS THE VENDOR'S OWN EMPTY PAGE, and it is worth saying why. + * `@composio/core` used to manufacture one — `getRawComposioTools` ends `if (!tools) { return []; }` + * (0.18.1, `src/models/Tools.ts:553-557`), so a response it could not read arrived here as the same + * `[]` an app with no actions would send — and that particular hazard is gone with the wrapper: + * `./composio-adapter` reads the raw client and refuses an answer that is not a page rather than + * turning it into an empty one. What remains is the honest case, a listing Composio really did + * answer with no rows, and that is answered a layer down rather than here — `store.ts`'s own + * empty-listing guard keeps every recorded action, * its `effect`, its `destructive` and its `version` whenever an app that HAS actions lists none, * writes the sentence saying so, and stamps no refresh. Refusing here as well would buy nothing * that guard does not already hold, and would cost the case it is careful to allow: an app that @@ -600,25 +609,28 @@ export async function listTools(connection: { ); } - if (actions.length >= LISTING_LIMIT) { - /* - * A FULL PAGE IS NOT A COMPLETE LISTING, and this deployment cannot find out which it is. - * - * `LISTING_LIMIT` is the largest page the vendor's REST parameter allows, and the core SDK - * offers no cursor to ask for a second one. So an app with exactly that many actions and an app - * with more of them answer identically here. Committed as complete, the second one has every - * action past the cut deleted from `mcp_tools` under a refresh that reported success — the same - * loss the empty answer used to cause, arriving by a different route. - * - * AND THE ONE `store.ts` CANNOT CATCH FOR US, which is why this refusal is not redundant with - * the empty-listing guard there. That guard keys on a listing with NOTHING in it; a full page - * is a listing with a thousand things in it, indistinguishable from a complete one, and it - * commits as the whole truth about the app. - */ - throw new Error( - `Composio answered with ${actions.length} actions for ${toolkit}, which is the largest page this deployment's @composio/core can ask for, so there may be more that it cannot see. The actions already recorded are kept rather than replaced by a listing that might be a fragment.`, - ); - } + /* + * A FULL PAGE USED TO BE REFUSED HERE, AND THAT REFUSAL IS GONE BECAUSE ITS PREMISE WAS FALSE. + * + * It said that `LISTING_LIMIT` is the largest page the vendor's REST parameter allows and that + * the core SDK offers no cursor to ask for a second one, so an app with exactly that many actions + * and an app with more of them answer identically — and that committing the second deletes every + * action past the cut from `mcp_tools` under a refresh that reported success. The consequence was + * real and the premise was about the WRAPPER. `ToolListParamsSchema` names no cursor, but + * `@composio/client`'s `ToolListParams` does, and its `ToolListResponse` carries `next_cursor` + * (0.1.0-alpha.76, `resources/tools.d.ts:421-432`, `:200-204`). `./composio-adapter` reads that + * client directly and follows the cursor to the end of the listing, so what arrives here is every + * action the app publishes and a full page is just a large app. A refusal that cannot be told + * from a healthy answer is one thing; a refusal that fires ON a healthy answer is another, and + * this had become the second — the same defect, at the same ceiling, that emptied the app picker + * one file over. + * + * NOTHING ELSE MOVED WITH IT. A listing that could not be read at all is still a throw rather than + * an empty list, for the reason at the top of this function, and `store.ts`'s empty-listing guard + * still keeps every recorded action when an app that HAS actions lists none. What is no longer + * claimed is that a listing this long might be a fragment, because it cannot be: the adapter + * refuses a cursor it cannot follow rather than handing over what it had. + */ /* * AN ACTION IS OFFERED ONLY IF A MODEL COULD ACTUALLY FILL IN ITS ARGUMENTS. diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 1224b7911..ac38db7b8 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -54,13 +54,13 @@ function fakeVendor(parts: { }) { return { tools: { - getRawComposioTools: refuse("tools.getRawComposioTools"), + list: refuse("tools.list"), getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), execute: refuse("tools.execute"), ...parts.tools, }, toolkits: { - get: refuse("toolkits.get"), + list: refuse("toolkits.list"), ...parts.toolkits, }, authConfigs: { @@ -106,8 +106,16 @@ const WHOLE_LISTING = 1000; * * So the number of pages READ and the number the refusal STATES are both asserted, and both * against this literal. + * + * IT WAS 50 AND IT IS 200, WHICH IS A DELIBERATE CHANGE RATHER THAN A TEST FOLLOWING A MODULE. 50 + * was argued from the only two listings that paged — one app's authorization configs and one + * person's accounts for one app — where a second page is already extraordinary. The app catalogue + * now pages too and is not that listing: Composio publishes more than {@link WHOLE_LISTING} + * toolkits, so its second page is the ordinary case. The module states the arithmetic; what matters + * here is that both numbers are written out, so a ceiling that moves again reddens the tests that + * are about it rather than redefining them. */ -const PAGES_BEFORE_REFUSING = 50; +const PAGES_BEFORE_REFUSING = 200; /** The page this deployment sends somebody back to once the consent screen is done with them. */ const RETURN_URL = "https://openbot.test/settings/connected-accounts/x"; @@ -311,19 +319,21 @@ describe("listing an app's actions", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioTools: async (query: unknown) => { + list: async (query: unknown) => { asked.push(query); - return [ - { - slug: "GMAIL_FETCH_EMAILS", - name: "Fetch emails", - description: "Fetch emails from Gmail.", - inputParameters: { type: "object", properties: {} }, - tags: ["readOnlyHint"], - version: "20260903_00", - toolkit: { slug: "gmail" }, - }, - ]; + return { + items: [ + { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + description: "Fetch emails from Gmail.", + input_parameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + toolkit: { slug: "gmail" }, + }, + ], + }; }, }, }), @@ -333,12 +343,19 @@ describe("listing an app's actions", () => { limit: WHOLE_LISTING, }); - // An omitted limit is not "no opinion": Composio's page defaults to 20, and - // `getRawComposioTools` additionally sets `important=true` whenever a toolkit query carried no - // limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so the - // short answer is also a filtered one and nothing in it says so. Passing the limit through is - // what turns that flag off, which is why the assertion is on the query and not on the answer. - expect(asked).toEqual([{ toolkits: ["gmail"], limit: WHOLE_LISTING }]); + // An omitted limit is not "no opinion": Composio's page defaults to 20, and the wrapper this + // listing used to go through additionally set `important=true` whenever a toolkit query carried + // no limit, no tags and no search (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so + // the short answer was also a filtered one and nothing in it said so. The request is composed + // here now, which is why the assertion is on the query and not on the answer: the limit, the + // toolkit version the wrapper used to supply, and no `important` at all. + expect(asked).toEqual([ + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + }, + ]); expect(listed).toEqual([ { slug: "GMAIL_FETCH_EMAILS", @@ -355,12 +372,14 @@ describe("listing an app's actions", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioTools: async (query: unknown) => { + list: async (query: unknown) => { asked.push(query); - // Twenty rows: Composio's own default page, which is what a dropped limit produces. - return Array.from({ length: 20 }, (_, index) => ({ - slug: `GMAIL_ACTION_${index}`, - })); + // Twenty rows: Composio's own default page, which is what a dropped limit produced. + return { + items: Array.from({ length: 20 }, (_, index) => ({ + slug: `GMAIL_ACTION_${index}`, + })), + }; }, }, }), @@ -369,14 +388,14 @@ describe("listing an app's actions", () => { const refusal = await failureOf(actions.listActions("gmail", { limit: 0 })); /* - * A ZERO DOES NOT TRAVEL SHORT — IT DOES NOT TRAVEL. `getRawComposioTools` composes its request - * with `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over - * a schema that spells the field `z.number().optional()` with no floor - * (`src/types/tool.types.ts:257`), so the request goes out with no limit and Composio answers - * with its own page of twenty. Nothing downstream can see that: `./composio` catches a page - * that might be a fragment by measuring it against 1000, and twenty is nowhere near it, so the - * vendor's default commits as everything the app publishes and every action past it is deleted - * from `mcp_tools` by a refresh that reported success. + * THE REASON MOVED AND THE REFUSAL DID NOT. Through the wrapper a zero did not travel short — + * it did not travel: `getRawComposioTools` composed its request with `...(limit ? { limit } : + * {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a schema spelling the field + * `z.number().optional()` with no floor (`src/types/tool.types.ts:257`), so it went out with no + * limit and came back as Composio's own page of twenty. The raw client passes a zero through, + * so that particular substitution is gone. What is left is the argument that never rested on + * it: the page is required on this seam precisely so that no layer supplies one quietly, and a + * caller asking for no rows is a fault to report rather than one to correct on their behalf. */ expect(refusal.message).not.toMatch(A_CRASH); expect(refusal.message).toMatch(/0 rows is not a page/); @@ -392,33 +411,35 @@ describe("the app catalogue", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async (query: unknown) => { + list: async (query: unknown) => { asked.push(query); - return [ - { - slug: "gmail", - name: "Gmail", - isLocalToolkit: false, - meta: { - description: "Send and read mail.", - logo: "https://logos.composio.dev/gmail.png", - categories: [ - { slug: "productivity", name: "Productivity" }, - { slug: "email", name: "Email" }, - ], - toolsCount: 63, + return { + items: [ + { + slug: "gmail", + name: "Gmail", + is_local_toolkit: false, + meta: { + description: "Send and read mail.", + logo: "https://logos.composio.dev/gmail.png", + categories: [ + { id: "productivity", name: "Productivity" }, + { id: "email", name: "Email" }, + ], + tools_count: 63, + }, }, - }, - // A toolkit that publishes none of the optional fields, because several do. An - // administrator picking from a few hundred apps is better served by a missing logo - // than by a broken one, so the absence has to survive as null rather than as "". - { - slug: "sparse", - name: "Sparse", - isLocalToolkit: false, - meta: {}, - }, - ]; + // A toolkit that publishes none of the optional fields, because several do. An + // administrator picking from a few hundred apps is better served by a missing logo + // than by a broken one, so the absence has to survive as null rather than as "". + { + slug: "sparse", + name: "Sparse", + is_local_toolkit: false, + meta: {}, + }, + ], + }; }, }, }), @@ -426,11 +447,11 @@ describe("the app catalogue", () => { const apps = await broker.listApps(); - // The catalogue is asked for one page at the documented ceiling, ordered by usage so the apps - // anybody actually connects are at the top. No search term: the SDK's list params have no such - // field, so one passed here would be stripped before the request and the caller would be - // filtering against a list nobody filtered. - expect(asked).toEqual([{ limit: WHOLE_LISTING, sortBy: "usage" }]); + // Pages at the documented ceiling, ordered by usage so the apps anybody actually connects come + // first, and carrying no cursor field on the first request. No search term: the catalogue is + // held for ten minutes and searched in this process by both of its callers, so a per-term + // request would be a per-term cache. + expect(asked).toEqual([{ limit: WHOLE_LISTING, sort_by: "usage" }]); expect(apps).toEqual([ { slug: "gmail", @@ -472,7 +493,7 @@ describe("holding the catalogue", () => { const GMAIL = { slug: "gmail", name: "Gmail", - meta: { description: "Send and read mail.", toolsCount: 63 }, + meta: { description: "Send and read mail.", tools_count: 63 }, }; const GMAIL_ROW = { slug: "gmail", @@ -489,9 +510,9 @@ describe("holding the catalogue", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => { + list: async () => { calls += 1; - return [GMAIL]; + return { items: [GMAIL] }; }, }, }), @@ -515,16 +536,22 @@ describe("holding the catalogue", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => { + list: async () => { calls += 1; // The catalogue moves between the two reads, which is the only way to tell a second // request apart from a cache that happened to be asked twice. return calls === 1 - ? [GMAIL] - : [ - GMAIL, - { slug: "linear", name: "Linear", meta: { toolsCount: 12 } }, - ]; + ? { items: [GMAIL] } + : { + items: [ + GMAIL, + { + slug: "linear", + name: "Linear", + meta: { tools_count: 12 }, + }, + ], + }; }, }, }), @@ -551,14 +578,14 @@ describe("holding the catalogue", () => { test("callers arriving while a listing is in flight share the one request", async () => { let calls = 0; - let answer: (toolkits: unknown[]) => void = () => {}; - const inFlight = new Promise((resolve) => { + let answer: (page: { items: unknown[] }) => void = () => {}; + const inFlight = new Promise<{ items: unknown[] }>((resolve) => { answer = resolve; }); const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: () => { + list: () => { calls += 1; return inFlight; }, @@ -571,7 +598,7 @@ describe("holding the catalogue", () => { // together, or one debounce firing twice, all arrive before the first answer exists. A cache // that held the ROWS rather than the request would be empty for every one of them. const both = Promise.all([broker.listApps(), broker.listApps()]); - answer([GMAIL]); + answer({ items: [GMAIL] }); const [first, second] = await both; expect(calls).toBe(1); @@ -584,13 +611,13 @@ describe("holding the catalogue", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => { + list: async () => { calls += 1; // The real first failure here is a key that is unset or wrong, and the operator who // fixes it presses the button again within seconds. A cached refusal would keep // refusing for ten minutes with nothing left to fix. if (calls === 1) throw new Error("Composio refused the catalogue."); - return [GMAIL]; + return { items: [GMAIL] }; }, }, }), @@ -2003,34 +2030,61 @@ describe("refusals a route can tell from an outage", () => { * What the catalogue is allowed to be, given that it is cached and then believed. */ describe("a catalogue that might be a fragment", () => { - test("a full page is refused rather than held for ten minutes", async () => { + /** + * THIS TEST ASSERTED THE OPPOSITE AND WAS CHANGED ON PURPOSE, WHICH IS WORTH READING BEFORE THE + * CODE UNDER IT. + * + * It was "a full page is refused rather than held for ten minutes", and it pinned a refusal whose + * stated reason was that "`LISTING_LIMIT` is the largest page the toolkit endpoint allows and the + * SDK drops the response's cursor, so a catalogue of exactly this size and one larger answer + * identically". The consequence it guarded against is real and is still guarded: a fragment + * committed here is served for ten minutes to the picker AND to the enable route, which then + * tells an administrator that a real app "is not an app Composio lists". + * + * WHAT WAS FALSE WAS THE PREMISE. The cursor exists on the raw client and always did — see the + * paging tests at the end of this file — so the two answers the refusal said were + * indistinguishable are told apart by asking for the next page. And the refusal's cost was not + * hypothetical: Composio publishes more than {@link WHOLE_LISTING} toolkits, so it fired on the + * first call every time and the app picker showed an operator nothing at all. + * + * SO THE ASSERTION IS INVERTED RATHER THAN DELETED, and it is inverted at the same boundary. A + * page of exactly {@link WHOLE_LISTING} rows with a cursor still outstanding is the shape that + * used to be refused; what is asserted now is that the row on the far side of it arrives. + */ + test("a full page is read on from rather than refused", async () => { let calls = 0; const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => { + list: async (query: unknown) => { calls += 1; - return Array.from({ length: WHOLE_LISTING }, (_, index) => ({ - slug: `app_${index}`, - name: `App ${index}`, - meta: {}, - })); + return (query as { cursor?: string }).cursor === undefined + ? { + items: Array.from({ length: WHOLE_LISTING }, (_, index) => ({ + slug: `app_${index}`, + name: `App ${index}`, + meta: {}, + })), + next_cursor: "page_2", + } + : { + items: [ + { slug: "the_one_past_the_cut", name: "Past", meta: {} }, + ], + next_cursor: null, + }; }, }, }), () => 1_000_000, ); - /* - * `LISTING_LIMIT` is the largest page the toolkit endpoint allows and the SDK drops the - * response's cursor, so a catalogue of exactly this size and one larger answer identically. - * Committed, the fragment would be served for ten minutes to the picker AND to the enable - * route, which tells an administrator that a real app "is not an app Composio lists". - */ - await expect(broker.listApps()).rejects.toThrow(/largest page/); - // And the refusal is not what gets held: the next caller asks again. - await expect(broker.listApps()).rejects.toThrow(/largest page/); + const apps = await broker.listApps(); + expect(calls).toBe(2); + expect(apps).toHaveLength(WHOLE_LISTING + 1); + // The app an administrator would have been told Composio does not publish. + expect(apps.at(-1)?.slug).toBe("the_one_past_the_cut"); }); test("each caller gets its own rows, so one of them cannot edit the cache", async () => { @@ -2038,17 +2092,19 @@ describe("a catalogue that might be a fragment", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, toolkits: { - get: async () => [ - { - slug: "gmail", - name: "Gmail", - meta: { - description: "Send and read mail.", - categories: [{ slug: "productivity", name: "Productivity" }], - toolsCount: 63, + list: async () => ({ + items: [ + { + slug: "gmail", + name: "Gmail", + meta: { + description: "Send and read mail.", + categories: [{ id: "productivity", name: "Productivity" }], + tools_count: 63, + }, }, - }, - ], + ], + }), }, }), () => 1_000_000, @@ -2250,7 +2306,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { }[] = [ { method: "listApps", - vendor: (raise) => ({ toolkits: { get: raise } }), + vendor: (raise) => ({ toolkits: { list: raise } }), ask: ({ broker }) => broker.listApps(), }, { @@ -2303,7 +2359,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { }, { method: "listActions", - vendor: (raise) => ({ tools: { getRawComposioTools: raise } }), + vendor: (raise) => ({ tools: { list: raise } }), ask: ({ actions }) => actions.listActions("gmail", { limit: WHOLE_LISTING }), }, @@ -2862,7 +2918,7 @@ describe("a vendor listing that is not the shape it is declared to be", () => { return { parts: { toolkits: { - get: async () => { + list: async () => { sent.push("toolkits.get"); return answer; }, @@ -2977,7 +3033,7 @@ describe("a vendor listing that is not the shape it is declared to be", () => { return { parts: { tools: { - getRawComposioTools: async () => { + list: async () => { sent.push("tools.getRawComposioTools"); return answer; }, @@ -3679,7 +3735,7 @@ describe("what a malformed field of a row actually costs", () => { row: { slug: "gmail", name: "Gmail", - meta: { categories: [{ slug: "productivity" }] }, + meta: { categories: [{ id: "productivity" }] }, }, names: /categor/, }, @@ -3687,7 +3743,7 @@ describe("what a malformed field of a row actually costs", () => { fault: "an action count that arrived as a string", // `Number("63")` is the defect this whole sweep is about: a vendor change turned into a // plausible figure, shown BEFORE anybody enables an app, that nobody would think to question. - row: { slug: "gmail", name: "Gmail", meta: { toolsCount: "63" } }, + row: { slug: "gmail", name: "Gmail", meta: { tools_count: "63" } }, names: /count/, }, ]; @@ -3695,7 +3751,7 @@ describe("what a malformed field of a row actually costs", () => { for (const { fault, row, names } of CATALOGUE_ROWS) { test(`a catalogue row with ${fault} stops the directory`, async () => { const { broker } = buildComposioClient( - fakeVendor({ toolkits: { get: async () => [row] } }), + fakeVendor({ toolkits: { list: async () => ({ items: [row] }) } }), () => 1_000_000, ); @@ -3730,10 +3786,12 @@ describe("what a malformed field of a row actually costs", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioTools: async () => [ - { slug: "GMAIL_FETCH_EMAILS", name: "Fetch emails" }, - { slug: " ", name: "Send mail" }, - ], + list: async () => ({ + items: [ + { slug: "GMAIL_FETCH_EMAILS", name: "Fetch emails" }, + { slug: " ", name: "Send mail" }, + ], + }), }, }), ); @@ -3775,9 +3833,11 @@ describe("what a malformed field of a row actually costs", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => [ - { slug: "gmail", name: "Gmail", meta: { categories: [entry] } }, - ], + list: async () => ({ + items: [ + { slug: "gmail", name: "Gmail", meta: { categories: [entry] } }, + ], + }), }, }), () => 1_000_000, @@ -3850,7 +3910,7 @@ describe("what a malformed field of a row actually costs", () => { }, { fault: "an input schema that is not an object", - row: { slug: "GMAIL_FETCH_EMAILS", inputParameters: "not-a-schema" }, + row: { slug: "GMAIL_FETCH_EMAILS", input_parameters: "not-a-schema" }, names: /input schema/, }, { @@ -3861,7 +3921,7 @@ describe("what a malformed field of a row actually costs", () => { ]) { test(`an action with ${fault} stops the listing rather than crossing the seam`, async () => { const { actions } = buildComposioClient( - fakeVendor({ tools: { getRawComposioTools: async () => [row] } }), + fakeVendor({ tools: { list: async () => ({ items: [row] }) } }), ); const failure = await failureOf( @@ -3887,7 +3947,7 @@ describe("what a malformed field of a row actually costs", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioTools: async () => [{ slug: "GMAIL_FETCH_EMAILS" }], + list: async () => ({ items: [{ slug: "GMAIL_FETCH_EMAILS" }] }), }, }), ); @@ -4210,9 +4270,11 @@ describe("a field Composio padded with whitespace", () => { const { broker } = buildComposioClient( fakeVendor({ toolkits: { - get: async () => [ - { slug: " gmail ", name: " Gmail ", meta: { toolsCount: 63 } }, - ], + list: async () => ({ + items: [ + { slug: " gmail ", name: " Gmail ", meta: { tools_count: 63 } }, + ], + }), }, }), () => 1_000_000, @@ -4279,9 +4341,9 @@ describe("a field Composio padded with whitespace", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - getRawComposioTools: async () => [ - { slug: " GMAIL_FETCH_EMAILS ", version: "20260903_00" }, - ], + list: async () => ({ + items: [{ slug: " GMAIL_FETCH_EMAILS ", version: "20260903_00" }], + }), }, }), ); @@ -4465,3 +4527,295 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = ]); }); }); + +/** + * THE TWO LISTINGS THAT USED TO REFUSE A FULL PAGE, NOW READ TO THE END OF THEIR CURSORS. + * + * These were the last two places in this file where a request the vendor can answer was described + * as one this deployment cannot express. It can: `@composio/client` 0.1.0-alpha.76 declares + * `cursor` on both `ToolkitListParams` (`resources/toolkits.d.ts:467-478`) and `ToolListParams` + * (`resources/tools.d.ts:421-432`), and `next_cursor` on both responses (`:322-326` and + * `:200-204`). What had no cursor was the WRAPPER around them — `transformToolkitListResponse` + * returns `response.items.map(...)`, a bare array with the cursor dropped, and + * `ToolListParamsSchema` names no cursor field at all — and the two refusals blamed the vendor for + * the wrapper's shape. + * + * IT MATTERED MOST WHERE IT COST MOST. Composio publishes more than {@link WHOLE_LISTING} toolkits, + * so the catalogue refusal fired on the FIRST call every time and an operator opening the app + * picker saw no apps at all — not a truncated directory, none. The tests below are about the rows + * on the SECOND page, because a reader that stops at the first is exactly as wrong as the old + * refusal was and answers far more plausibly. + */ +describe("listings Composio pages, read to the end", () => { + /** One catalogue row, complete, so a test about paging is not also a test about a field. */ + const app = (slug: string) => ({ + slug, + name: slug.toUpperCase(), + meta: { + description: `The ${slug} app.`, + logo: `https://logo.test/${slug}.png`, + categories: [{ id: "productivity", name: "Productivity" }], + tools_count: 3, + }, + }); + + /** One action row as the raw client hands it over, which is snake_case and unparsed. */ + const action = (slug: string) => ({ + slug, + description: `Does ${slug}.`, + input_parameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }); + + test("an app on the catalogue's second page is an app the picker can find", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [app("slack")], next_cursor: "page_2" } + : { items: [app("gmail")], next_cursor: null }; + }, + }, + }), + () => 1_000_000, + ); + + // `gmail` is the whole test, and it is the app the operator waiting on this actually typed. + // Under the old refusal this call answered nothing at all; under a pager that stopped at page + // one it answers an app short and says so nowhere. + expect((await broker.listApps()).map((one) => one.slug)).toEqual([ + "slack", + "gmail", + ]); + // The first request carries no cursor FIELD rather than an undefined one, and the second + // carries the vendor's own word for where it left off. + expect(asked).toEqual([ + { limit: WHOLE_LISTING, sort_by: "usage" }, + { limit: WHOLE_LISTING, sort_by: "usage", cursor: "page_2" }, + ]); + }); + + test("an action on the second page is an action the refresh records", async () => { + const asked: unknown[] = []; + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async (query: unknown) => { + asked.push(query); + return (query as { cursor?: string }).cursor === undefined + ? { items: [action("GMAIL_FETCH_EMAILS")], next_cursor: "page_2" } + : { items: [action("GMAIL_SEND_EMAIL")], next_cursor: null }; + }, + }, + }), + ); + + const listed = await actions.listActions("gmail", { limit: WHOLE_LISTING }); + + expect(listed.map((one) => one.slug)).toEqual([ + "GMAIL_FETCH_EMAILS", + "GMAIL_SEND_EMAIL", + ]); + // `refreshTools` commits a listing as the complete truth about an app — the write is a delete + // and an insert — so an action left on page two is an action DELETED from `mcp_tools` under a + // refresh that reported success, taking every grant pointing at it. + expect(listed[1]).toEqual({ + slug: "GMAIL_SEND_EMAIL", + description: "Does GMAIL_SEND_EMAIL.", + inputParameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + }); + /* + * THE QUERY IS ASSERTED BECAUSE THREE OF ITS FIELDS ARE THINGS THE WRAPPER USED TO DO FOR US. + * `toolkit_versions` is the SDK's own default, forwarded on every listing it made + * (`@composio/core` 0.18.1, `src/models/Tools.ts:548`), and it decides which `version` each + * action comes back with — the value a later call sends back to Composio. `limit` is the page, + * and its absence is what used to let the vendor apply twenty. And `important` is named + * NOWHERE, deliberately: the wrapper set it to "true" whenever a toolkit query gave no limit + * (`:505-515`), which narrows the answer to a featured subset that nothing in the answer + * declares. + */ + expect(asked).toEqual([ + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + }, + { + toolkit_slug: "gmail", + limit: WHOLE_LISTING, + toolkit_versions: "latest", + cursor: "page_2", + }, + ]); + }); + + test("a catalogue cursor this deployment cannot follow refuses rather than truncates", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ items: [app("slack")], next_cursor: 7 }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // A number is a position this deployment cannot express and cannot rule out being real, so it + // is the fault it always was. Coercing it to "7" would be the fragment-read-as-whole mistake + // the pager exists to prevent, wearing a default's clothes. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a number where the cursor to the next page/, + ); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("an action cursor this deployment cannot follow refuses rather than truncates", async () => { + const { actions } = buildComposioClient( + fakeVendor({ + tools: { + list: async () => ({ + items: [action("GMAIL_FETCH_EMAILS")], + next_cursor: { page: 2 }, + }), + }, + }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/an object/); + expect(refusal.message).toMatch(/gmail's actions/); + }); + + test("a catalogue page that is not a page of rows refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { list: async () => ({ items: "gmail" }) }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + /* + * THE CONTAINER IS THIS FILE'S TO CHECK NOW, WHICH IT WAS NOT BEFORE. The wrapper dereferenced + * every answer on its way out — `response.items.map(...)` — so a malformed envelope died inside + * the vendor's package and reached a reader as the translated `TypeError`. Reading the raw + * client means nothing dereferences it before this file does, and `rows.push(...items)` over a + * string is "string is not iterable" with nothing in it a person can act on. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/sent a string where the rows/); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("an action page that is not a page of rows refuses", async () => { + const { actions } = buildComposioClient( + fakeVendor({ tools: { list: async () => null } }), + ); + + const refusal = await failureOf( + actions.listActions("gmail", { limit: WHOLE_LISTING }), + ); + + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/gmail's actions/); + }); + + test("the catalogue stops at the page ceiling rather than reading a listing that never ends", async () => { + let pages = 0; + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => { + pages += 1; + return { + items: [app(`app_${pages}`)], + next_cursor: `page_${pages}`, + }; + }, + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // The number of pages READ and the number the refusal STATES, both against the literal. + expect(pages).toBe(PAGES_BEFORE_REFUSING); + expect(refusal.message).toMatch( + new RegExp(`answered ${PAGES_BEFORE_REFUSING} pages`), + ); + expect(refusal.message).toMatch(/Composio's app catalogue/); + }); + + test("a catalogue row whose metadata is not an object refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [{ slug: "gmail", name: "Gmail", meta: "productivity" }], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + /* + * A GUARANTEE THAT CAME FROM THE WRAPPER AND LEAVES WITH IT. `transformToolkitListResponse` + * built each row's meta itself, spreading it into a fresh literal, so every meta arriving here + * was an object however the wire had spelled it. Nothing does that now, and `meta.description` + * off a string is `undefined` rather than a throw — so the app would have shown no description, + * no logo, no categories and no count, indistinguishable from an app that published none. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a string where gmail's description, logo, categories and action count belong/, + ); + }); + + test("a catalogue row whose categories are not a list refuses", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + list: async () => ({ + items: [ + { + slug: "gmail", + name: "Gmail", + meta: { categories: "productivity" }, + }, + ], + }), + }, + }), + () => 1_000_000, + ); + + const refusal = await failureOf(broker.listApps()); + + // The wrapper's `item.meta.categories?.map(...)` died on this one; nothing maps it now, so a + // string would be read as an app that publishes no categories at all. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch( + /sent a string where gmail's categories belong/, + ); + }); +}); diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts index 4b3dd2aba..983ebb288 100644 --- a/server/tests/composio-transport.test.ts +++ b/server/tests/composio-transport.test.ts @@ -485,16 +485,40 @@ describe("listing an app's actions", () => { await listTools({ url: "composio://gmail" }); // Composio's default page is 20 and Gmail publishes 63 actions, so an omitted limit truncates. - // It also NARROWS: `getRawComposioTools` auto-applies `important=true` when no limit, no tags - // and no search were given (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), and - // nothing in the short answer says a filter was applied. Asking for a page is therefore not an - // optimisation, and the seam must not let a caller forget to. + // It also NARROWED, through the wrapper this listing used to go through: `getRawComposioTools` + // auto-applied `important=true` when no limit, no tags and no search were given + // (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), and nothing in the short answer + // said a filter had been applied. Asking for a page is therefore not an optimisation, and the + // seam must not let a caller forget to — what it sizes now is each request rather than the + // answer, because `./composio-adapter` reads on until Composio's cursor stops. expect(asked).toEqual([ { toolkit: "gmail", page: { limit: WHOLE_LISTING } }, ]); }); - test("a listing that filled the biggest page the SDK can ask for is not called complete", async () => { + /** + * THIS TEST ASSERTED A REFUSAL AND WAS CHANGED ON PURPOSE. + * + * It was "a listing that filled the biggest page the SDK can ask for is not called complete", and + * it pinned the refusal this module made of any listing at {@link WHOLE_LISTING} rows. The reason + * it gave was true of the WRAPPER: "`ToolListParamsSchema` accepts no cursor and + * `getRawComposioTools` drops the response's `next_cursor`, so one page at the API's stated + * maximum is the largest listing expressible through this SDK". + * + * IT IS NOT TRUE OF THE VENDOR. `@composio/client`'s `ToolListParams` carries a `cursor` and its + * `ToolListResponse` carries `next_cursor` (0.1.0-alpha.76, `resources/tools.d.ts:421-432`, + * `:200-204`), and `./composio-adapter` now reads that client and follows the cursor to the end. + * A listing this long is an app with a lot of actions, and refusing it would be refusing a + * complete answer — which is what the same ceiling, one file over, was doing to the app picker + * on every single call. + * + * SO WHAT IS ASSERTED IS THE INVERSE, AT THE SAME BOUNDARY. The seam below hands back a full page + * because that is what the adapter's pager resolves to once it has read every page; this module's + * job is to commit it rather than to second-guess its length. The refusals this module still + * makes — an empty listing, an unreadable row, a listing that did not answer — are unmoved, and + * each has its own test above. + */ + test("a listing as long as the biggest page is committed rather than called a fragment", async () => { useComposioClient( recording({ listActions: async () => @@ -505,13 +529,9 @@ describe("listing an app's actions", () => { }).client, ); - // `ToolListParamsSchema` accepts no cursor and `getRawComposioTools` drops the response's - // `next_cursor`, so one page at the API's stated maximum is the largest listing expressible - // through this SDK. A page that came back full is therefore indistinguishable from a truncated - // one, and committing it would delete every action past the cut while reporting a success. - await expect(listTools({ url: "composio://gmail" })).rejects.toThrow( - /there may be more/i, - ); + const listed = await listTools({ url: "composio://gmail" }); + + expect(listed).toHaveLength(WHOLE_LISTING); }); test("an action arrives with its schema, its effect and its version", async () => { @@ -984,9 +1004,8 @@ describe("listing an app's actions", () => { test("a listing that failed with no wrapper still carries the vendor's sentence", async () => { /* - * `./composio-adapter`'s listing calls `getRawComposioTools`, which awaits - * `this.client.tools.list` with no try around it, so what lands here is `@composio/client`'s - * own error — see {@link unwrapped}. `refreshTools` writes this string into the row's + * `./composio-adapter`'s listing calls `client.tools.list` directly, with no try around it, so + * what lands here is `@composio/client`'s own error — see {@link unwrapped}. `refreshTools` writes this string into the row's * `lastError` for an administrator to read off the Plugins page, and what it used to write was * a status code followed by the entire response body. */ @@ -1521,10 +1540,12 @@ describe("listing an app's actions", () => { * AND THE VENDOR'S OWN EMPTY ANSWER IS STILL AN ANSWER, which is a decision this file shares * with `store.ts` rather than one it makes alone. * - * `@composio/core` does manufacture an empty listing out of a response it could not read — + * `@composio/core` used to manufacture an empty listing out of a response it could not read — * `getRawComposioTools` ends `if (!tools) { return []; }` (0.18.1, - * `src/models/Tools.ts:553-557`) — so the hazard of committing `[]` is real. It is answered at - * the layer that does the committing: `refreshTools` has its own empty-listing guard, which + * `src/models/Tools.ts:553-557`) — and that half is gone with the wrapper, because + * `./composio-adapter` refuses an answer that is not a page rather than emptying it. What is + * left is a listing Composio really did answer with no rows, and it is answered at the layer + * that does the committing: `refreshTools` has its own empty-listing guard, which * keeps every recorded action with its `effect`, `destructive` and `version` whenever an app * that holds actions lists none, and stamps no refresh * (`plugin-store.integration.test.ts`, "a refresh the vendor answered with no actions at all"). From 4e91a22cdda25c5521e53dd95c17b6f9519c903a Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:26:22 +0200 Subject: [PATCH 154/215] Name the connection kinds in the broker's own vocabulary, so no app has to be guessed at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app's connection flow was a single hard-coded choice, which is how one reading of "how somebody connects" came to stand for every app in the catalogue. BrokerConnection names the five readings instead — consent, self-registering, fields, no-auth and an unsupported one that says why — and BrokerApp now carries it, so the picker, the enable path and the connect screen all read the same derived fact rather than each assuming one. Types only. Nothing derives a connection yet, so composio-adapter's appOf still owes the new required field. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index d8302febb..3149265bc 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -13,6 +13,62 @@ * the import graph of every test that touches enablement. */ +/** The schemes whose secret a PERSON holds and types in, rather than one anybody registers. */ +export type FieldScheme = + | "API_KEY" + | "BASIC" + | "BEARER_TOKEN" + | "BASIC_WITH_JWT"; + +const FIELD_SCHEME_NAMES: readonly string[] = [ + "API_KEY", + "BASIC", + "BEARER_TOKEN", + "BASIC_WITH_JWT", +]; + +/** + * Whether a scheme recorded on a row is one whose secret a person types. + * + * Takes the column's own type — `string | null` — rather than a `FieldScheme`, because every + * caller is asking ABOUT a recorded value, and a signature demanding the answer first would push + * the same `includes` into four call sites. + */ +export function isFieldScheme(scheme: string | null): scheme is FieldScheme { + return scheme !== null && FIELD_SCHEME_NAMES.includes(scheme); +} + +/** + * One value Composio wants from the person connecting, as Composio itself describes it. + * + * Every field here is published per app rather than guessed: `is_secret` says which one to mask, + * and the description is written for the person filling it in ("Your Firecrawl API key, a token + * starting with fc-"). `name` is sent back on the wire verbatim and is never shown. + */ +export type BrokerField = { + name: string; + label: string; + help: string; + required: boolean; + secret: boolean; + default?: string; +}; + +/** + * How this deployment would connect somebody to an app — the one fact everything else reads. + * + * Derived once from the catalogue, recorded on the app's row at enable time, and read by the + * picker (which hides `unsupported`), by the enable path (which creates the config this names, or + * none) and by the connect screen (which draws a link or a form). Deriving it twice is how a + * single hard-coded choice came to leak into every app in the first place. + */ +export type BrokerConnection = + | { kind: "consent" } + | { kind: "self-registering" } + | { kind: "fields"; authScheme: FieldScheme } + | { kind: "no-auth" } + | { kind: "unsupported"; reason: string }; + /** * One app in the catalogue, as much of Composio's toolkit listing as anything here reads. * @@ -36,6 +92,8 @@ export type BrokerApp = { * made the choice this field exists to inform. */ actionCount: number; + /** How somebody would connect to it. See {@link BrokerConnection}. */ + connection: BrokerConnection; }; /** From e07fffd7eff14592a7788f9a6eea1c8397a2cdc3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:26:36 +0200 Subject: [PATCH 155/215] Record how each brokered app connects, and when a connection was last checked Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/drizzle/0030_composio_schemes.sql | 5 +++++ server/drizzle/meta/_journal.json | 7 +++++++ server/src/db/schema/plugins.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 server/drizzle/0030_composio_schemes.sql diff --git a/server/drizzle/0030_composio_schemes.sql b/server/drizzle/0030_composio_schemes.sql new file mode 100644 index 000000000..2f447e05e --- /dev/null +++ b/server/drizzle/0030_composio_schemes.sql @@ -0,0 +1,5 @@ +ALTER TABLE "mcp_servers" ADD COLUMN "auth_scheme" text;--> statement-breakpoint +UPDATE "mcp_servers" SET "auth_scheme" = 'OAUTH2' WHERE "provenance" = 'composio' AND "auth_scheme" IS NULL;--> statement-breakpoint +ALTER TABLE "composio_connections" ADD COLUMN "verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "composio_connections" ADD COLUMN "verified_at" timestamp with time zone;--> statement-breakpoint +UPDATE "composio_connections" SET "verified" = true, "verified_at" = "connected_at"; diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index eeaebb1af..af2a6cd22 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1788968143912, "tag": "0029_composio", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1789298766378, + "tag": "0030_composio_schemes", + "breakpoints": true } ] } diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index db1e8f82d..44d9cace2 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -76,6 +76,15 @@ export const mcpServers = pgTable("mcp_servers", { credentialId: uuid("credential_id").references(() => credentials.id, { onDelete: "restrict", }), + /** + * How this app connects, as it was resolved when somebody enabled it. + * + * Recorded rather than re-derived, because the catalogue is somebody else's and a vendor that + * starts publishing a new scheme for an app must not move live connections onto a different + * flow underneath them. Null on every row that predates this column, which is exactly the set + * that was created by the one path there was: Composio-managed OAuth. + */ + authScheme: text("auth_scheme"), /** What the deployment last heard back from it. `null` until the first successful listing. */ toolsRefreshedAt: timestamp("tools_refreshed_at", { withTimezone: true }), /** The last failure, kept so the Plugins page can say why a server has no tools. */ @@ -195,6 +204,10 @@ export const composioConnections = pgTable( connectedAt: timestamp("connected_at", { withTimezone: true }) .notNull() .defaultNow(), + /** Whether a real call was made with this connection and answered. See the verify path. */ + verified: boolean("verified").notNull().default(false), + /** When that check last passed, which is what the page reports instead of a present tense. */ + verifiedAt: timestamp("verified_at", { withTimezone: true }), updatedAt: updatedAt(), }, (table) => [ From 1e27703f5535b5d3cc37a4554d55b4a735368bc1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:27:58 +0200 Subject: [PATCH 156/215] Name the connection check on the trail, and say whose call it is Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/audit.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/src/audit.ts b/server/src/audit.ts index cb3c7a984..ce68c86bd 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -166,6 +166,24 @@ export const auditEventTypes = [ * was current when it was made. The client id, never the secret. */ "mcp.oauth_client_registered", + /* + * The vendor was asked whether one person's brokered account is really there, and what it said. + * + * THE ONE VENDOR CALL IN THIS DEPLOYMENT THAT BELONGS TO A PERSON RATHER THAN TO A BOT, which is + * why it is written down on its own instead of joining the `mcp.call_*` family. Every other vendor + * call on this trail is a Bot spending a grant: it arrives through the callback endpoint, it names + * the Bot that made it, and a reader counting calls per Bot accounts for all of them. This one is + * a step inside connect — no endpoint exposes it and no Bot is involved — so what the row names is + * the person whose account was asked about, and there is no Bot to name. + * + * WHICH IS THE PART A QUERY OVER `mcp.*` MUST NOT BE SURPRISED BY. Anything that reads a Bot out + * of each payload finds none here, and code that assumes one will either drop the row or + * mis-attribute it to whichever Bot was nearest. The row is shaped that way on purpose rather than + * borrowing a Bot to look uniform with its neighbours: a call somebody made about their own + * account, filed against a Bot that never ran, is the confidently wrong kind of entry that + * `mcp.call_failed` exists to keep off this trail. + */ + "mcp.connection_verified", /* * One person connected their own account to one server. * From 3721dcc2bc582f3d71bb96de4a4a65529694388a Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:38:20 +0200 Subject: [PATCH 157/215] Derive the field scheme union from the list the guard reads, rather than state the same set twice Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 3149265bc..3f7197101 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -13,19 +13,27 @@ * the import graph of every test that touches enablement. */ -/** The schemes whose secret a PERSON holds and types in, rather than one anybody registers. */ -export type FieldScheme = - | "API_KEY" - | "BASIC" - | "BEARER_TOKEN" - | "BASIC_WITH_JWT"; - -const FIELD_SCHEME_NAMES: readonly string[] = [ +/** + * The schemes whose secret a PERSON holds and types in, rather than one anybody registers. + * + * WRITTEN ONCE AND READ TWICE, BECAUSE A SECOND DECLARATION IS A WAY FOR THE TWO TO DISAGREE. The + * list is the fact and {@link FieldScheme} is derived from it, so the names the guard tests and the + * names the type admits cannot come apart. A hand-written union beside a hand-written array is the + * same set stated twice, and a member added to one of them and not the other typechecks perfectly. + * + * AND THE DISAGREEMENT FAILS OPEN, which is why it is worth a derivation rather than a comment + * asking the next person to keep both in step. {@link isFieldScheme} would answer false for a scheme + * the type calls valid, and an app whose secret a person types would be read as one nobody types — + * sent down the consent path, to a vendor screen that has nothing to ask them for. + */ +const FIELD_SCHEME_NAMES = [ "API_KEY", "BASIC", "BEARER_TOKEN", "BASIC_WITH_JWT", -]; +] as const; + +export type FieldScheme = (typeof FIELD_SCHEME_NAMES)[number]; /** * Whether a scheme recorded on a row is one whose secret a person types. @@ -35,7 +43,10 @@ const FIELD_SCHEME_NAMES: readonly string[] = [ * the same `includes` into four call sites. */ export function isFieldScheme(scheme: string | null): scheme is FieldScheme { - return scheme !== null && FIELD_SCHEME_NAMES.includes(scheme); + return ( + scheme !== null && + (FIELD_SCHEME_NAMES as readonly string[]).includes(scheme) + ); } /** From cf3cb023937b932ddb0e49f74534e64e56f9e3b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:39:31 +0200 Subject: [PATCH 158/215] Say what is actually singular about the connection check, and stop claiming a novelty the trail already had Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/audit.ts | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index ce68c86bd..a4f8693a7 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -167,21 +167,33 @@ export const auditEventTypes = [ */ "mcp.oauth_client_registered", /* - * The vendor was asked whether one person's brokered account is really there, and what it said. - * - * THE ONE VENDOR CALL IN THIS DEPLOYMENT THAT BELONGS TO A PERSON RATHER THAN TO A BOT, which is - * why it is written down on its own instead of joining the `mcp.call_*` family. Every other vendor - * call on this trail is a Bot spending a grant: it arrives through the callback endpoint, it names - * the Bot that made it, and a reader counting calls per Bot accounts for all of them. This one is - * a step inside connect — no endpoint exposes it and no Bot is involved — so what the row names is - * the person whose account was asked about, and there is no Bot to name. - * - * WHICH IS THE PART A QUERY OVER `mcp.*` MUST NOT BE SURPRISED BY. Anything that reads a Bot out - * of each payload finds none here, and code that assumes one will either drop the row or - * mis-attribute it to whichever Bot was nearest. The row is shaped that way on purpose rather than - * borrowing a Bot to look uniform with its neighbours: a call somebody made about their own - * account, filed against a Bot that never ran, is the confidently wrong kind of entry that - * `mcp.call_failed` exists to keep off this trail. + * One person's brokered account was exercised with a real call, to see whether it is really there. + * + * THE ONE EXECUTION OF AN APP'S OWN ACTION THAT HAPPENS OUTSIDE `callTool`, which is why it is + * written down on its own instead of joining the `mcp.call_*` family. Everything that surrounds a + * vendor call on the ordinary path is absent from this one: no grant is consulted, no policy is + * evaluated, no arguments are inspected, and no `mcp.call_succeeded`, `mcp.call_rejected` or + * `mcp.call_failed` row is left behind it. An action ran at a vendor and the trail's tool-call + * family says nothing about it, so this row is the whole of what is recorded. + * + * WHAT MAKES THAT SAFE IS THE SHAPE OF THE CALL RATHER THAN ANYBODY'S CARE. The action is fixed: + * the adapter picks it out of the metadata this deployment already recorded for the app, not out + * of anything a caller sent; it is sent with no arguments at all, so there is nothing for content + * inspection to have missed; and the only two callers are the connect step and a person + * re-checking their own connection. Those three properties are the protection. A later change + * that let the action, the arguments or the callers vary would not be loosening a check that is + * merely skipped here — it would be removing the reason the check can be skipped at all, and the + * one call in this deployment that reaches a vendor unexamined would start taking instructions. + * + * THE ROW NAMES A PERSON AND HAS NO BOT IN IT, which is not new on this trail and must not be + * read as such. `mcp.callback_refused` deliberately names none, and `mcp.account_connected` and + * `mcp.account_disconnected` are both filed against the person whose account it was, so code that + * reads a Bot out of every `mcp.*` payload was already wrong before this row existed. What IS new + * is a vendor action with no `mcp.call_*` row beside it: anybody reconciling this trail against + * an app's own logs has a call here to account for that the tool-call rows will never mention. + * Borrowing a Bot to make the row look uniform with its neighbours would answer that by lying — + * a call somebody made about their own account, filed against a Bot that never ran, is the + * confidently wrong kind of entry that `mcp.call_failed` exists to keep off this trail. */ "mcp.connection_verified", /* From a59a7bdad8bac376256bcfbbc52062d3b6b48e61 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:39:55 +0200 Subject: [PATCH 159/215] Ship the snapshot 0030 was missing, so the next generate diffs against the columns it added Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/drizzle/meta/0030_snapshot.json | 3242 ++++++++++++++++++++++++ server/src/db/schema/plugins.ts | 23 +- server/tests/migration-journal.test.ts | 32 + 3 files changed, 3294 insertions(+), 3 deletions(-) create mode 100644 server/drizzle/meta/0030_snapshot.json diff --git a/server/drizzle/meta/0030_snapshot.json b/server/drizzle/meta/0030_snapshot.json new file mode 100644 index 000000000..28d697159 --- /dev/null +++ b/server/drizzle/meta/0030_snapshot.json @@ -0,0 +1,3242 @@ +{ + "id": "e2ed6458-1ca9-4358-bff7-8663f0352146", + "prevId": "9038560c-f1da-4f48-bb9a-f489ec39409f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 44d9cace2..0766b95f5 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -81,8 +81,15 @@ export const mcpServers = pgTable("mcp_servers", { * * Recorded rather than re-derived, because the catalogue is somebody else's and a vendor that * starts publishing a new scheme for an app must not move live connections onto a different - * flow underneath them. Null on every row that predates this column, which is exactly the set - * that was created by the one path there was: Composio-managed OAuth. + * flow underneath them. + * + * THE VENDOR'S OWN SCHEME LITERAL, NOT A {@link BrokerConnection} KIND — `OAUTH2`, `DCR_OAUTH`, + * `API_KEY`, `BASIC`, `BEARER_TOKEN`, `BASIC_WITH_JWT`, `NO_AUTH`. Those two vocabularies name one + * fact, and this column is where a reader comes to find out which of them is written down, so it + * says: somebody looking here for `consent` or `fields` is reading the other one. Migration 0030 + * backfilled every row whose provenance is `composio` to `OAUTH2`, because managed OAuth was the + * only config this deployment ever created and `addBrokeredApp` writes the row only after that + * config stands. A null is therefore not an older brokered row — it is a row that is not brokered. */ authScheme: text("auth_scheme"), /** What the deployment last heard back from it. `null` until the first successful listing. */ @@ -204,7 +211,17 @@ export const composioConnections = pgTable( connectedAt: timestamp("connected_at", { withTimezone: true }) .notNull() .defaultNow(), - /** Whether a real call was made with this connection and answered. See the verify path. */ + /** + * Whether a real call was made with this connection and answered. See the verify path. + * + * TRUE ON EVERY ROW THAT PREDATES THIS COLUMN WITHOUT A PROBE BEHIND IT. Migration 0030 + * backfilled them to true with `verified_at = connected_at`, and no call was made to earn it: + * every one of those rows is a consent connection, which is verified by construction, because + * the only way it exists at all is that the vendor's own screen sent the person back connected. + * So on a backfilled row the timestamp is the moment of consent, not the moment of a check, and + * a reader treating every `verified_at` as "this connection answered then" would be wrong about + * exactly the rows that were here first. + */ verified: boolean("verified").notNull().default(false), /** When that check last passed, which is what the page reports instead of a present tense. */ verifiedAt: timestamp("verified_at", { withTimezone: true }), diff --git a/server/tests/migration-journal.test.ts b/server/tests/migration-journal.test.ts index f1471a5ea..6740f9b23 100644 --- a/server/tests/migration-journal.test.ts +++ b/server/tests/migration-journal.test.ts @@ -73,4 +73,36 @@ describe("the migration journal", () => { expect(journal.entries.map((entry) => entry.tag).sort()).toEqual(files); }); + + test("has a snapshot for every migration", async () => { + /* + * A third way this goes wrong quietly, and the one that bites the NEXT person rather than this + * one. `generate` diffs the schema against the newest snapshot in `meta/`, so a migration that + * ships without one leaves the previous snapshot as the newest: the columns it added are absent + * from what `generate` compares against, and the next migration re-emits them. That migration + * then fails on every database the first one already ran on, because `ADD COLUMN` is not + * conditional. A hand-written migration needs a hand-written snapshot for the same reason a + * generated one gets one for free. + */ + const directory = new URL("../drizzle/", import.meta.url); + const snapshots = new Set( + (await readdir(new URL("meta/", directory))).filter((name) => + name.endsWith("_snapshot.json"), + ), + ); + + const journal = JSON.parse( + await readFile(new URL("meta/_journal.json", directory), "utf8"), + ) as { entries: { idx: number; tag: string }[] }; + + const missing = journal.entries + .map((entry) => ({ + entry, + snapshot: `${entry.tag.split("_")[0]}_snapshot.json`, + })) + .filter(({ snapshot }) => !snapshots.has(snapshot)) + .map(({ entry, snapshot }) => `${entry.tag} has no meta/${snapshot}`); + + expect(missing).toEqual([]); + }); }); From 2f530612c52cec2624625fccaef2303de9e69aa2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:47:09 +0200 Subject: [PATCH 160/215] Report what an app's authorization config was created as, rather than leave every reader to re-derive it from the catalogue Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index a30e3addb..2dcd33763 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -148,6 +148,20 @@ export type ServerRecord = { * there is nothing for it to collect. */ dynamicClient: boolean; + /** + * How this server's authorization config was created, for the brokered rows that have one. + * + * WHAT WAS WRITTEN DOWN WHEN SOMEBODY ENABLED THE APP, NOT WHAT THE CATALOGUE PUBLISHES TODAY. + * The catalogue is the vendor's, and an app it starts advertising under a different scheme has + * not moved the config this deployment already created — so a reader deciding what a live + * connection does must read this and not a fresh listing. The `authScheme` column carries the + * same fact and the same warning about which vocabulary it holds: the vendor's own scheme + * literals (`OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH`, and the rest), never a + * {@link BrokerConnection} kind. + * + * Null is not an older brokered row. It is a row that is not brokered at all. + */ + authScheme: string | null; tools: ToolRecord[]; /** * Grants on tools this server no longer advertises. @@ -3089,6 +3103,7 @@ export function createPluginStore(options: PluginStoreOptions) { dynamicClient: entry?.auth.kind === "user-oauth" && entry.auth.clientRegistration === "dynamic", + authScheme: row.authScheme, tools: tools .filter((tool) => tool.serverId === row.id) .map((tool) => { From b71070e39fd306acffc07100dc291551702e94d2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:48:03 +0200 Subject: [PATCH 161/215] Read how an app connects off the catalogue, rather than assume one flow for all of them Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 62 ++++++++++++++- server/tests/composio-adapter.test.ts | 20 +++++ .../tests/composio-connection-kinds.test.ts | 77 +++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 server/tests/composio-connection-kinds.test.ts diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index edd164d09..7b179d063 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1,8 +1,10 @@ import { Composio } from "@composio/core"; import { type BrokerApp, + type BrokerConnection, BrokerRefusalError, type ComposioBroker, + type FieldScheme, } from "./broker"; import { type ComposioAction, @@ -155,6 +157,9 @@ type VendorToolkit = { slug?: unknown; name?: unknown; meta?: unknown; + no_auth?: unknown; + auth_schemes?: unknown; + composio_managed_auth_schemes?: unknown; }; /** @@ -630,6 +635,53 @@ async function pageOf( return { items: answered.items as Row[], nextCursor: answered.next_cursor }; } +/** The field schemes, in the order a person would rather meet them. */ +const FIELD_SCHEMES: readonly FieldScheme[] = [ + "API_KEY", + "BEARER_TOKEN", + "BASIC", + "BASIC_WITH_JWT", +]; + +/** The strings out of an `unknown`, which is all a vendor list promises. */ +function labelsOf(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** + * Which flow an app gets, and why that order. + * + * `no_auth` FIRST, AND IT IS NOT A PREFERENCE. Composio refuses an auth config for such a toolkit + * outright — "Cannot create an auth config for toolkit hackernews because it does not require + * authentication" — so an app flagged this way has no other reading available, whatever else it + * publishes beside it. + * + * Then managed OAuth, because it asks the person for nothing at all. Then self-registering OAuth, + * which asks nobody for anything: the client registers itself at consent time. Then a scheme whose + * secret the person already holds. What is left wants an OAuth client registered by whoever runs + * this deployment, and there is nowhere here to put one, so it is named rather than attempted. + */ +export function connectionOf(row: VendorToolkit): BrokerConnection { + if (row.no_auth === true) return { kind: "no-auth" }; + + const offered = labelsOf(row.auth_schemes); + const managed = labelsOf(row.composio_managed_auth_schemes); + if (managed.length > 0) return { kind: "consent" }; + if (offered.includes("DCR_OAUTH")) return { kind: "self-registering" }; + + const field = FIELD_SCHEMES.find((scheme) => offered.includes(scheme)); + if (field) return { kind: "fields", authScheme: field }; + + return { + kind: "unsupported", + reason: offered.length + ? `${offered.join(", ")} needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.` + : "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", + }; +} + /** * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. * @@ -796,7 +848,15 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { ); } - return { slug, name, description, logo, categories, actionCount }; + return { + slug, + name, + description, + logo, + categories, + actionCount, + connection: connectionOf(row), + }; } /** diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index ac38db7b8..449e947b5 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 } from "@composio/core"; import { + type BrokerConnection, BrokerRefusalError, brokerSentence, type ComposioBroker, @@ -32,6 +33,20 @@ import { * looked at what came back. */ +/** + * The connection every fixture in this file resolves to, because none of them publishes a scheme. + * + * These rows were written to exercise the field mapping and the cache, so they carry a slug, a name + * and a meta and nothing about authentication — which is itself a readable answer: an app Composio + * says nothing about the auth of is one no flow here could run. It is named once rather than + * retyped into five assertions, so a change to the sentence is a change in one place. + */ +const NO_SCHEME: BrokerConnection = { + kind: "unsupported", + reason: + "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", +}; + /** A vendor method nothing in a given test should reach, which says so rather than answering. */ function refuse(what: string) { return async (): Promise => { @@ -460,6 +475,7 @@ describe("the app catalogue", () => { logo: "https://logos.composio.dev/gmail.png", categories: ["Productivity", "Email"], actionCount: 63, + connection: NO_SCHEME, }, { slug: "sparse", @@ -468,6 +484,7 @@ describe("the app catalogue", () => { logo: null, categories: [], actionCount: 0, + connection: NO_SCHEME, }, ]); }); @@ -502,6 +519,7 @@ describe("holding the catalogue", () => { logo: null, categories: [], actionCount: 63, + connection: NO_SCHEME, }; test("a second listing inside the window asks the vendor nothing", async () => { @@ -572,6 +590,7 @@ describe("holding the catalogue", () => { logo: null, categories: [], actionCount: 12, + connection: NO_SCHEME, }, ]); }); @@ -2127,6 +2146,7 @@ describe("a catalogue that might be a fragment", () => { logo: null, categories: ["Productivity"], actionCount: 63, + connection: NO_SCHEME, }, ]); }); diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts new file mode 100644 index 000000000..2baf6de42 --- /dev/null +++ b/server/tests/composio-connection-kinds.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { connectionOf } from "../src/plugins/composio-adapter"; + +/** + * Which flow an app gets, decided from what Composio's own catalogue publishes. + * + * Every case here is a real row measured against the live catalogue on 2026-09-13. The ordering + * is the whole of the logic: `no_auth` wins outright because Composio REFUSES an auth config for + * such a toolkit, managed OAuth beats everything else because it asks the person for nothing, and + * a scheme this deployment cannot drive is named as unsupported rather than attempted. + */ +test("an app Composio holds credentials for gets the consent flow", () => { + expect( + connectionOf({ + slug: "gmail", + auth_schemes: ["OAUTH2"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "consent" }); +}); + +test("managed OAuth wins over a key the app also accepts", () => { + expect( + connectionOf({ + slug: "linear", + auth_schemes: ["OAUTH2", "API_KEY"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "consent" }); +}); + +test("OAuth that registers itself needs nobody's credentials", () => { + expect( + connectionOf({ + slug: "linear_mcp", + auth_schemes: ["DCR_OAUTH"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "self-registering" }); +}); + +test("an app the person holds a key for asks for fields", () => { + expect( + connectionOf({ + slug: "perplexityai", + auth_schemes: ["API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "fields", authScheme: "API_KEY" }); +}); + +test("no_auth beats every scheme beside it, because Composio refuses a config for one", () => { + expect( + connectionOf({ + slug: "gemini", + no_auth: true, + auth_schemes: ["NO_AUTH", "API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "no-auth" }); +}); + +test("an app wanting this deployment's own OAuth client is unsupported, and says so", () => { + const connection = connectionOf({ + slug: "docusign", + auth_schemes: ["OAUTH2"], + composio_managed_auth_schemes: [], + }); + expect(connection.kind).toBe("unsupported"); + expect(connection.kind === "unsupported" && connection.reason).toContain( + "its own OAuth client", + ); +}); + +test("an app publishing nothing readable is unsupported rather than guessed at", () => { + expect(connectionOf({ slug: "mystery" }).kind).toBe("unsupported"); +}); From 5938d9f9d349f1af5e0426de6e0f60d0f75b0220 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:48:05 +0200 Subject: [PATCH 162/215] Ask an app what it wants typed in, take the values, and re-check the key on demand Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/mutations.ts | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index 4718a624e..e4d282e8b 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -334,3 +334,105 @@ export function removeSkillMutationOptions(queryClient: QueryClient) { onSuccess: () => invalidatePlugins(queryClient), }); } + +/** + * One value Composio wants from the person connecting, as Composio itself describes it. + * + * Declared here rather than guessed at a form: the vendor publishes the list per app, `secret` says + * which one to mask, and `help` is written for the person filling it in. `name` goes back on the + * wire verbatim and is never shown. + */ +export type BrokerField = { + name: string; + label: string; + help: string; + required: boolean; + secret: boolean; + default?: string; +}; + +/** + * Ask what an app wants typed in, for the apps nobody consents to. + * + * Most Composio apps are not connected through a consent screen — the person holds an API key and + * types it in — so the same connect route answers a field list on the first press and takes the + * values on the second. Nothing is written by this half: it is a question about the app, not about + * anybody's account, which is why it refetches nothing. + */ +export function brokeredConnectionFieldsMutationOptions() { + return mutationOptions({ + mutationFn: (serverId: string): Promise => + client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connect`, + "fields", + { + method: "POST", + fallback: "That app could not be asked what it needs.", + }, + ), + }); +} + +/** + * Finish that connection with what the person typed. + * + * The values are passed to the mutation and held nowhere else — no query cache, no router state, no + * local storage. They are somebody's own key: the request body is the whole of their life in this + * app, and putting them anywhere a later render could read them back would be keeping a credential + * we were only ever asked to forward. + * + * Answers with the body rather than a bare success, because Composio does not check a submitted key. + * `connected` is only that the vendor accepted the row; `verified` is whether a real call was made + * with it, and a screen says different things about the two. + */ +export function connectBrokeredWithFieldsMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (variables: { + serverId: string; + values: Record; + }): Promise<{ connected: boolean; verified: boolean }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(variables.serverId)}/connect`, + { + method: "POST", + body: { values: variables.values }, + fallback: "That account could not be connected.", + }, + ); + return (await response.json()) as { + connected: boolean; + verified: boolean; + }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * Spend one read-only call at the vendor to find out whether a key still works. + * + * A button rather than something a page does on its own. Verifying on every render would spend the + * person's own rate limit at the vendor to redraw a word, so the check happens when somebody asks + * for it and the answer is recorded with the time it was taken. + */ +export function recheckBrokeredConnectionMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async ( + serverId: string, + ): Promise<{ verified: boolean; verifiedAt: string | null }> => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection/recheck`, + { method: "POST", fallback: "That connection could not be checked." }, + ); + return (await response.json()) as { + verified: boolean; + verifiedAt: string | null; + }; + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} From 5458097c1fd21250cd6a86cff62833d47396b771 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:48:05 +0200 Subject: [PATCH 163/215] Offer only the apps this deployment can connect, rather than the whole catalogue Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 14 +++++++-- server/tests/plugin-routes.test.ts | 48 +++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index f7bab51e3..665713f40 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -524,14 +524,24 @@ export function createPluginRoutes( const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); return context.json({ error: refusal.error }, refusal.status); } + /* + * AN APP THAT CANNOT BE CONNECTED IS NOT AN APP TO OFFER. Every kind but this one ends at a + * person with a working account; `unsupported` ends at an administrator pressing Add and + * meeting Composio's refusal, because the OAuth client it wants is one this deployment has + * nowhere to hold. Hidden here rather than at the vendor: the filter is a fact about what this + * deployment can drive, not about what Composio publishes. + */ + const connectable = directory.filter( + (candidate) => candidate.connection.kind !== "unsupported", + ); const term = (context.req.query("q") ?? "").trim().toLowerCase(); const matched = term - ? directory.filter((app) => + ? connectable.filter((app) => [app.slug, app.name, app.description].some((field) => field.toLowerCase().includes(term), ), ) - : directory; + : connectable; const enabled = new Set( (await store.serverUrls()) diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 452feade2..b29601ceb 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; import { ServerRowAmbiguousError } from "../src/plugins/access"; +import type { BrokerApp } from "../src/plugins/broker"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -565,7 +566,7 @@ describe("granting a Bot itself", () => { * The no-broker answer is a 503 naming the setting rather than an empty list: an empty directory * and an absent one are different facts, and only one of them has a remedy. */ -const DIRECTORY = [ +const DIRECTORY: BrokerApp[] = [ { slug: "slack", name: "Slack", @@ -573,6 +574,7 @@ const DIRECTORY = [ logo: null, categories: ["communication"], actionCount: 63, + connection: { kind: "consent" }, }, { slug: "gmail", @@ -581,6 +583,7 @@ const DIRECTORY = [ logo: null, categories: ["communication"], actionCount: 24, + connection: { kind: "consent" }, }, { slug: "linear", @@ -589,6 +592,26 @@ const DIRECTORY = [ logo: null, categories: ["project-management"], actionCount: 18, + connection: { kind: "consent" }, + }, + { + /* + * The fourth app is one Composio publishes and this deployment cannot drive: connecting it + * wants an OAuth application registered by whoever runs the deployment, and there is nowhere + * here to keep one. Fifty-six of the catalogue's apps are this, which is why the fixture + * carries one rather than pretending the catalogue is uniform. + */ + slug: "docusign", + name: "DocuSign", + description: "Send documents for signature.", + logo: null, + categories: ["documents"], + actionCount: 31, + connection: { + kind: "unsupported", + reason: + "DocuSign needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.", + }, }, ]; @@ -667,6 +690,29 @@ describe("the Composio directory", () => { ).toEqual(["slack"]); }); + test("an app this deployment could not connect is never offered", async () => { + /* + * COMPOSIO PUBLISHES 1540 APPS AND FIFTY-SIX OF THEM CANNOT BE CONNECTED FROM HERE: they want + * an OAuth client registered by whoever runs the deployment, and this deployment holds nowhere + * to put one. Listed, they are a row an administrator presses Add on and meets the vendor's + * refusal at — a dead end offered as a choice. Hidden in the route rather than asked of the + * vendor, because which apps are connectable is a fact about what this deployment can drive, + * not about what Composio publishes. + */ + const { app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + ); + + expect(response.status).toBe(200); + const slugs = (await response.json()).apps.map( + (entry: { slug: string }) => entry.slug, + ); + expect(slugs).not.toContain("docusign"); + expect(slugs).toEqual(["slack", "gmail", "linear"]); + }); + test("an app is enabled by the url of the row, not by the row's id", async () => { // Which app a row is comes off its url and only off its url, because that is where the // transport reads it from. An id read as an app name is a different question wearing the same From 0e3b281b779ecebffe6107e04148b88559a76dfc Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:58:45 +0200 Subject: [PATCH 164/215] Search the directory in the test too, so the unconnectable app cannot return through the other branch Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-routes.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index b29601ceb..d1d3890f8 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -711,6 +711,14 @@ describe("the Composio directory", () => { ); expect(slugs).not.toContain("docusign"); expect(slugs).toEqual(["slack", "gmail", "linear"]); + + // And the search branch reads the same filtered list, not the raw directory. Searching is what + // an administrator does to a 1540-app picker, so a term that names the unconnectable app is + // the request most likely to hand one back. + const searched = await app.request( + "http://openbot.test/api/plugins/composio/apps?q=docu", + ); + expect((await searched.json()).apps).toEqual([]); }); test("an app is enabled by the url of the row, not by the row's id", async () => { From 65f6b173c353986e18b7be087975dde611f0d64d Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 13:59:21 +0200 Subject: [PATCH 165/215] Rank the field schemes through a map the compiler counts, rather than a second hand-written list Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 42 +++++++++++++++---- .../tests/composio-connection-kinds.test.ts | 14 +++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 7b179d063..eca7af9d1 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -5,6 +5,7 @@ import { BrokerRefusalError, type ComposioBroker, type FieldScheme, + isFieldScheme, } from "./broker"; import { type ComposioAction, @@ -635,13 +636,34 @@ async function pageOf( return { items: answered.items as Row[], nextCursor: answered.next_cursor }; } -/** The field schemes, in the order a person would rather meet them. */ -const FIELD_SCHEMES: readonly FieldScheme[] = [ - "API_KEY", - "BEARER_TOKEN", - "BASIC", - "BASIC_WITH_JWT", -]; +/** + * Where each field scheme sits in the order a person would rather meet them. + * + * A RECORD KEYED BY {@link FieldScheme} RATHER THAN A LIST OF THEM, because the compiler counts the + * keys of a record and counts nothing at all about a list. This was a `readonly FieldScheme[]` + * spelling the same four names a second time, which is precisely what the comment on + * {@link FIELD_SCHEME_NAMES} argues against: the same set stated twice, where a member added to one + * of them and not the other typechecks perfectly. An element type refuses a name that is not a + * scheme and says nothing whatever about a scheme left out. + * + * AND THE ONE LEFT OUT FAILS OPEN, MORE QUIETLY HERE THAN THERE. A fifth scheme added to the names + * and not given a rank here would match nothing in the pick below, so every app publishing it would + * fall through to `unsupported` — and the directory route hides an unsupported app, so those apps + * would simply leave the picker: no refusal for anyone to read, no operator sentence, and no failing + * test. `Record` does not compile until the new scheme has a place, which is + * the whole of the protection, and the reason the pick goes through {@link isFieldScheme} rather + * than through a second list of names. + * + * THE ORDER ITSELF IS THE PERSON'S AND NOT THE WIRE'S, and it is unchanged: a plain key first, + * because it is the one they are likeliest to already hold, then the other three ways of spelling a + * secret they have to go and assemble. + */ +const FIELD_SCHEME_ORDER: Record = { + API_KEY: 0, + BEARER_TOKEN: 1, + BASIC: 2, + BASIC_WITH_JWT: 3, +}; /** The strings out of an `unknown`, which is all a vendor list promises. */ function labelsOf(value: unknown): string[] { @@ -671,7 +693,11 @@ export function connectionOf(row: VendorToolkit): BrokerConnection { if (managed.length > 0) return { kind: "consent" }; if (offered.includes("DCR_OAUTH")) return { kind: "self-registering" }; - const field = FIELD_SCHEMES.find((scheme) => offered.includes(scheme)); + const field = offered + .filter((scheme) => isFieldScheme(scheme)) + .sort( + (left, right) => FIELD_SCHEME_ORDER[left] - FIELD_SCHEME_ORDER[right], + )[0]; if (field) return { kind: "fields", authScheme: field }; return { diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index 2baf6de42..81cdb0f05 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -39,6 +39,20 @@ test("OAuth that registers itself needs nobody's credentials", () => { ).toEqual({ kind: "self-registering" }); }); +/** + * A consent screen asks the person for nothing and a key asks them to go and find one, so an app + * offering both should never send them looking. + */ +test("an app offering both a self-registering consent and a key prefers the consent", () => { + expect( + connectionOf({ + slug: "x", + auth_schemes: ["DCR_OAUTH", "API_KEY"], + composio_managed_auth_schemes: [], + }), + ).toEqual({ kind: "self-registering" }); +}); + test("an app the person holds a key for asks for fields", () => { expect( connectionOf({ From d7e66b69fbb833d5b43c1a4f2d8f290074f5ef03 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:04:08 +0200 Subject: [PATCH 166/215] Take the resolved connection kind at the enable seam, rather than derive a second answer for it The caller has already resolved which flow an app uses from the catalogue row the administrator chose. Deriving it a second time inside the broker is a second answer, and the two are free to disagree. It is required rather than optional on purpose: an optional field would let a caller that forgot it fall back to one flow for every app, which is the defect this change exists to remove. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 3f7197101..53b35ba6e 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -130,7 +130,17 @@ export type ComposioBroker = { * administrators can press the button together. An implementation that created a second config * on the second call would leave a person's existing connections pointing at the first one. */ - ensureAuthConfig(config: { toolkit: string; name: string }): Promise; + ensureAuthConfig(config: { + toolkit: string; + name: string; + /** + * Which flow this app was resolved to, which decides what is created and whether anything is. + * + * Passed in rather than read here, because the caller has already resolved it from the + * catalogue row the administrator chose, and a second derivation is a second answer. + */ + connection: BrokerConnection; + }): Promise; /** * Drop this deployment's own auth configs for the app, which is what removing an app has to do. * From f5ef9fee3f16ee673bc9afe9c2ecd25a0be2911e Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:04:51 +0200 Subject: [PATCH 167/215] Report when a brokered connection was last checked, rather than let connectedAt stand in for a present tense Composio never re-checks a submitted API key: it answers ACTIVE for as long as the row exists, so "connected" drawn off connectedAt asserts something this deployment last knew on the day the row was written. brokeredConnectionsFor now carries verified and verifiedAt, so the page can say when the claim was last earned instead. verifiedAt keeps null as null where connectedAt collapses to "". Null is reachable on that column and it means never checked, which is not the same fact as checked at a time nobody recorded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 2dcd33763..d69288179 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3741,14 +3741,38 @@ export function createPluginStore(options: PluginStoreOptions) { * none that it tells us about, and the field exists to record what the vendor said it granted * rather than what we suppose. It is returned all the same, so the shape matches what * {@link connectionsFor} answers and one screen can draw both kinds of row. + * + * `verified` AND `verifiedAt` COME ALONG BECAUSE "connected" IS NOT A PRESENT TENSE HERE. + * Composio never re-checks a key somebody typed in: it answers ACTIVE for as long as the row + * exists, whatever the vendor on the other side now thinks of that credential. So a page drawn + * off `connectedAt` alone would assert something this deployment has not known since the day it + * was written. These two fields are what lets it say when the claim was last earned instead — + * "connected with a key you provided, last checked 13 Sep" — and a consent connection, which + * has nothing to re-check, reads differently off the same pair. + * + * `verifiedAt` STAYS NULL WHERE IT IS NULL, unlike `connectedAt`, which collapses to `""` + * because a row cannot exist without one and the fallback is unreachable. Null here is + * reachable and it means something: never checked. Folding it into `""` would hand the page a + * row that was checked at a time nobody recorded, which is a different fact and not one this + * table ever holds. Note also what {@link composioConnections.verified} sets out about the rows + * migration 0030 backfilled: their `verifiedAt` is the moment of consent, not the moment of a + * probe, so a caller must not read every timestamp here as "this connection answered then". */ - async brokeredConnectionsFor( - userId: string, - ): Promise<{ serverId: string; scope: string; connectedAt: string }[]> { + async brokeredConnectionsFor(userId: string): Promise< + { + serverId: string; + scope: string; + connectedAt: string; + verified: boolean; + verifiedAt: string | null; + }[] + > { const rows = await database .select({ serverId: mcpServers.id, connectedAt: composioConnections.connectedAt, + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, }) .from(composioConnections) .innerJoin( @@ -3762,6 +3786,8 @@ export function createPluginStore(options: PluginStoreOptions) { serverId: row.serverId, scope: "", connectedAt: iso(row.connectedAt) ?? "", + verified: row.verified, + verifiedAt: iso(row.verifiedAt), })); }, From 8bf1ac05a1035b59317f2d64bb330a41222c6c71 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:05:05 +0200 Subject: [PATCH 168/215] Pin no_auth ahead of a managed scheme, for the two apps that publish both Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/composio-connection-kinds.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index 81cdb0f05..2828dbe8d 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -74,6 +74,21 @@ test("no_auth beats every scheme beside it, because Composio refuses a config fo ).toEqual({ kind: "no-auth" }); }); +/** + * Two of the thirty-four no-auth apps publish a managed scheme beside the flag, and they are the + * only rows this precedence decides. Read as managed, they get a config Composio refuses outright. + */ +test("no_auth beats managed OAuth, because a config for one is refused however it is asked for", () => { + expect( + connectionOf({ + slug: "hackernews", + no_auth: true, + auth_schemes: ["NO_AUTH", "OAUTH2"], + composio_managed_auth_schemes: ["OAUTH2"], + }), + ).toEqual({ kind: "no-auth" }); +}); + test("an app wanting this deployment's own OAuth client is unsupported, and says so", () => { const connection = connectionOf({ slug: "docusign", From daff963965df7a07102b85f04eb82de28c352594 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:06:01 +0200 Subject: [PATCH 169/215] Answer a failed enable with the reason it failed, rather than with nothing Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 14 +++++++++- server/tests/plugin-routes.test.ts | 44 +++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 665713f40..309cfec2e 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -629,7 +629,19 @@ export function createPluginRoutes( if (isDeploymentFault(error)) { return context.json({ error: deploymentFaultSentence(error) }, 409); } - throw error; + /* + * 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. + */ + const refusal = brokerRefusal( + error, + `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); } }); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index d1d3890f8..774bd637b 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; import { ServerRowAmbiguousError } from "../src/plugins/access"; -import type { BrokerApp } from "../src/plugins/broker"; +import { type BrokerApp, BrokerRefusalError } from "../src/plugins/broker"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -621,6 +621,13 @@ function directoryApp( role: "admin" | "user" = "admin", /** What this deployment has already added, which is where `enabled` comes from. */ servers: Array<{ id: string; url: string }> = [], + /** + * How enabling fails, for the cases that are about the failing half. Null is the store that + * works: it records the call and answers a row. Enabling is where the second half of this + * surface's failures are decided, and none of them could be tested while the only store here + * succeeded. + */ + enable: (() => Promise) | null = null, ) { const added: Array<{ slug: string; title: string; by: string }> = []; const store = { @@ -634,6 +641,7 @@ function directoryApp( title: string; by: string; }) => { + if (enable) return enable(); added.push(input); return { id: `composio-${input.slug}`, url: `composio://${input.slug}` }; }, @@ -856,6 +864,40 @@ describe("the Composio directory", () => { expect(refusal).toContain("COMPOSIO_API_KEY"); expect(refusal).not.toContain("fetch failed"); }); + + test("a refusal while enabling reaches the administrator who pressed the button", async () => { + /* + * THE DEAD BUTTON. An administrator pressed Add and Composio answered "Default auth config not + * found for toolkit linear_mcp. Composio does not have managed credentials for this toolkit." — + * everything needed to explain the failure, and a step somebody here can take. The route mapped + * that sentence for the directory read and for nothing else, so a refusal out of enabling left + * as an unhandled throw: a bodyless 500, and the browser's own "That app could not be added." + * over the top of a reason that existed. + * + * 503 with the refusal's own words, because a refusal this deployment authored is not a third + * party being down and the generic sentence would send an administrator to check a key that is + * fine. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw new BrokerRefusalError( + "Linear was not enabled: it needs its own OAuth client, and Composio holds no managed credentials for it.", + ); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "linear" }), + }, + ); + + expect(response.status).toBe(503); + expect(((await response.json()) as { error: string }).error).toContain( + "its own OAuth client", + ); + }); }); /** From fcad76624ad340d6e8fc096d98be94856591eb95 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:16:01 +0200 Subject: [PATCH 170/215] Say that the brokered listing is a superset of the vault one, and that the pair separates a checked key from an unchecked one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 15 ++++++++++----- server/src/plugins/store.ts | 13 +++++++++---- server/tests/plugin-store.integration.test.ts | 8 +++++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 0766b95f5..be87d87c9 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -216,11 +216,16 @@ export const composioConnections = pgTable( * * TRUE ON EVERY ROW THAT PREDATES THIS COLUMN WITHOUT A PROBE BEHIND IT. Migration 0030 * backfilled them to true with `verified_at = connected_at`, and no call was made to earn it: - * every one of those rows is a consent connection, which is verified by construction, because - * the only way it exists at all is that the vendor's own screen sent the person back connected. - * So on a backfilled row the timestamp is the moment of consent, not the moment of a check, and - * a reader treating every `verified_at` as "this connection answered then" would be wrong about - * exactly the rows that were here first. + * every one of the rows it touched is a consent connection, which is verified by construction, + * because the only way it exists at all is that the vendor's own screen sent the person back + * connected. So on a backfilled row the timestamp is the moment of consent, not the moment of a + * check, and a reader treating every `verified_at` as "this connection answered then" would be + * wrong about exactly the rows that were here first. + * + * That is a claim about the backfill and not about the column in general: the writer which + * records a consent connection is expected to set `verified` itself, so the meaning holds for + * rows written since as well, rather than letting them land on the `false` default that the + * never-checked key rows use. */ verified: boolean("verified").notNull().default(false), /** When that check last passed, which is what the page reports instead of a present tense. */ diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d69288179..463ae5135 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3739,16 +3739,21 @@ export function createPluginStore(options: PluginStoreOptions) { * * `scope` IS EMPTY for the reason {@link confirmBrokeredConnection} sets out: Composio grants * none that it tells us about, and the field exists to record what the vendor said it granted - * rather than what we suppose. It is returned all the same, so the shape matches what - * {@link connectionsFor} answers and one screen can draw both kinds of row. + * rather than what we suppose. It is returned all the same, so the fields this shares with + * {@link connectionsFor} — `serverId`, `scope`, `connectedAt` — line up and one screen can draw + * both kinds of row. What comes back here is a SUPERSET of that shape rather than the same one: + * `verified` and `verifiedAt` ride along too, and only on a brokered row, because only a + * brokered row is a thing this deployment can re-check. * * `verified` AND `verifiedAt` COME ALONG BECAUSE "connected" IS NOT A PRESENT TENSE HERE. * Composio never re-checks a key somebody typed in: it answers ACTIVE for as long as the row * exists, whatever the vendor on the other side now thinks of that credential. So a page drawn * off `connectedAt` alone would assert something this deployment has not known since the day it * was written. These two fields are what lets it say when the claim was last earned instead — - * "connected with a key you provided, last checked 13 Sep" — and a consent connection, which - * has nothing to re-check, reads differently off the same pair. + * "connected with a key you provided, last checked 13 Sep". What the pair separates is a + * CHECKED key connection from an unchecked one, and nothing more: which KIND of connection a + * row is comes from the app's recorded {@link ServerRecord.authScheme}, which the page branches + * on first, and not from anything answered here. * * `verifiedAt` STAYS NULL WHERE IT IS NULL, unlike `connectedAt`, which collapses to `""` * because a row cannot exist without one and the fallback is unreachable. Null here is diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 205a6dc95..d115b90dc 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6542,9 +6542,11 @@ test("a brokered disconnect with nothing to disconnect files nothing in the trai /** * A brokered connection is visible to the person who made it, under the server row's own id. * - * CRITERION. `brokeredConnectionsFor` answers one row per app this person has connected, shaped - * exactly as `connectionsFor` answers, and the `serverId` in it is the id of the `mcp_servers` row - * whose url names that app. + * CRITERION. `brokeredConnectionsFor` answers one row per app this person has connected, carrying + * the `serverId`, `scope` and `connectedAt` that `connectionsFor` answers with — so one screen + * draws both kinds of row — plus the `verified` and `verifiedAt` that only a brokered row has, + * because only a brokered row is a thing this deployment can re-check. The `serverId` in it is the + * id of the `mcp_servers` row whose url names that app. * * REASON. `connectionsFor` selects from the vault's join table alone, so a brokered connection was * invisible to the browser and the settings screen could not honestly say whether somebody was From 54b89abf3b6adaf3cb88d6afef26fe7b3c42f1b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:18:12 +0200 Subject: [PATCH 171/215] Log the enable failure nobody explained, and say what the two connection reads really share The comment over the connections union claimed the brokered read answers in the shape the vault read answers; it has not since `verified` and `verifiedAt` were added. Reword it to say what is true: the route concatenates without rewriting, the common fields line up so one row template draws both kinds, and the extra pair travels because a brokered row is the only one with anything to re-check. Catching everything on the enable route fixed the answer and silenced the log. An unrecognised programmer error there is now a 502 with the generic sentence and no trace anywhere, so when neither `brokerSentence` nor `vendorSentence` has anything to say, print the structured line the callback's own unrecordable grant already prints -- slug, what the person was told, the error stringified, and nothing from the request. The enable-refusal case threw a "needs its own OAuth client" refusal against `linear`, whose fixture row is an ordinary consent app that could never raise it, so the row, the sentence and the docblock were three different apps. Press `docusign` instead, which is the fixture's unsupported row and reachable because this POST searches the unfiltered directory. And pin the 409 branch, which nothing pinned: a deployment fault out of `addBrokeredApp` must keep its own sentence rather than fall into the broker bucket one branch below. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 39 ++++++++++++++++++++++++--- server/tests/plugin-routes.test.ts | 43 ++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 309cfec2e..a3fd2e60c 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -637,6 +637,34 @@ export function createPluginRoutes( * browser fell back to its own "That app could not be added". Everything that explains the * failure existed; nothing carried it the last step. */ + /* + * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a + * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed + * where an operator could find it. Catching everything fixed the answer and silenced the log, + * and the failure that needs the log most is the one left here — not a refusal this + * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one + * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both + * null is a fault nobody has explained, most often a programmer error on this side, and it + * would otherwise leave only the generic "Composio said nothing about why" in a browser + * nobody is reading a console from. + * + * The same shape and the same restraint as the connection-not-recorded log below: the slug, + * because it is the app an operator is about to be asked about; what the person was told, so + * the console line and the support request can be matched up; and the error stringified, + * never spread, logged as an object or reached into. No key, no vendor response, no request + * body. + */ + if (brokerSentence(error) === null && vendorSentence(error) === null) { + console.error( + JSON.stringify({ + type: "composio-app-not-enabled", + slug: app.slug, + note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + error: String(error), + }), + ); + } + const refusal = brokerRefusal( error, `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, @@ -663,9 +691,14 @@ export function createPluginRoutes( * so the page could only either say "Not connected" over a live account or say nothing at all, * and it chose to say nothing. * - * A brokered row is an ordinary connection — `brokeredConnectionsFor` answers in the shape - * `connectionsFor` answers, deliberately — so nothing here marks which source a row came from - * and nothing downstream asks. + * CONCATENATED WITHOUT REWRITING, because the fields a settings page draws from — the server + * id, the scope, the date — line up across the two reads, and one row template can draw either + * kind. What does not line up is the pair `brokeredConnectionsFor` adds, `verified` and + * `verifiedAt`, so the list that leaves here is not uniform. That pair travels because a + * brokered row is the only one with anything to re-check: this deployment holds no secret for + * it, only a note that Composio said yes, and that note can drift when somebody ends the + * connection in Composio's own dashboard. A held connection has no equivalent question, so its + * rows carry no such fields, and their absence is what tells a reader which kind a row is. * * SORTED, so two requests answer in the same order. Each read is ordered by server id within * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 774bd637b..c8e42143a 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -877,10 +877,15 @@ describe("the Composio directory", () => { * 503 with the refusal's own words, because a refusal this deployment authored is not a third * party being down and the generic sentence would send an administrator to check a key that is * fine. + * + * DOCUSIGN IS THE APP PRESSED HERE because it is the fixture's `unsupported` row, and a refusal + * of exactly this kind is the one it would really raise. The POST can still reach it: the route + * searches the unfiltered `directory`, not the `connectable` subset the GET answers with, so + * the last thing between an administrator and a dead button is this sentence. */ const { app } = directoryApp(undefined, "admin", [], async () => { throw new BrokerRefusalError( - "Linear was not enabled: it needs its own OAuth client, and Composio holds no managed credentials for it.", + "DocuSign was not enabled: it needs its own OAuth client, and Composio holds no managed credentials for it.", ); }); @@ -889,7 +894,7 @@ describe("the Composio directory", () => { { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ slug: "linear" }), + body: JSON.stringify({ slug: "docusign" }), }, ); @@ -898,6 +903,40 @@ describe("the Composio directory", () => { "its own OAuth client", ); }); + + test("a deployment fault while enabling is still a 409 in its own words", async () => { + /* + * WHAT THE BROKER MAPPING MUST NOT SWALLOW. The catch on this route now ends in + * `brokerRefusal`, and where that mapping sits decides the answer for three whole classes of + * failure: the deployment faults — an ambiguous server row, a broken invariant, a query this + * database refused — are recognised one branch ABOVE it and answered 409 with the sentence + * they carry. Move the broker mapping up and every one of them turns into a 502 saying + * "Composio said nothing about why" about a failure Composio had no part in, which would send + * an administrator to check a key that is fine and hide the row they actually have to fix. + * + * Nothing else on this route pins that ordering, so this is the case that stops a later reader + * tidying the branches into the wrong sequence. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw new ServerRowAmbiguousError( + "Two rows claim the url composio://slack, and this deployment cannot tell which one an enable belongs to.", + ); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(409); + const refusal = ((await response.json()) as { error: string }).error; + expect(refusal).toContain("Two rows claim the url"); + expect(refusal).not.toContain("Composio said nothing about why"); + }); }); /** From f6c36fe5e5a59b1b6d55790a87efd756e8482ddb Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:23:46 +0200 Subject: [PATCH 172/215] Name the gap the consent writer still leaves, and pin the failure nobody explained Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 10 +++++---- server/src/plugins/routes.ts | 5 ++++- server/tests/plugin-routes.test.ts | 34 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index be87d87c9..13704ad39 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -222,10 +222,12 @@ export const composioConnections = pgTable( * check, and a reader treating every `verified_at` as "this connection answered then" would be * wrong about exactly the rows that were here first. * - * That is a claim about the backfill and not about the column in general: the writer which - * records a consent connection is expected to set `verified` itself, so the meaning holds for - * rows written since as well, rather than letting them land on the `false` default that the - * never-checked key rows use. + * That is a claim about the backfill and not about the column in general. For the meaning to + * hold generally the writer which records a consent connection has to set `verified` itself, + * and today it does not: `confirmBrokeredConnection` inserts on the defaults, so a consent + * connection made since the backfill reads `false` with a null `verified_at` — the same pair a + * key connection that was never checked reads. The extracted connection writer is where that is + * settled. */ verified: boolean("verified").notNull().default(false), /** When that check last passed, which is what the page reports instead of a present tense. */ diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index a3fd2e60c..f7408cb93 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -698,7 +698,10 @@ export function createPluginRoutes( * brokered row is the only one with anything to re-check: this deployment holds no secret for * it, only a note that Composio said yes, and that note can drift when somebody ends the * connection in Composio's own dashboard. A held connection has no equivalent question, so its - * rows carry no such fields, and their absence is what tells a reader which kind a row is. + * rows carry no such fields, and their absence is what tells the two READS apart. It is not how + * a reader learns how an app connects: that is the app's recorded `authScheme`, and a page + * asking this list instead would be deriving a second answer to a question the row already + * carries. * * SORTED, so two requests answer in the same order. Each read is ordered by server id within * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index c8e42143a..891eadd60 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -937,6 +937,40 @@ describe("the Composio directory", () => { expect(refusal).toContain("Two rows claim the url"); expect(refusal).not.toContain("Composio said nothing about why"); }); + + test("a failure nobody explained is a 502 in the route's own words", async () => { + /* + * THE LAST CLASS ON THIS ROUTE, and the only one that gets logged. A refusal this deployment + * authored carries its own sentence, and a sentence Composio wrote carries Composio's; a bare + * `Error` carries neither, which is what `brokerSentence` and `vendorSentence` both answering + * null means. That is most often a programmer error on this side, so the route writes it to the + * console — the one failure whose explanation cannot be read off the response, because the + * response is the generic sentence and nothing more. + * + * The other two enable cases take the branches above this one, so without this the guard's + * condition is unpinned: a later reader could widen it to log every refusal, or drop it, and + * both existing cases would still pass. + */ + const { app } = directoryApp(undefined, "admin", [], async () => { + throw new Error("Cannot read properties of undefined (reading 'slug')"); + }); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); + + expect(response.status).toBe(502); + const refusal = ((await response.json()) as { error: string }).error; + expect(refusal).toContain("Composio said nothing about why"); + // Never the thrown object's own text: on this path it is as likely to be a stack frame as a + // sentence, and it is the console line that carries it to an operator. + expect(refusal).not.toContain("Cannot read properties"); + }); }); /** From 6d5c16f9578a73dffd7009d42ae6f471912739d0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:28:31 +0200 Subject: [PATCH 173/215] Read the console line the unexplained enable failure writes, and the silence the authored refusal keeps Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-routes.test.ts | 106 +++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 891eadd60..24b8b2045 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -889,19 +889,38 @@ describe("the Composio directory", () => { ); }); - const response = await app.request( - "http://openbot.test/api/plugins/composio/apps", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ slug: "docusign" }), - }, - ); + const said: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { + said.push(args.map(String).join(" ")); + }; - expect(response.status).toBe(503); - expect(((await response.json()) as { error: string }).error).toContain( - "its own OAuth client", - ); + try { + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "docusign" }), + }, + ); + + expect(response.status).toBe(503); + expect(((await response.json()) as { error: string }).error).toContain( + "its own OAuth client", + ); + } finally { + console.error = realError; + } + + // AND NOTHING ON THE CONSOLE. The log below this branch is for the failure whose explanation + // cannot be read off the response; this refusal's explanation is the response. Widen that + // guard to fire on every refusal — or add a third sentence source and leave the guard out of + // step with it — and an operator gets a console line per dead button, about failures they can + // already read in the browser. + expect( + said.find((line) => line.includes("composio-app-not-enabled")), + ).toBeUndefined(); }); test("a deployment fault while enabling is still a 409 in its own words", async () => { @@ -947,29 +966,56 @@ describe("the Composio directory", () => { * console — the one failure whose explanation cannot be read off the response, because the * response is the generic sentence and nothing more. * - * The other two enable cases take the branches above this one, so without this the guard's - * condition is unpinned: a later reader could widen it to log every refusal, or drop it, and - * both existing cases would still pass. + * SO THE CONSOLE IS READ HERE, not only the body. The response half alone pins nothing about + * the guard — `brokerRefusal` produces that same 502 whether the log block exists or not — so + * the line itself is asserted: it has to carry the sentence the response withholds, and the app + * it was about. Delete the guard and this case fails; widen its condition to log every refusal + * and the authored refusal above fails. + * + * THE FAKE KEY ON THE THROWN OBJECT pins the restraint the route's comment promises. The error + * is stringified, never spread, logged as an object or reached into, so a property that rode + * along on it reaches nobody. `JSON.stringify(error)` in place of `String(error)` is the quiet + * way to lose that, and it is what these last two assertions catch. */ const { app } = directoryApp(undefined, "admin", [], async () => { - throw new Error("Cannot read properties of undefined (reading 'slug')"); + throw Object.assign( + new Error("Cannot read properties of undefined (reading 'slug')"), + { apiKey: "ak_a_key_nobody_should_read" }, + ); }); + const said: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { + said.push(args.map(String).join(" ")); + }; - const response = await app.request( - "http://openbot.test/api/plugins/composio/apps", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ slug: "slack" }), - }, - ); + try { + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "slack" }), + }, + ); - expect(response.status).toBe(502); - const refusal = ((await response.json()) as { error: string }).error; - expect(refusal).toContain("Composio said nothing about why"); - // Never the thrown object's own text: on this path it is as likely to be a stack frame as a - // sentence, and it is the console line that carries it to an operator. - expect(refusal).not.toContain("Cannot read properties"); + expect(response.status).toBe(502); + const refusal = ((await response.json()) as { error: string }).error; + expect(refusal).toContain("Composio said nothing about why"); + // Never the thrown object's own text: on this path it is as likely to be a stack frame as a + // sentence, and it is the console line that carries it to an operator. + expect(refusal).not.toContain("Cannot read properties"); + } finally { + console.error = realError; + } + + const line = said.find((said) => said.includes("composio-app-not-enabled")); + expect(line).toBeDefined(); + // Which app an operator is about to be asked about, and the cause the browser was not given — + // the complement of the assertion above, and the whole point of the guard. + expect(line).toContain("slack"); + expect(line).toContain("Cannot read properties"); + expect(line).not.toContain("ak_a_key_nobody_should_read"); }); }); From aa86d7f6cb84f35a2e35cc1e871420fc80703750 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:32:51 +0200 Subject: [PATCH 174/215] Carry the app's recorded auth scheme to the browser, and admit the connections list is two reads Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/queries.ts | 54 ++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index 9f6e82c1f..23c380a13 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,6 +1,14 @@ import { queryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; +/** + * Re-exported, not re-declared. + * + * The field list is asked for by a POST, so it lives beside its only producer in `./mutations` — + * a second copy here would be free to drift from the shape that write actually returns. + */ +export type { BrokerField } from "./mutations"; + /** A tool one server offers, as the Plugins page sees it. */ export type PluginTool = { serverId: string; @@ -56,6 +64,19 @@ export type PluginServer = { * administrator to paste one in. */ dynamicClient: boolean; + /** + * How this server's authorization config was CREATED, for the brokered rows that have one. + * + * What was written down when somebody enabled the app, not what the catalogue publishes today. + * The catalogue is the vendor's, and an app it starts advertising under a different scheme has + * not moved the config this deployment already created — so a screen deciding what a live + * connection does reads this and never a fresh listing. The vocabulary is the vendor's own scheme + * literals (`OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH`, and the rest), so it is a string rather + * than a union this page would have to keep level with Composio's. + * + * Null is not an older brokered row. It is a row that is not brokered at all. + */ + authScheme: string | null; tools: PluginTool[]; /** Empty for a healthy connector. See {@link WithdrawnGrant}. */ withdrawn: WithdrawnGrant[]; @@ -170,12 +191,41 @@ export const pluginKeys = { ["plugins", "composio", "apps", query] as const, }; -/** One account this person has connected, from their own point of view. */ +/** + * One account this person has connected, from their own point of view. + * + * DELIBERATELY NON-UNIFORM, because `/api/plugins/connections` concatenates two reads: connections + * held in this deployment's own vault, and brokered ones Composio keeps on our behalf. The fields a + * settings row draws from — the server id, the scope, the date — line up across both, which is why + * one type covers both. The pair below does not, so it is optional rather than required: making it + * required would be a lie the compiler then enforced on every held row. + */ export type PluginConnection = { serverId: string; - /** What the vendor actually granted, which is not always what was asked for. */ + /** What the vendor actually granted, which is not always what was asked for. Empty for brokered. */ scope: string; connectedAt: string; + /** + * Whether a real call was last made with this credential, present only on a BROKERED row. + * + * Only a brokered row has anything to re-check: this deployment holds no secret for it, only a + * note that Composio said yes, and that note can drift when somebody ends the connection in + * Composio's own dashboard. A held connection has no equivalent question, so its rows carry + * neither field, and the absence of the pair is what tells the two READS apart — nothing more. + * + * It is NOT how a reader learns how an app connects. That comes from {@link PluginServer.authScheme} + * on the server, and a page deriving it from a connection row instead would be a second answer to + * a question already carried. + */ + verified?: boolean; + /** + * When {@link PluginConnection.verified} was last earned, present only on a brokered row. + * + * Null is reachable and means never checked, which is why it stays null rather than collapsing + * the way `connectedAt` does. For the rows an older migration backfilled it is the moment of + * consent rather than of a probe, so it is not read as "this connection answered then". + */ + verifiedAt?: string | null; }; export type PluginConnections = { From f74a5f434dc5fba9eb092b7c6d04fac60b9abd09 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:33:36 +0200 Subject: [PATCH 175/215] Name the three calls a typed-secret connection needs, and say why this one throws without its cause Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/broker.ts | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 53b35ba6e..5b857228c 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -111,7 +111,7 @@ export type BrokerApp = { * What this deployment needs of Composio's broker, and nothing more. * * A NARROW PROJECTION RATHER THAN THEIR CLIENT, for the reason the transport's `ComposioActions` is - * one: six methods is a shape a test satisfies with an object literal, so every test about + * one: nine methods is a shape a test satisfies with an object literal, so every test about * enablement, connection and revocation is a test about this deployment's logic and none of them * reaches the network. The vendor's client would drag its constructor, its retries and its schemas * into each of those tests, and the first thing every one of them would do is find a way not to @@ -214,6 +214,93 @@ export type ComposioBroker = { * press disconnect a second time. */ revoke(request: { userId: string; toolkit: string }): Promise; + /** + * What this app asks a person to type, as Composio publishes it for the scheme. + * + * ASKED OF THE VENDOR RATHER THAN WRITTEN DOWN HERE, which is the whole reason it is a call and + * not a constant. The fields are per app and they move: a form built from one hard-coded "API + * key" box is right for Firecrawl and wrong for the app that also wants a workspace subdomain, + * and wrong quietly — the person fills in what they were shown, a connection is created without + * the value nobody asked them for, and the first tool call is what discovers it. The `help` on + * {@link BrokerField} is the vendor's own sentence written for the person filling the box in, and + * it is worth more than anything this deployment could invent about somebody else's console. + * + * THE SCHEME IS PASSED IN RATHER THAN RESOLVED HERE, for the reason {@link + * ComposioBroker.ensureAuthConfig} takes a connection rather than deriving one: the caller + * already holds the scheme recorded on the app's row at enable time, and a second derivation is a + * second answer — a form drawn for `BASIC` in front of a config created for `API_KEY`, whose + * fields the person cannot fill in because they are not the ones their app has. + */ + connectionFields(request: { + toolkit: string; + authScheme: FieldScheme; + }): Promise; + /** + * Connect this person with the secret they typed, answering the account it made. + * + * THE ONE CALL WHOSE IMPLEMENTATION MUST RETHROW WITH NO `cause`, WHICH IS AN INVERSION OF THE + * STANDING RULE AND SAYS SO ON PURPOSE. The rule `composio-adapter.ts` states and every path here + * keeps is that a vendor error is never logged and always carried as `cause`, precisely because + * the object holds the request it was made for and whoever is reading a log rather than a page + * deserves it. On every other call that request is a link mint or a delete. On this one it is + * somebody's API key. So this is the single place the rule reverses: read the vendor's sentence + * through the existing door, then drop the object entirely rather than attach it. + * + * WHAT THAT COSTS IS THE DIAGNOSTIC TRAIL ON THE FLOW PEOPLE MOST OFTEN MISTYPE, AND THE COST IS + * ACCEPTED KNOWINGLY. A key pasted with a newline, a token from the wrong workspace, a secret for + * the staging tenant — these are the ordinary failures here, and they are the ones this leaves + * nothing behind about. What an operator gets instead is Composio's own sentence and the request + * id inside it, which is enough to ask the vendor about that attempt and not enough to rebuild it + * here. A `cause` that made the next mistyped key easier to explain would put every correctly + * typed one in a log for as long as the log is kept. + * + * THE `accountId` IS ANSWERED SO A CALLER CAN UNDO EXACTLY THIS ACCOUNT. A connection made from + * typed fields is verified before it is kept, and a verification has to be able to take back the + * thing it just made and nothing else. {@link ComposioBroker.revoke} is the wrong instrument for + * that — it ends every account this person holds for the app, which is right for somebody ending + * their access and wrong for a step undoing its own work. The two differ exactly when the local + * row and Composio have drifted apart: the person already had a connection that works, this + * attempt made a second one, the verification failed — and a sweep there takes down the + * connection that was working. See {@link ComposioBroker.revokeAccount}. + */ + connectWithFields(request: { + userId: string; + toolkit: string; + authScheme: FieldScheme; + /** + * What the person typed, keyed by the `name` {@link BrokerField} was published under. + * + * THE ONLY SECRET THAT CROSSES THIS SEAM, which is what the no-`cause` rule above is about. The + * names are sent back on the wire verbatim and are never shown; the values are the person's own + * credential and belong in no message, no log and no audit row, for the reason the connect url + * does not. + */ + values: Record; + }): Promise<{ accountId: string }>; + /** + * End ONE account by id, and ask for the grant behind it to be withdrawn too. + * + * ONE, WHICH IS THE WHOLE DIFFERENCE FROM {@link ComposioBroker.revoke}. That method sweeps every + * account a person holds for an app, because what it serves is a person ending their access to + * it. This serves a caller undoing an account it just made, and the id is the whole of what it + * names — nothing is listed, nothing is matched, and no account this call was not handed can be + * reached by it. The narrower instrument exists because the wider one is destructive in precisely + * the case a verification runs into: a working connection standing beside a failed second + * attempt. + * + * WITH `revoke_on_delete`, WHICH IS WHAT MAKES IT A WITHDRAWAL RATHER THAN A RECORD-KEEPING + * SOFT-DELETE. Without that flag the account stops being visible to this deployment and the + * credential at the far end stands — which is the exact state {@link ComposioBroker.revoke} + * records this deployment once claiming as a revocation, and it is worse here than there: the + * secret left live is one a person typed minutes ago into a form that then told them the + * connection had not been kept. + * + * NO BOOLEAN, BECAUSE THERE IS NOTHING TO COUNT. `revoke` answers what it found because it + * searches for it; this is handed the id of an account created moments earlier by the call that + * answered it, so "there was nothing there" is not an outcome a caller chooses between — it is a + * failure, and it throws like any other. + */ + revokeAccount(accountId: string): Promise; }; /** From 38e44238d75024f661b18600de3b2cd7cec87734 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:36:40 +0200 Subject: [PATCH 176/215] Create the config each kind of app actually needs, rather than the managed one for all five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed type was right for exactly one kind and sent for every one of them. Only one of the four failures announced itself — Composio has no OAuth client of its own for a self-registering app, so the managed path answers 404 and Linear's MCP app was simply unconnectable. The rest were quiet: a no-auth app, whose config the vendor refuses outright, and a key app whose people were then sent to a consent screen with nothing to ask them for. So enabling now branches, and two of the five branches create nothing at all. A no-auth app returns before the listing is even made, because there is nothing to find and nothing to create. An unsupported one is refused before any write, carrying the derivation's own sentence rather than a second one invented here that would drift from the one the picker filters on. NEITHER CUSTOM BRANCH CARRIES A SECRET, AND `credentials: {}` IS WHAT THAT COSTS. A self-registering app needs none by definition and a key app's key belongs to each connection made against the config rather than to the config. But `CreateCustomAuthConfigParamsSchema` spells the field REQUIRED (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:52-65`) and `AuthConfigs.create` `safeParse`s before it builds a body (`src/models/AuthConfigs.ts:132-137`), so an absent field is not "no credentials sent" — it is a `ValidationError` raised with no request made. Verified both halves: the omission throws `ZodError` at path `credentials`, and the empty record is accepted live and mints a working consent link. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 94 +++++++++++- server/tests/composio-adapter.test.ts | 197 ++++++++++++++++++++++++- 2 files changed, 277 insertions(+), 14 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index eca7af9d1..15e64d923 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1737,7 +1737,36 @@ export type ComposioVendor = { */ create( toolkit: string, - options: { type: "use_composio_managed_auth"; name: string }, + /** + * THE UNION, BECAUSE THE MANAGED TYPE IS RIGHT FOR ONE KIND OF APP AND WAS SENT FOR ALL OF + * THEM. A self-registering app has no Composio-owned OAuth client behind it and a key app + * has no consent screen to send anybody to, so both are created as custom configs — carrying + * the scheme, and never a credential, which is per-connection rather than per-config. The + * managed shape has no field to name a scheme with, which is why this is a union of two + * shapes rather than one shape with an optional field. + */ + options: + | { type: "use_composio_managed_auth"; name: string } + | { + type: "use_custom_auth"; + authScheme: FieldScheme | "DCR_OAUTH"; + name: string; + /** + * EMPTY, AND PRESENT, WHICH IS NOT THE CONTRADICTION IT LOOKS LIKE. + * + * Neither config this deployment creates carries a secret — a self-registering app + * needs none and a key app's key belongs to each connection made against the config + * rather than to the config — but `CreateCustomAuthConfigParamsSchema` spells + * `credentials` REQUIRED (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:52-65`) and `AuthConfigs.create` `safeParse`s its + * options before it builds a body (`src/models/AuthConfigs.ts:132-137`). So an absent + * field is not "no credentials sent"; it is a `ValidationError` raised inside the + * vendor's package with no request made at all. The empty record is what carries + * nothing THROUGH that check, and the type says so rather than leaving the next reader + * to discover it from a failed enable. + */ + credentials: Record; + }, ): Promise<{ id?: unknown } | null>; /** * Delete one auth config, and ask for the upstream credentials on it to be revoked too. @@ -2578,7 +2607,27 @@ export function buildComposioClient( return copyOf(entry.apps); }, - async ensureAuthConfig({ toolkit, name }): Promise { + async ensureAuthConfig({ toolkit, name, connection }): Promise { + /* + * NOTHING AT ALL FOR AN APP THAT NEEDS NO AUTHENTICATION, AND THAT IS THE VENDOR'S RULE + * RATHER THAN A SHORTCUT. Composio refuses an auth config for such a toolkit — "Cannot + * create an auth config for toolkit hackernews because it does not require authentication. + * You can use its tools directly without creating a connected account." — so the listing + * below is not even worth making: there is nothing to find and nothing to create. + */ + if (connection.kind === "no-auth") return; + + /* + * AND A REFUSAL BEFORE ANY WRITE for the one kind this deployment cannot drive. The sentence + * is the derivation's own, which named the scheme and what it wants; a refusal here that + * invented a second sentence would drift from the one the picker filters on. + */ + if (connection.kind === "unsupported") { + throw new BrokerRefusalError( + `${toolkit} was not enabled: ${connection.reason}`, + ); + } + /* * IDEMPOTENT BY LOOKING FIRST, because a second config is not a duplicate — it is a split. * A person's existing connection is created against one particular auth config, so creating @@ -2637,16 +2686,47 @@ export function buildComposioClient( * Composio. What is true of every condition here is the half that is said now: the app is not * enabled, and what is at Composio is not something this call can report. */ + const configName = `${name} ${CONFIG_SUFFIX}`; + + /* + * WHICH TYPE OF CONFIG, AND THE MANAGED ONE IS RIGHT FOR EXACTLY ONE OF THE THREE KINDS THAT + * REACH HERE. It was sent for all of them, and only one of the failures announced itself: + * Composio has no OAuth client of its own for a self-registering app, so the managed path + * answers 404 and the app is simply unconnectable — which is what made Linear's MCP app + * impossible to attach. A key app's was quiet and worse: the config was accepted, and every + * person enabled onto it was then sent to a consent screen that had nothing to ask them for. + * + * AND NEITHER CUSTOM BRANCH CARRIES A CREDENTIAL, which is the point rather than an omission. + * A self-registering app needs none by definition — the vendor registers a client of its own + * at connect time. A key app needs none HERE because the key is one person's: it belongs to + * each connection made against this config, which is per-deployment, and a key written onto + * it would be one person's secret shared by everybody the app is enabled for. + */ + const options = + connection.kind === "consent" + ? ({ type: "use_composio_managed_auth", name: configName } as const) + : ({ + type: "use_custom_auth", + authScheme: + connection.kind === "self-registering" + ? ("DCR_OAUTH" as const) + : connection.authScheme, + name: configName, + /* + * THE EMPTY RECORD IS THE VENDOR'S PRICE FOR SENDING NOTHING — see the field's own + * comment on {@link ComposioVendor}. Omitting it is a `ValidationError` raised before + * any request, which reads as a refusal to enable rather than as the secret-free + * config this branch is for. + */ + credentials: {}, + } as const); + const created = await askVendor( { outcome: `the app is not enabled, and whether an authorization config for ${toolkit} now stands at Composio is not something this deployment can tell`, app: toolkit, }, - () => - vendor.authConfigs.create(toolkit, { - type: "use_composio_managed_auth", - name: `${name} ${CONFIG_SUFFIX}`, - }), + () => vendor.authConfigs.create(toolkit, options), ); /* diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 449e947b5..5331205f2 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -1244,7 +1244,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); const refusal = await failureOf( - broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), ); expect(refusal).toBeInstanceOf(BrokerRefusalError); @@ -1302,7 +1306,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { }), ); - await broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + await broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); expect(created).toEqual([]); }); @@ -1358,7 +1366,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { }), ); - await broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + await broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); // Adopting the hand-made one would have this deployment mint connections against scopes it // cannot see and delete an operator's work when the app is removed. @@ -1371,6 +1383,161 @@ describe("telling this deployment's auth configs from anybody else's", () => { }); }); +/** + * WHAT ENABLING AN APP ACTUALLY CREATES, WHICH IS A DIFFERENT ANSWER FOR EACH KIND OF APP. + * + * Every config this deployment ever made was `use_composio_managed_auth`, which is the right answer + * for exactly one of the five kinds. The other four were wrong in four different ways, and only one + * of them announced itself: Composio answers 404 for an app that has no managed OAuth client of its + * own, so Linear's MCP app was unconnectable. The rest were quiet — a no-auth app whose creation + * the vendor refuses outright, a key app whose people were sent to a consent screen with nothing to + * ask them, and an app this deployment cannot drive at all, enabled anyway. + * + * THE ASSERTIONS ARE ON WHAT WENT OUT, AND ON WHAT DID NOT. Two of these four are about a call that + * must not be made at all, which no assertion on a return value can see: {@link fakeVendor}'s + * refusals name the unasked-for call, and the counters here say which door was not opened. + */ +describe("the config each kind of app is enabled with", () => { + test("a self-registering app gets a custom config with no credentials in it", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "linear_mcp", + name: "Linear MCP", + connection: { kind: "self-registering" }, + }); + + /* + * `DCR_OAUTH` AND NO CREDENTIALS, which is the whole of what such an app needs: the vendor + * registers a client of its own against the provider at connect time. The managed type is what + * this used to send and it is the one answer that cannot work here — Composio has no OAuth + * client of its own for these apps, so the managed path answers 404 and nobody connects. + */ + expect(created).toEqual([ + [ + "linear_mcp", + { + type: "use_custom_auth", + authScheme: "DCR_OAUTH", + name: "Linear MCP (OpenBot)", + credentials: {}, + }, + ], + ]); + }); + + test("a key app gets a custom config carrying no secret, because the secret is per person", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + create: async (...call: unknown[]) => { + created.push(call); + return CREATED; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "perplexityai", + name: "Perplexity", + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + /* + * NO KEY ON THE CONFIG, AND THAT IS NOT AN OMISSION TO BE FIXED LATER. The config is + * per-deployment and the key is one person's; it belongs to each connection made against this + * config, which is where the connect form sends it. A key here would be one person's secret + * shared by everybody the app is enabled for. + */ + expect(created).toEqual([ + [ + "perplexityai", + { + type: "use_custom_auth", + authScheme: "API_KEY", + name: "Perplexity (OpenBot)", + credentials: {}, + }, + ], + ]); + }); + + test("a no-auth app gets no config at all, because Composio refuses one", async () => { + let listed = false; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + listed = true; + return { items: [] }; + }, + }, + }), + ); + + await broker.ensureAuthConfig({ + toolkit: "hackernews", + name: "Hacker News", + connection: { kind: "no-auth" }, + }); + + /* + * NOT EVEN THE LISTING, which is the half that is easy to leave in. Composio's own refusal is + * "Cannot create an auth config for toolkit hackernews because it does not require + * authentication. You can use its tools directly without creating a connected account." — so + * there is nothing to find and nothing to create, and a read made anyway is a round trip whose + * answer no branch below could use. `create` is left at {@link fakeVendor}'s refusal, which + * names itself if it is ever reached. + */ + expect(listed).toBe(false); + }); + + test("an unsupported app is refused before anything is created", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [] }), + }, + }), + ); + + const refusal = await failureOf( + broker.ensureAuthConfig({ + toolkit: "docusign", + name: "DocuSign", + connection: { + kind: "unsupported", + reason: "needs its own OAuth client", + }, + }), + ); + + /* + * THE DERIVATION'S OWN SENTENCE, carried rather than restated. It is the one the picker filters + * on and the one an administrator has already read beside the app; a second sentence invented + * here would be a second account of why the app cannot be driven, and the two would drift. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/needs its own OAuth client/); + expect(refusal.message).toMatch(/docusign/); + expect(refusal.message).not.toMatch(A_CRASH); + }); +}); + /** * Ending somebody's access, which is the claim this whole surface is here to be able to make. * @@ -2335,7 +2502,11 @@ describe("what a vendor failure becomes on its way out of the seam", () => { authConfigs: { list: async () => ({ items: [] }), create: raise }, }), ask: ({ broker }) => - broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), }, { method: "deleteAuthConfig", @@ -2526,7 +2697,11 @@ describe("each vendor condition reaches the reader as its own remedy", () => { }), () => 1_000_000, ); - return broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }); + return broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }); } /** @@ -4177,7 +4352,11 @@ describe("what a malformed field of a row actually costs", () => { ); const refusal = await failureOf( - broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), ); expect(refusal).toBeInstanceOf(BrokerRefusalError); @@ -4208,7 +4387,11 @@ describe("what a malformed field of a row actually costs", () => { ); const refusal = await failureOf( - broker.ensureAuthConfig({ toolkit: "linear", name: "Linear" }), + broker.ensureAuthConfig({ + toolkit: "linear", + name: "Linear", + connection: { kind: "consent" }, + }), ); /* From 87895a61841715bd46ea73af6b248455b33b5034 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:36:48 +0200 Subject: [PATCH 177/215] Record how an enabled app connects, and refuse to move a connected one onto another flow `addBrokeredApp` now takes the `BrokerConnection` the administrator's chosen catalogue row resolved to, forwards it to `ensureAuthConfig`, and records the vendor's own scheme literal on the row. The scheme is written on the way in and deliberately not on the upsert's update branch: re-enabling is ordinary, and a rewrite there would move every live connection onto a different flow the moment Composio re-labels an app. The one place it may still change is an app nobody has connected, where there is nothing to strand and pressing Add again is how an operator picks up a vendor's change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 75 ++++++++++++++- server/tests/composio-connections.test.ts | 106 ++++++++++++++++++++-- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 463ae5135..33ba05649 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -37,7 +37,11 @@ import { type ServerAccess, ServerUnresolvableError, } from "./access"; -import { BrokerUnconfiguredError, type ComposioBroker } from "./broker"; +import { + type BrokerConnection, + BrokerUnconfiguredError, + type ComposioBroker, +} from "./broker"; import { type CatalogueEntry, catalogueEntry, @@ -923,6 +927,22 @@ export type PluginStoreOptions = { redirectUri?: string; }; +/** The literal recorded on the row, which is the scheme a later call must keep using. */ +function schemeFor(connection: BrokerConnection): string | null { + switch (connection.kind) { + case "consent": + return "OAUTH2"; + case "self-registering": + return "DCR_OAUTH"; + case "fields": + return connection.authScheme; + case "no-auth": + return "NO_AUTH"; + case "unsupported": + return null; + } +} + export function createPluginStore(options: PluginStoreOptions) { const { database, auditStore, credentials, encryptionKey } = options; /* @@ -2336,6 +2356,14 @@ export function createPluginStore(options: PluginStoreOptions) { slug: string; title: string; by: string; + /** + * How this app connects, resolved from the catalogue row the administrator chose. + * + * Taken rather than derived here, because the caller has already read it off that row and a + * second derivation is a second answer — the one thing {@link BrokerConnection} exists to + * prevent. It decides what config is created at the vendor, and it is what this row records. + */ + connection: BrokerConnection; }): Promise { // Before anything at all. A deployment with no key has no catalogue for this app to have been // chosen from, so there is nothing here to half-do and nothing to say but the setting. @@ -2348,7 +2376,11 @@ export function createPluginStore(options: PluginStoreOptions) { ); } - await broker.ensureAuthConfig({ toolkit: input.slug, name: input.title }); + await broker.ensureAuthConfig({ + toolkit: input.slug, + name: input.title, + connection: input.connection, + }); const id = `composio-${input.slug}`; await database @@ -2364,6 +2396,10 @@ export function createPluginStore(options: PluginStoreOptions) { // Nothing for the vault to hold. A brokered call runs as the person asking, on their own // connection at the vendor, which is a `composio_connections` row rather than a secret. credentialId: null, + // What the config this call just made was created AS, which is the scheme every later + // connection against it has to keep using. Written on the way in, and after that only + // where nothing is connected to be moved by it — see the statement below the upsert. + authScheme: schemeFor(input.connection), addedBy: input.by, }) .onConflictDoUpdate({ @@ -2382,9 +2418,44 @@ export function createPluginStore(options: PluginStoreOptions) { * `removeServer` retires a server's secret by reading it off this column, and a null * written over it leaves that secret live with nothing left to name it. */ + /* + * `auth_scheme` is not written here either, and for a neighbouring reason. + * + * This is the branch a second press of Add takes, and the scheme is the one thing on + * the row that live connections depend on rather than merely display. Rewriting it + * here would move them; the statement below rewrites it only where there are none. + */ }, }); + /* + * WRITE-ONCE, EXCEPT WHERE THERE IS NOTHING TO STRAND. + * + * A row's scheme is what its authorization config was created as, and every connection made + * against that config depends on it. Re-enabling must not rewrite it underneath them: a + * vendor that starts publishing managed OAuth for an app somebody connected by key would, + * one press of Add later, leave this deployment minting consent links against a config full + * of keys. + * + * With no connections there is no such dependence, so the rewrite is safe and useful — it is + * how an operator picks up a vendor's change without removing and re-adding the app. + */ + const connections = await database + .select({ userId: composioConnections.userId }) + .from(composioConnections) + .where(eq(composioConnections.toolkit, input.slug)) + .limit(1); + + if (connections.length === 0) { + await database + .update(mcpServers) + .set({ + authScheme: schemeFor(input.connection), + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, id)); + } + await recordAuditEvent(auditStore, { eventType: "configuration.changed", targetType: "mcp_server", diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index fc65ad488..e94364fc5 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -86,8 +86,19 @@ const renamedId = `renamed-${suite}`; * itself a guess about the server's locale. */ const secondToolkit = `${toolkit}-more`; +/** + * The app this file ENABLES rather than inserts, and so the only one whose row it did not write. + * + * Every other fixture here is an `mcp_servers` insert made by hand, because what those tests are + * about is what a removal does to a row that already stands. The two tests at the foot of this file + * are about the row `addBrokeredApp` writes itself — its `auth_scheme` in particular — so the app + * has to arrive the way an administrator's press of Add makes it arrive, id and all. + */ +const enabledToolkit = `enablable-${suite}`; +/** What `addBrokeredApp` spells that app's row, which is the id the two tests below read back. */ +const enabledId = `composio-${enabledToolkit}`; /** Every app this run owns, which is the scope of every read and every delete below. */ -const ownedToolkits = [toolkit, secondToolkit]; +const ownedToolkits = [toolkit, secondToolkit, enabledToolkit]; /** * An app this file does NOT own, standing in for another run's fixture — or another file's. * @@ -214,8 +225,13 @@ async function connectionsHeld(): Promise { * for its reason. "The removal asked the broker to revoke" is worth little beside "and asked it * nothing else": a removal that also listed the catalogue or began somebody's connection would be * acting on somebody's behalf in a way nothing here has reasoned about, and a stub answering - * plausibly would let that pass unremarked. Nothing in this file enables an app or confirms a - * connection, so those four methods have no caller here and say so. + * plausibly would let that pass unremarked. Nothing in this file lists the catalogue or confirms a + * connection, so those three methods have no caller here and say so. + * + * `ensureAuthConfig` is the exception, and it is recorded rather than answered silently: the two + * tests at the foot of this file enable an app for real, so it has a caller — and every assertion + * above compares {@link asksMade} whole, so recording it keeps "and asked it nothing else" true of + * the removals as well. */ const unasked = (what: string) => async (): Promise => { throw new Error(`this suite's path asked the broker to ${what}`); @@ -261,7 +277,15 @@ let vendorRefuses: (request: { userId: string; toolkit: string }) => boolean = const broker: ComposioBroker = { listApps: unasked("list the catalogue"), - ensureAuthConfig: unasked("create an auth config"), + ensureAuthConfig: async (config) => { + // Named by app AND kind, because the kind is what decides which config is created: an enable + // that forwarded nothing would record an ask whose second half is missing rather than one that + // merely differs. + asks.push({ + ask: `ensureAuthConfig:${config.toolkit}/${config.connection.kind}`, + held: await connectionsHeld(), + }); + }, authorize: unasked("begin somebody's connection"), isConnected: unasked("check somebody's connection"), revoke: async (request) => { @@ -328,10 +352,10 @@ async function clean() { await database.delete(agents).where(eq(agents.id, botId)); await database .delete(mcpTools) - .where(inArray(mcpTools.serverId, [toolkit, renamedId])); + .where(inArray(mcpTools.serverId, [toolkit, renamedId, enabledId])); await database .delete(mcpServers) - .where(inArray(mcpServers.id, [toolkit, renamedId])); + .where(inArray(mcpServers.id, [toolkit, renamedId, enabledId])); await database .delete(composioConnections) .where(inArray(composioConnections.toolkit, ownedToolkits)); @@ -1066,3 +1090,73 @@ test("the refresh that follows an add is attributed to the deployment", async () refs: [ref], }); }); + +/** + * ENABLING AN APP THAT IS ALREADY HERE, which is what pressing Add a second time is. + * + * `addBrokeredApp` is idempotent by design — two administrators can press Add together, and an app + * can be removed and added again — so the second press takes the upsert's update branch. Everything + * on that branch is a display fact the vendor is allowed to restate: the title, the url, who added + * it. `auth_scheme` is not. It is what this deployment's authorization config was created AS, and + * every connection anybody has made against that config depends on it, so re-enabling has to leave + * it standing: a vendor that starts publishing managed OAuth for an app somebody connected by key + * would otherwise, one press of Add later, have this deployment minting consent links against a + * config full of keys. + */ +test("re-enabling never moves a connected app onto a different flow", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + await database + .insert(composioConnections) + .values({ toolkit: enabledToolkit, userId: askerId }); + + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + + const [row] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, enabledId)); + expect(row.authScheme).toBe("API_KEY"); +}); + +/** + * AND THE ONE CASE WHERE THE REWRITE IS BOTH SAFE AND THE POINT. + * + * The rule above is about not stranding connections, so where there are none there is nothing to + * strand. Re-enabling is then how an operator picks up a vendor's change — without it the only way + * to record a new scheme would be removing the app and adding it back, which takes its grants with + * it. So the column is write-once EXCEPT here, and this test is the half of that sentence the test + * above cannot state. + */ +test("re-enabling an app nobody has connected picks up the vendor's change", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + + const [row] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, enabledId)); + expect(row.authScheme).toBe("OAUTH2"); +}); From 524c1c99d2772af6c91a506cb6b8facbdd364d90 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:41:35 +0200 Subject: [PATCH 178/215] Hand the enable tests the connection the caller resolved, and assert it reaches the broker unchanged Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/tests/plugin-store.integration.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index d115b90dc..1e17a6a48 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -37,7 +37,7 @@ import { CatalogueTransportUnroutableError, ServerRowAmbiguousError, } from "../src/plugins/access"; -import type { ComposioBroker } from "../src/plugins/broker"; +import type { BrokerConnection, ComposioBroker } from "../src/plugins/broker"; import type { CatalogueEntry } from "../src/plugins/catalogue"; import { catalogueEntry } from "../src/plugins/catalogue"; import { @@ -5987,7 +5987,11 @@ test("an audit write that fails is not recorded as the vendor misbehaving", asyn * * THE BROKER IS ASKED FIRST AND EXACTLY ONCE. First because a row whose auth config does not exist * is an app an administrator can see and nobody can connect to; once because `ensureAuthConfig` is - * idempotent at the vendor and a second call here would be this deployment leaning on that. + * idempotent at the vendor and a second call here would be this deployment leaning on that. The + * connection the caller resolved is asserted as it reaches the broker unchanged, because that kind + * decides what config is created at the vendor: an enable path that re-derived it here rather than + * passing the caller's through would be a second answer to the question this argument exists to + * settle once. * * `composio-linear` is cleaned up in a `finally` rather than by {@link freshDatabase}, which knows * only the ids the guard at the top of this file cleared. The row is checked absent before the add @@ -5995,7 +5999,11 @@ test("an audit write that fails is not recorded as the vendor misbehaving", asyn * spells it, so a row already at it would be somebody's app rather than this test's. */ test("enabling an app writes a brokered row, and asks for its auth config first", async () => { - const asked: { toolkit: string; name: string }[] = []; + const asked: { + toolkit: string; + name: string; + connection: BrokerConnection; + }[] = []; const rowsWhenAsked: string[] = []; const unasked = (what: string) => async (): Promise => { throw new Error(`enabling an app asked the broker to ${what}`); @@ -6051,6 +6059,7 @@ test("enabling an app writes a brokered row, and asks for its auth config first" slug: "linear", title: "Linear", by: "admin@example.com", + connection: { kind: "consent" }, }); expect(record.id).toBe("composio-linear"); @@ -6060,7 +6069,9 @@ test("enabling an app writes a brokered row, and asks for its auth config first" // on their connection at the vendor, so there is nothing on this row for a vault to hold. expect(record.hasCredential).toBe(false); - expect(asked).toEqual([{ toolkit: "linear", name: "Linear" }]); + expect(asked).toEqual([ + { toolkit: "linear", name: "Linear", connection: { kind: "consent" } }, + ]); expect(rowsWhenAsked).toEqual([]); const changes = auditStore @@ -6685,6 +6696,7 @@ test("removing an app revokes everybody, then clears rows, then drops the config slug: "linear", title: "Linear", by: "admin", + connection: { kind: "consent" }, }); for (const userId of ["user-b", "user-a"]) { expect( From 331871f2769aad4382e6fd73dda6eebe35f0aa45 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:51:17 +0200 Subject: [PATCH 179/215] Carry the chosen app's connection into the store, and refuse an app this deployment cannot connect before reaching it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 34 +++++++++++++ server/tests/plugin-routes.test.ts | 76 ++++++++++++++++++++++++++---- 2 files changed, 100 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index f7408cb93..df24ad2d5 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -608,11 +608,45 @@ export function createPluginRoutes( ); } + /* + * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not + * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on + * one, but this route validates against the unfiltered `directory` — so a request that names + * one by hand arrives here past that filter. + * + * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes + * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere + * else as "not a brokered row at all" — so the row enabling would write is one whose recorded + * kind contradicts what it is. + * + * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two + * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that + * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker + * is behind it, including one that would happily create the config. The adapter's stops ANY + * caller at all — this route, a script, a future path — from reaching the vendor for an app + * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the + * store itself still has no guard, which is precisely why the cheap one here is worth having. + * + * The derivation's own `reason` is the sentence, because it names what is missing for this app + * rather than for unsupported apps in general. 503 because this is a refusal this deployment + * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and + * for the same reading: the brokered surface is unavailable for this app until somebody here + * changes something, rather than unavailable because a third party is down. Not the 400 above, + * which says the app does not exist; this one does exist, and this deployment cannot drive it. + */ + if (app.connection.kind === "unsupported") { + return context.json({ error: app.connection.reason }, 503); + } + try { const server = await store.addBrokeredApp({ slug: app.slug, title: app.name, by: actorEmail(context), + // Off the directory entry the administrator chose, never derived a second time here: a + // second derivation is a second answer, which is the one thing `BrokerConnection` exists to + // prevent. It decides what config the store creates at the vendor, and what the row records. + connection: app.connection, }); return context.json({ server }, 201); } catch (error) { diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 24b8b2045..451b67396 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; import { ServerRowAmbiguousError } from "../src/plugins/access"; -import { type BrokerApp, BrokerRefusalError } from "../src/plugins/broker"; +import { + type BrokerApp, + type BrokerConnection, + BrokerRefusalError, +} from "../src/plugins/broker"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -629,7 +633,12 @@ function directoryApp( */ enable: (() => Promise) | null = null, ) { - const added: Array<{ slug: string; title: string; by: string }> = []; + const added: Array<{ + slug: string; + title: string; + by: string; + connection: BrokerConnection; + }> = []; const store = { // Every read the plugins surface makes on its way to the route under test. The directory asks // for urls and is handed urls: the rows below carry an id as well, and the route never sees it. @@ -640,6 +649,7 @@ function directoryApp( slug: string; title: string; by: string; + connection: BrokerConnection; }) => { if (enable) return enable(); added.push(input); @@ -771,6 +781,40 @@ describe("the Composio directory", () => { expect(added).toEqual([]); }); + test("an app this deployment cannot connect is refused before the store", async () => { + /* + * THE ASYMMETRY THIS CLOSES. The GET hides every `unsupported` app, so an administrator + * cannot press Add on one through the picker; the POST validates against the unfiltered + * catalogue, so a request naming one by hand walks straight past that. What it would reach is + * `addBrokeredApp`, whose `schemeFor` records `null` for an unsupported app — and `null` on + * that column is read everywhere else as "not a brokered row at all". So the row this would + * write is one that lies about its own kind. + * + * The derivation's own `reason` is the answer, because it is the sentence that names what is + * missing for THIS app, and 503 because it is a refusal this deployment authored — the same + * status `brokerRefusal` gives one raised a layer down, and not a 400, which would read as a + * malformed request about an app Composio really does publish. + */ + const { added, app } = directoryApp(); + + const response = await app.request( + "http://openbot.test/api/plugins/composio/apps", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: "docusign" }), + }, + ); + + expect(response.status).toBe(503); + expect(((await response.json()) as { error: string }).error).toContain( + "OAuth application registered by whoever runs this deployment", + ); + // AND THE STORE WAS NEVER ASKED, which is the whole point of the guard: the misleading row is + // not written and then answered around, it is never reachable. + expect(added).toEqual([]); + }); + test("an app the directory does list is added", async () => { const { added, app } = directoryApp(); @@ -786,7 +830,16 @@ describe("the Composio directory", () => { expect(response.status).toBe(201); // The title comes off the directory entry, never off the request: the caller chose an app, not // a name for it. - expect(added).toEqual([{ slug: "slack", title: "Slack", by: ADMIN.email }]); + // And so does the connection: how an app connects is read off the catalogue row that was + // chosen, not derived a second time on the way to the store. + expect(added).toEqual([ + { + slug: "slack", + title: "Slack", + by: ADMIN.email, + connection: { kind: "consent" }, + }, + ]); }); test("somebody who is not an administrator sees none of it", async () => { @@ -878,14 +931,17 @@ describe("the Composio directory", () => { * party being down and the generic sentence would send an administrator to check a key that is * fine. * - * DOCUSIGN IS THE APP PRESSED HERE because it is the fixture's `unsupported` row, and a refusal - * of exactly this kind is the one it would really raise. The POST can still reach it: the route - * searches the unfiltered `directory`, not the `connectable` subset the GET answers with, so - * the last thing between an administrator and a dead button is this sentence. + * A CONNECTABLE APP IS PRESSED HERE, and it has to be. The fixture's `unsupported` row would + * be the natural choice — the refusal below is the one DocuSign would really raise — but the + * route now refuses an unsupported app itself, before the store is called at all, so pressing + * that row would answer 503 with the catalogue's own sentence and never reach the failing + * store this case exists to exercise. Linear is connectable, and a refusal can still come back + * out of enabling it: Composio holding no managed credentials is a fact about the vendor's + * side, not about what this deployment can drive. */ const { app } = directoryApp(undefined, "admin", [], async () => { throw new BrokerRefusalError( - "DocuSign was not enabled: it needs its own OAuth client, and Composio holds no managed credentials for it.", + "Linear was not enabled: Composio holds no managed credentials for this toolkit, so there is no auth config for this deployment to create.", ); }); @@ -901,13 +957,13 @@ describe("the Composio directory", () => { { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ slug: "docusign" }), + body: JSON.stringify({ slug: "linear" }), }, ); expect(response.status).toBe(503); expect(((await response.json()) as { error: string }).error).toContain( - "its own OAuth client", + "no managed credentials for this toolkit", ); } finally { console.error = realError; From 6b191d39863a27789981e29f38d290db8cd1a051 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:51:30 +0200 Subject: [PATCH 180/215] Carry the recorded auth scheme on a server address, so connect opens the flow the config was created as Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 33ba05649..e3594e660 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -179,12 +179,32 @@ export type ServerRecord = { /** * A server row as the surfaces that only need to know where it is see it. * - * Three columns of {@link ServerRecord} and none of what hangs off it, because the callers this is + * Four columns of {@link ServerRecord} and none of what hangs off it, because the callers this is * for ask one question: which vendor is this row addressed at. The title travels with the url * because their refusals name it — "You already have an account connected to Linear" is the app's * name, which is the only one of the two a person has ever seen on a screen. */ -export type ServerAddress = { id: string; title: string; url: string }; +export type ServerAddress = { + id: string; + title: string; + url: string; + /** + * How this row's authorization config was created, for the brokered rows that have one. + * + * THE RECORDED SCHEME, NEVER A FRESH CATALOGUE READ. An app's config was created as one + * particular scheme and every connection standing against that config depends on it, so the + * connect path has to open the flow this column names rather than the one the catalogue + * publishes for the app today. Re-derived from a listing instead, a vendor that starts + * advertising a new scheme would silently move live connections onto a different flow — minting + * a consent link against a config that holds keys, or asking for a key where a consent screen is + * waiting. + * + * The same vocabulary the column holds, which the schema comment spells out: the vendor's own + * scheme literals, never a {@link BrokerConnection} kind. Null is not an older brokered row; it + * is a row that is not brokered. + */ + authScheme: string | null; +}; export type SkillRecord = { id: string; @@ -3237,6 +3257,7 @@ export function createPluginStore(options: PluginStoreOptions) { id: mcpServers.id, title: mcpServers.title, url: mcpServers.url, + authScheme: mcpServers.authScheme, }) .from(mcpServers) .where(eq(mcpServers.id, serverId)) From 757de71ec11be309ae2cf779c80afdd0f1d07966 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:52:15 +0200 Subject: [PATCH 181/215] Tell the account hook which of three ways an app connects, and wire the typed-secret pair Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 113 +++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 855ccd892..11407d661 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -10,8 +10,11 @@ import { ItemTitle, } from "@/components/ui/item"; import { + type BrokerField, + brokeredConnectionFieldsMutationOptions, confirmBrokeredConnectionMutationOptions, connectAccountMutationOptions, + connectBrokeredWithFieldsMutationOptions, disconnectBrokeredMutationOptions, } from "@/lib/plugins/mutations"; @@ -30,7 +33,36 @@ import { * key, what the dot means, what disconnecting ends — is one answer and lives here. */ -/** The row's state and the two things it can do, from {@link useBrokeredAccount}. */ +/** + * What this person actually does to connect: click through a consent screen, type a secret, or + * nothing at all. + * + * A coarser question than the scheme the app's authorization config was created as. The recorded + * `authScheme` is a vendor literal — `OAUTH2`, `DCR_OAUTH`, `API_KEY`, `NO_AUTH` and the rest — and + * a row that branched on it would be re-asking the same three-way question at every branch, each + * copy free to forget a literal the others remembered. + */ +export type BrokeredAccountKind = "consent" | "fields" | "no-auth"; + +/** The schemes whose secret a person types, which is the whole of what `fields` means here. */ +const FIELD_SCHEMES = ["API_KEY", "BASIC", "BEARER_TOKEN", "BASIC_WITH_JWT"]; + +/** + * Which of the three a recorded scheme is. + * + * Everything that is not `NO_AUTH` and not one of the typed schemes is consent, including a scheme + * this file has never heard of: the catalogue is the vendor's and it may name a new one tomorrow, + * and sending somebody to a consent screen that turns out not to exist is a refusal they can read, + * where an empty form is a box they cannot fill in. + */ +function kindOf(authScheme: string | null): BrokeredAccountKind { + if (authScheme === "NO_AUTH") return "no-auth"; + return authScheme !== null && FIELD_SCHEMES.includes(authScheme) + ? "fields" + : "consent"; +} + +/** The row's state and the things it can do, from {@link useBrokeredAccount}. */ export type BrokeredAccount = { /** Whether this person's account is live, as the vendor last answered. */ connected: boolean; @@ -42,12 +74,30 @@ export type BrokeredAccount = { * below can do anything but fail. */ configured: boolean; + /** See {@link BrokeredAccountKind}. Derived here so no branch below re-asks. */ + kind: BrokeredAccountKind; /** Leave for the vendor's consent screen. */ connect: () => void; /** End the account at Composio, not only here. */ disconnect: () => void; connecting: boolean; disconnecting: boolean; + /** + * What the app wants typed in, once it has been asked. Null until then, and for every app nobody + * types anything into. + */ + fields: BrokerField[] | null; + /** Ask the app what it needs, which is the first press on a `fields` app. */ + requestFields: () => void; + /** + * Finish the connection with what the person typed. + * + * The values are handed straight to the request and held nowhere else: they are somebody's own + * key, and this hook keeps no copy a later render could read back. + */ + submitFields: (values: Record) => void; + requestingFields: boolean; + submittingFields: boolean; }; export function useBrokeredAccount(input: { @@ -58,6 +108,14 @@ export function useBrokeredAccount(input: { configured: boolean; /** What this deployment recorded, which is what stands until the vendor has answered anything. */ recorded: boolean; + /** + * How the app's authorization config was CREATED, as the vendor's own scheme literal. + * + * Read off the recorded server row rather than off a connection row: the connections endpoint + * answers two different row shapes, so the absence of a field there says which READ a row came + * from and never how an app connects. Null where the app is not brokered at all. + */ + authScheme: string | null; /** Which screen the vendor's callback puts somebody down on. */ returnTo: "settings" | "admin"; /** @@ -69,7 +127,15 @@ export function useBrokeredAccount(input: { report: (message: string | null) => void; }): BrokeredAccount { const queryClient = useQueryClient(); - const { brokered, configured, recorded, report, returnTo, serverId } = input; + const { + authScheme, + brokered, + configured, + recorded, + report, + returnTo, + serverId, + } = input; /* * Ask the vendor whether this person's brokered account is actually live, on arrival. @@ -141,6 +207,37 @@ export function useBrokeredAccount(input: { }, }); + /* + * The first press on an app nobody consents to: what does it want typed in? + * + * A question about the app rather than about anybody's account, which is why it writes nothing + * and refetches nothing. Its answer is the mutation's own `data` and is not held anywhere else, + * so leaving the screen forgets the form rather than leaving a half-filled one behind. + */ + const fieldsRequest = useMutation({ + ...brokeredConnectionFieldsMutationOptions(), + onError: (thrown: Error) => report(thrown.message), + }); + + /* + * The second press, with the values on it. Its `onSuccess` is called rather than replaced, for + * the reason the disconnect above gives: that is what refetches the recorded row. + * + * The confirmed answer is dropped here too. A row that connects this way arrived with a "not + * connected" answer from the mount, and nothing about typing a key changes the dependencies of + * the effect that asked — so without this the account would go on reading as not connected + * however well the vendor accepted it. + */ + const submitOptions = connectBrokeredWithFieldsMutationOptions(queryClient); + const submission = useMutation({ + ...submitOptions, + onError: (thrown: Error) => report(thrown.message), + onSuccess: (...args) => { + forgetConfirmation(); + return submitOptions.onSuccess?.(...args); + }, + }); + return { /* * What the vendor last answered, and only our own record until it has answered anything. The @@ -160,6 +257,18 @@ export function useBrokeredAccount(input: { disconnect.mutate(serverId); }, disconnecting: disconnect.isPending, + kind: kindOf(authScheme), + fields: fieldsRequest.data ?? null, + requestFields: () => { + report(null); + fieldsRequest.mutate(serverId); + }, + requestingFields: fieldsRequest.isPending, + submitFields: (values: Record) => { + report(null); + submission.mutate({ serverId, values }); + }, + submittingFields: submission.isPending, }; } From f654100286e5ec465b0d337b48bd45324afcd767 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:52:22 +0200 Subject: [PATCH 182/215] Ask each app what it wants from the person connecting, in that app's own words Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 97 ++++++++++++++- server/tests/composio-adapter.test.ts | 158 +++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 15e64d923..465b9b682 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -2,6 +2,7 @@ import { Composio } from "@composio/core"; import { type BrokerApp, type BrokerConnection, + type BrokerField, BrokerRefusalError, type ComposioBroker, type FieldScheme, @@ -163,6 +164,23 @@ type VendorToolkit = { composio_managed_auth_schemes?: unknown; }; +/** + * One toolkit read on its own, which this file wants for exactly one thing: what it asks a person. + * + * ONE FIELD, BECAUSE ONE FIELD IS WHAT IS READ. The per-app retrieve answers everything the + * catalogue row does and a good deal more, and naming any of it here would be this file declaring + * knowledge of a shape nothing below opens. + * + * `unknown` FOR THE REASON EVERY OTHER VENDOR TYPE IN THIS FILE SAYS SO. What hangs off + * `auth_config_details` is a list of modes, each carrying the fields its scheme wants, and every + * one of those is copied across verbatim — so the declaration would be an assertion about the wire + * rather than a fact about it. {@link ComposioBroker.connectionFields} reads it a step at a time + * and shows a person only what it could actually read. + */ +type VendorToolkitDetail = { + auth_config_details?: unknown; +}; + /** * One auth config as the vendor hands it over, which is three fields because all three decide. * @@ -1663,6 +1681,19 @@ export type ComposioVendor = { /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ next_cursor?: unknown; } | null>; + /** + * ONE app, read for the one thing the catalogue listing does not carry: what it asks a person. + * + * The listing above answers which apps exist and which scheme each of them authenticates with; + * it does not answer which boxes a form for that scheme needs. That is published per app and it + * moves — one app wants a key, the next wants a key and a workspace subdomain — so it is asked + * for at the moment a person presses Connect rather than derived from anything held here. See + * {@link ComposioBroker.connectionFields}. + * + * NO PAGE AND NO CURSOR, because a toolkit is one object rather than a listing: this is the one + * vendor read in this file where there is no second page to be mistaken for the whole answer. + */ + retrieve(slug: string): Promise; }; authConfigs: { list(query: { @@ -3285,6 +3316,67 @@ export function buildComposioClient( */ return ids.length > 0; }, + + /** + * What the app itself says it wants typed in, mapped onto the boxes a form can draw. + * + * READ A STEP AT A TIME OFF `unknown`, for the reason every other vendor read here is: the + * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list, a + * scheme this app does not publish and a field row that is not an object are all shapes the + * wire can send. Each of those answers an empty form rather than a crash — an app whose scheme + * publishes nothing to fill in is an app with nothing to ask, which is a true thing to say and + * the one thing a `[]` here means. + * + * REQUIRED AND OPTIONAL IN THAT ORDER, because the order is what a person reads down. The + * vendor publishes them as two lists and the required ones are the ones that stop the form; a + * form that interleaved them, or put the optional base-url box above the key, would be asking + * somebody to hunt for the field they came to fill in. + */ + async connectionFields({ toolkit, authScheme }): Promise { + const detail = await askVendor( + { + outcome: `what ${toolkit} asks for could not be read, so there is nothing to show`, + app: toolkit, + }, + () => vendor.toolkits.retrieve(toolkit), + ); + + const modes = Array.isArray(detail?.auth_config_details) + ? detail.auth_config_details + : []; + const mode = modes.find( + (candidate: { mode?: unknown }) => candidate?.mode === authScheme, + ); + const published = mode?.fields?.connected_account_initiation; + const rows = [ + ...(Array.isArray(published?.required) ? published.required : []), + ...(Array.isArray(published?.optional) ? published.optional : []), + ]; + + return rows + .filter((row) => row?.user_visible !== false) + .map((row) => { + /* + * A TYPE THIS DEPLOYMENT CANNOT DRAW IS A REFUSAL RATHER THAN A TEXT BOX. Every required + * field measured across the catalogue is a plain string, so this is a guard against the + * vendor rather than a routine case — and the failure it prevents is somebody typing a + * path into a box labelled Certificate and being told they are connected. + */ + if (row?.type !== "string") { + throw new BrokerRefusalError( + `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)}, which cannot be filled in here. Connecting this app is not something this deployment can offer yet.`, + ); + } + return { + name: String(row.name), + label: textOf(row.displayName) ?? String(row.name), + help: textOf(row.description) ?? "", + required: row.required === true, + secret: row.is_secret === true, + ...(textOf(row.default) ? { default: String(row.default) } : {}), + }; + }); + }, }; return { actions, broker }; @@ -3340,7 +3432,10 @@ export function createComposioClient(apiKey: string): { composio.tools.getRawComposioToolBySlug(slug, options), execute: (slug, body) => composio.tools.execute(slug, body), }, - toolkits: { list: (query) => client.toolkits.list(query) }, + toolkits: { + list: (query) => client.toolkits.list(query), + retrieve: (slug) => client.toolkits.retrieve(slug), + }, authConfigs: { list: (query) => composio.authConfigs.list(query), create: (toolkit, options) => diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 5331205f2..ee4c47482 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -76,6 +76,7 @@ function fakeVendor(parts: { }, toolkits: { list: refuse("toolkits.list"), + retrieve: refuse("toolkits.retrieve"), ...parts.toolkits, }, authConfigs: { @@ -2449,6 +2450,12 @@ describe("what a vendor failure becomes on its way out of the seam", () => { because: "The account listing did not answer. This is a gate rather than a page, and its caller refuses the run either way.", }, + { + method: "connectionFields", + kind: "an outage", + because: + "Reading what the app asks a person for did not answer. No form was drawn and nothing was attached, so there is nothing about this app to say that the route's own advice — the deployment's key, the vendor's status page — does not already cover.", + }, /* * THE TWO REASONS BELOW USED TO BE FALSE, AND THIS IS THE CASE THE ALLOW-LIST CANNOT CATCH BY * ITSELF. Its assertion asks whether an AUTHORED sentence reached the reader, so an entry whose @@ -2548,6 +2555,15 @@ describe("what a vendor failure becomes on its way out of the seam", () => { ask: ({ broker }) => broker.revoke({ userId: "user_1", toolkit: "gmail" }), }, + { + method: "connectionFields", + vendor: (raise) => ({ toolkits: { retrieve: raise } }), + ask: ({ broker }) => + broker.connectionFields({ + toolkit: "perplexityai", + authScheme: "API_KEY", + }), + }, { method: "listActions", vendor: (raise) => ({ tools: { list: raise } }), @@ -5022,3 +5038,145 @@ describe("listings Composio pages, read to the end", () => { ); }); }); + +/** + * What an app asks a person to type, read off the vendor rather than written down here. + * + * THE FIXTURES ARE MEASURED ANSWERS RATHER THAN INVENTED ONES. The first is `perplexityai`'s + * `API_KEY` mode as Composio publishes it, down to the `legacy_template_name` this deployment does + * not read — because the value of asking the vendor at all is that the form follows what the app + * actually wants, and a fixture composed of the four fields the mapping happens to touch would + * assert that mapping against a shape no app sends. + */ +describe("the fields an app asks a person to fill in", () => { + test("the fields an app wants come back as the app describes them", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "generic_api_key", + displayName: "API Key", + description: + "Your secret Perplexity API key, starting with 'pplx-'.", + type: "string", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + expect( + await broker.connectionFields({ + toolkit: "perplexityai", + authScheme: "API_KEY", + }), + ).toEqual([ + { + name: "generic_api_key", + label: "API Key", + help: "Your secret Perplexity API key, starting with 'pplx-'.", + required: true, + secret: true, + }, + ]); + }); + + test("a field of a type this deployment cannot draw is refused rather than drawn blind", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "cert", + displayName: "Certificate", + type: "file", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "mystery", authScheme: "API_KEY" }), + ); + + /* + * A TEXT BOX DRAWN FOR A FILE IS THE FAILURE THIS PREVENTS: somebody types a path into a box + * labelled Certificate, the connection is made, and the first tool call is what discovers it. + * Every required field measured across the catalogue is a plain string, so this is a guard + * against the vendor changing rather than a routine case. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/cannot be filled in here/); + }); + + test("a field Composio marks invisible is not shown", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + name: "internal_tenant", + displayName: "Tenant", + description: "", + type: "string", + required: true, + is_secret: false, + user_visible: false, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + expect( + await broker.connectionFields({ + toolkit: "hidden", + authScheme: "API_KEY", + }), + ).toEqual([]); + }); +}); From 09967b2e322d79305e4f8dd0db99d5344da963c8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 14:54:45 +0200 Subject: [PATCH 183/215] Tell both screens' rows the scheme the recorded server row already carries Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routes/_authed/admin/plugins/$key.tsx | 1 + app/src/routes/_authed/settings/connected-accounts/$key.tsx | 1 + server/tests/plugin-store.integration.test.ts | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 017e2bd9d..b0b1f5155 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -225,6 +225,7 @@ function RouteComponent() { * no Disconnect beside it and nothing brokered to confirm. */ const brokeredAccount = useBrokeredAccount({ + authScheme: server?.authScheme ?? null, brokered: auth === "brokered", configured: plugins.data?.composioConfigured ?? false, recorded: youConnected, diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 51f9e0d44..adeef0463 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -80,6 +80,7 @@ function RouteComponent() { /* Everything the brokered row below reads and does. See `brokered-account-row.tsx`. */ const brokeredAccount = useBrokeredAccount({ + authScheme: server?.authScheme ?? null, brokered, configured: plugins.data?.composioConfigured ?? false, recorded: connection !== undefined, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1e17a6a48..94440f284 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -4840,6 +4840,10 @@ test("a server's address is the url the row holds, not one composed from its id" // Not `composio://gmail`. That is what composing the url from the id would have answered, and // it is the app this row does not run against. url: "composio://slack", + // Null, because a scheme is written down when an authorization config is CREATED and this + // fixture creates none. The column holds nothing for a row nothing was created against rather + // than a default standing in for one, so null here is the read passing the column through. + authScheme: null, }); /* * By membership rather than by equality, because this database is not only this test's: the From d27c39910bc297812492fb60a84c93cad9f117d4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:05:54 +0200 Subject: [PATCH 184/215] Connect one person with what they typed, and drop the failure because the failure holds their key Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 172 +++++++++++++- server/tests/composio-adapter.test.ts | 301 +++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 465b9b682..fc4364e44 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1925,6 +1925,47 @@ export type ComposioVendor = { authConfigId: string, options: { callbackUrl: string }, ): Promise<{ redirectUrl?: unknown }>; + /** + * Create one person's account from the secret they typed, WITHOUT a consent screen anywhere. + * + * THE RAW CLIENT FOR THE REASON BOTH DELETES ARE RAW, and a sharper one. `@composio/core`'s + * `connectedAccounts.initiate` is the wrapper over this endpoint, and the endpoint under it is + * retired for the managed-auth path — it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:146-160`) — while `link`, which replaced it, mints a consent + * url and has nowhere to put a typed value at all. What this flow needs is neither: the person + * has already typed their credential into a form here, so there is no screen to send them to + * and nothing to come back from. `@composio/client`'s own create takes the state directly + * (`0.1.0-alpha.76`, `resources/connected-accounts.d.ts:33`, `:7502-7511`), which is the whole + * of what this call is. + * + * NO `validate_credentials`, AND ITS ABSENCE IS DELIBERATE RATHER THAN AN OVERSIGHT. The + * parameter exists on the same body and the vendor marks it EXPERIMENTAL (`:7505-7509`). + * Whether a typed key actually works is settled here by making a call with it rather than by + * asking Composio to grade it, because a connection Composio accepts is not a connection that + * works: a wrong value comes back `ACTIVE`. + * + * `id` AND `status` READ AS `unknown`, for the reason every other vendor field in this + * projection is. The generated client declares both as required + * (`ConnectedAccountCreateResponse`, `:121-146`), which is the schema's promise about what + * Composio means to send rather than a fact about what arrived — and the id is the one field + * this whole call exists to answer, so a missing one is a refusal here rather than an + * `undefined` handed on as the account somebody is meant to be able to undo. + */ + create(body: { + auth_config: { id: string }; + /** + * The person, and the secret they typed, in the shape the vendor's own builder assembles. + * + * `state` IS `unknown` BECAUSE WHAT GOES IN IT IS THE APP'S QUESTION RATHER THAN THIS FILE'S + * ANSWER. The generated client declares it as a fourteen-member union keyed on the scheme, + * each member's `val` carrying whatever fields that app publishes plus `[k: string]: unknown` + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7551`, `:8083-8086`) + * — so naming a shape here would be this file asserting which boxes an app asks for, which is + * exactly the thing {@link ComposioBroker.connectionFields} exists to go and ask. + */ + connection: { user_id: string; state: unknown }; + }): Promise<{ id?: unknown; status?: unknown }>; /** * Delete one connected account, and ask for the grant behind it to be revoked too. * @@ -3367,16 +3408,128 @@ export function buildComposioClient( `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)}, which cannot be filled in here. Connecting this app is not something this deployment can offer yet.`, ); } + /* + * THE TRIMMED VALUE IS WHAT REACHES THE FORM, because the trimmed value is what was + * judged. This tested `textOf(row.default)` and emitted `String(row.default)`, so a + * default Composio padded passed the test on its trimmed form and arrived in the box with + * its padding — the same disagreement between guard and return that {@link vendorSentence} + * was corrected for, one field away from a value somebody then submits as typed. + */ + const suggested = textOf(row.default); return { name: String(row.name), label: textOf(row.displayName) ?? String(row.name), help: textOf(row.description) ?? "", required: row.required === true, secret: row.is_secret === true, - ...(textOf(row.default) ? { default: String(row.default) } : {}), + ...(suggested === null ? {} : { default: suggested }), }; }); }, + + /** + * One person's account made from what they typed, and the ONE call here that drops its error. + * + * THIS IS THE SINGLE PLACE THIS FILE'S CAUSE-CARRYING RULE REVERSES, AND IT SAYS SO ON PURPOSE. + * The rule the module comment states and every other path keeps is that a vendor error is never + * logged and always carried as `cause`, precisely because the object holds the request it was + * made for and whoever is reading a log rather than a page deserves it. On every other call + * that request is a link mint or a delete. On this one it is somebody's API key: the body below + * carries the value they pasted into the form, and an `APIError` out of a create carries the + * body. So the catch reads the vendor's sentence through the one door {@link vendorSentence} + * owns and then drops the object entirely — not attached, not rethrown, nothing left for a + * handler further out to serialize. + * + * WHAT THAT COSTS IS THE DIAGNOSTIC TRAIL ON THE FLOW PEOPLE MOST OFTEN MISTYPE, AND THE COST IS + * ACCEPTED KNOWINGLY. A key pasted with a newline, a token from the wrong workspace, a secret + * for the staging tenant — these are the ordinary failures here, and this leaves nothing behind + * about any of them. What an operator gets instead is Composio's own sentence and the request + * id inside it, which is what Composio's dashboard searches on: enough to ask the vendor about + * that attempt, and not enough to rebuild it here. A `cause` that made the next mistyped key + * easier to explain would put every correctly typed one in a log for as long as the log is kept. + * + * NOT {@link askVendor}, WHICH IS THE SAME DECISION SEEN FROM THE OTHER SIDE. That function is + * what every other vendor call in this file goes through, and every refusal it builds attaches + * the original — see {@link vendorRefusal}, where the `cause` is the point. Routing this call + * through it would be the rule applying here by default, which is the one place it must not. + * + * NO VERIFICATION HERE, AND THE ABSENCE IS NOT AN OVERSIGHT. Composio does not grade a submitted + * key: a connection created with an obviously wrong value comes back `ACTIVE`. Whether the + * credential works is settled by making a call with it, which is why this answers the + * `accountId` — so whoever does that can take back exactly the account it made and nothing else. + * See {@link ComposioBroker.revokeAccount}. + */ + async connectWithFields({ + userId, + toolkit, + authScheme, + values, + }): Promise<{ accountId: string }> { + /* + * THIS DEPLOYMENT'S OWN CONFIG, FOR THE REASON {@link ComposioBroker.authorize} READS ONE: an + * account is a lasting attachment to whatever config it was made against, so attaching + * somebody to an operator's hand-made config — scopes this deployment cannot see, tool + * restrictions it cannot read, an object it must not delete — is not a guess that can be + * corrected afterwards. + */ + const { ours } = await configsFor(toolkit); + const config = ours[0]; + if (!config) { + throw new BrokerRefusalError( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against and nothing was sent. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + let created: { id?: unknown }; + try { + created = await vendor.connectedAccounts.create({ + auth_config: { id: config.id }, + connection: { + user_id: userId, + /* + * THE SCHEME AND THE TYPED VALUES, IN THE SHAPE THE VENDOR'S OWN BUILDER ASSEMBLES. + * `AuthScheme.APIKey` and its siblings all return `{ authScheme, val: { status: + * ACTIVE, ...fields } }` (`@composio/core` 0.18.1, `src/models/AuthScheme.ts:84-94`), + * and the field NAMES are Composio's own — published per app by + * {@link ComposioBroker.connectionFields} and sent back verbatim, because a name this + * file renamed on the way through is a box somebody filled in that no app ever reads. + */ + state: { + authScheme, + val: { status: "ACTIVE", ...values }, + }, + }, + }); + } catch (error) { + /* + * ONLY THE SENTENCE LEAVES THIS BLOCK. `vendorSentence` is the one door in this deployment + * for reading a vendor's own words, and reading it is the whole of what `error` is used + * for: it is not attached, not rethrown and not named below this line. + */ + const said = vendorSentence(error); + throw new BrokerRefusalError( + said === null + ? `Composio did not accept the connection to ${toolkit} and said nothing this deployment can pass on. The failure arrived through this deployment's @composio/core with no sentence of Composio's on it, which is what an outage, a cancelled request and a reply the package could not read all look like from here — and the failure itself was dropped rather than recorded, because on this one call the object carrying it also carries what was typed into the form. Nothing was attached. Composio's own dashboard logs the attempt, and asking again is what settles whether the request ever landed.` + : `Composio refused the connection to ${toolkit}: ${said} Nothing was attached. Nothing further about this attempt is kept here, because what the failure carried was the value typed into the form — the request id in Composio's own words above is what their dashboard searches on.`, + ); + } + + /* + * NO ID IS NOT A CONNECTION, HOWEVER THE REPLY READS. The id is the whole of what this method + * answers and the only thing a caller can undo its own work with, so handing back an + * `undefined` cast to a string would leave an account standing at Composio that nothing on + * this deployment can name, made from a credential somebody typed a moment ago. The dashboard + * is named because it is the only place that account can now be seen and removed. + */ + const accountId = textOf(created?.id); + if (accountId === null) { + throw new BrokerRefusalError( + `Composio answered the connection to ${toolkit} with no account id, so this deployment cannot name the account it just asked for and cannot take it back. An account may be standing at Composio over this: ${toolkit} in Composio's own dashboard is where it can be seen and removed. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { accountId }; + }, }; return { actions, broker }; @@ -3446,6 +3599,23 @@ export function createComposioClient(apiKey: string): { list: (query) => composio.connectedAccounts.list(query), link: (userId, authConfigId, options) => composio.connectedAccounts.link(userId, authConfigId, options), + /* + * The create is the raw client's for a third reason of its own: the SDK's wrapper over this + * endpoint is `initiate`, which is retired for the managed-auth path, and `link` above — its + * replacement — mints a consent url and has nowhere to put a value a person typed. + * + * THE ONE ASSERTION IN THIS FUNCTION, AND IT IS ABOUT `state` ALONE. The generated client + * declares that field as a fourteen-member union keyed on the scheme, each member's `val` + * ending in `[k: string]: unknown` — which is to say the vendor's own type admits any object + * once the scheme is picked, and picking it here would be this file asserting which boxes an + * app asks a person for. {@link ComposioVendor} therefore says `unknown` and the widening is + * spent here, at the one line where this deployment's projection meets the vendor's schema + * and nothing else is decided. + */ + create: (body) => + client.connectedAccounts.create( + body as Parameters[0], + ), delete: (id, params) => client.connectedAccounts.delete(id, params), }, }); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index ee4c47482..deb78a5a8 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -88,6 +88,7 @@ function fakeVendor(parts: { connectedAccounts: { list: refuse("connectedAccounts.list"), link: refuse("connectedAccounts.link"), + create: refuse("connectedAccounts.create"), delete: refuse("connectedAccounts.delete"), ...parts.connectedAccounts, }, @@ -2564,6 +2565,23 @@ describe("what a vendor failure becomes on its way out of the seam", () => { authScheme: "API_KEY", }), }, + { + method: "connectWithFields", + vendor: (raise) => ({ + // The configs first, for the reason the withdrawal above answers them first: the connection + // is made against this deployment's own config, so the read that finds one has to succeed + // before the create this row is about can be reached at all. + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { create: raise }, + }), + ask: ({ broker }) => + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: "never-sent-anywhere" }, + }), + }, { method: "listActions", vendor: (raise) => ({ tools: { list: raise } }), @@ -5179,4 +5197,287 @@ describe("the fields an app asks a person to fill in", () => { }), ).toEqual([]); }); + + test("a default Composio padded arrives in the box without its padding", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [], + optional: [ + { + name: "base_url", + displayName: "Base URL", + description: "", + default: " https://api.example.com ", + type: "string", + required: false, + is_secret: false, + user_visible: true, + }, + ], + }, + }, + }, + ], + }), + }, + }), + ); + + /* + * THE GUARD AND THE ANSWER READ THE SAME VALUE, which is what this is about rather than the + * whitespace. The method tested `textOf(row.default)` — which trims — and emitted + * `String(row.default)` — which does not — so a padded default passed a judgement made about + * one string and reached the form as another. What a person then submits is whatever is in the + * box, so the padding would travel on into the connection as part of the value. + */ + expect( + await broker.connectionFields({ + toolkit: "padded", + authScheme: "API_KEY", + }), + ).toEqual([ + { + name: "base_url", + label: "Base URL", + help: "", + required: false, + secret: false, + default: "https://api.example.com", + }, + ]); + }); +}); + +/** + * THE ONE CALL IN THIS SEAM THAT DROPS THE VENDOR'S ERROR RATHER THAN CARRYING IT. + * + * Every other refusal below this adapter keeps the original as `cause`, deliberately, because the + * object holds the request it was made for and whoever is reading a log rather than a page deserves + * it. On every other call that request is a link mint or a delete. On this one it is somebody's API + * key — so the rule reverses here, and the reversal is worth a test rather than a comment because + * nothing about it is visible in a type or in a passing happy path. + * + * THE LEAK TEST ASKS THE WHOLE THROWN OBJECT AND NOT ITS MESSAGE. `JSON.stringify` of an `Error` is + * `{}` — its fields are non-enumerable — so a check written against that would stay green over a + * `cause` carrying the entire request body, which is exactly the defect this is about. What is + * asserted is that a recognisable secret planted on the vendor's error reaches none of the message, + * the `cause`, or any own property of what escapes, at any depth. + */ +describe("connecting one person with the secret they typed", () => { + /** Shaped like the thing a person pastes into the form, and recognisable wherever it surfaces. */ + const TYPED_SECRET = "pplx-LEAK-CANARY-3f9a2c"; + + /** + * Every string the thrown object carries, its own property names included, at every depth. + * + * `JSON.stringify(error)` answers `{}` for an `Error`, because `message`, `stack` and `cause` are + * all non-enumerable — so a leak test written against it would pass over a `cause` holding the + * whole request. This asks for the own property names at each level and follows whatever hangs + * off them, which is where the secret would be if this call ever carried the vendor's object out. + */ + function everythingCarriedBy(value: unknown, depth = 0): string { + // Deep enough to reach a typed value at the bottom of a request body hanging off a `cause`, + // which is seven levels down from the thrown error and is the whole thing this is looking for. + if (depth > 12) return ""; + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return String(value); + const held = value as Record; + return Object.getOwnPropertyNames(held) + .map((name) => `${name}=${everythingCarriedBy(held[name], depth + 1)}`) + .join(" "); + } + + /** + * The thrown object as a handler further out would serialize it, hidden fields expanded. + * + * `JSON.stringify(error)` IS `{}` AND THE OBVIOUS FIX IS BARELY BETTER. An `Error`'s `message`, + * `stack` and `cause` are all non-enumerable, so the first form sees none of them — and + * `JSON.stringify(error, Object.getOwnPropertyNames(error))` passes a replacer ARRAY, which is a + * key allow-list applied at EVERY depth: it names the top error's three fields and then filters + * the request body out of the very `cause` it just let through. Measured against the version of + * this adapter that attached the cause, that assertion passed while the key was two levels below + * it. The replacer below expands each error into its own property names instead and lets the + * plain objects under them through whole, which is the serialization this call has to survive. + */ + function serializedWith(error: Error): string { + return JSON.stringify(error, (_key, value: unknown) => { + if (!(value instanceof Error)) return value; + const own = value as unknown as Record; + return Object.fromEntries( + Object.getOwnPropertyNames(own).map((name) => [name, own[name]]), + ); + }); + } + + test("what the person typed is sent as this connection's state, and the account comes back", async () => { + const asked: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + create: async (body: unknown) => { + asked.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + expect( + await broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET, subdomain: "acme" }, + }), + ).toEqual({ accountId: "ca_new" }); + + /* + * THE WHOLE BODY, because every part of it decides something. The config is this deployment's + * own rather than whichever row the vendor listed first; the user id is what every later call + * names the account by; and the state is the scheme the form was drawn for with the typed + * values under it, which is the shape `AuthScheme.APIKey` builds (`@composio/core` 0.18.1, + * `src/models/AuthScheme.ts:84-94`) and the one the raw create declares. + */ + expect(asked).toEqual([ + { + auth_config: { id: OURS.id }, + connection: { + user_id: "user_1", + state: { + authScheme: "API_KEY", + val: { + status: "ACTIVE", + generic_api_key: TYPED_SECRET, + subdomain: "acme", + }, + }, + }, + }, + ]); + }); + + test("a failure on that call carries no vendor object, because the object holds the key", async () => { + /* + * THE VENDOR'S ERROR AS IT ARRIVES FROM A CREATE THAT WAS REFUSED. `@composio/client` hangs the + * response body on `.error` — which is the shallower of the two depths `vendorSentence` reads — + * and the object also carries the request it was made for. On this one call that request is the + * form somebody just filled in, which is why the secret below is planted there and nowhere in + * the sentence: what must survive is Composio's own words, and what must not is everything else. + */ + const raised = Object.assign( + new Error(`400 {"error":{"message":"Invalid credential"}}`), + { + error: { + error: { + message: + "Composio could not use that credential for linear (request req_9f3c).", + }, + }, + request: { + body: { + auth_config: { id: OURS.id }, + connection: { + user_id: "user_1", + state: { + authScheme: "API_KEY", + val: { status: "ACTIVE", generic_api_key: TYPED_SECRET }, + }, + }, + }, + }, + }, + ); + + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { + create: async () => { + throw raised; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + // Composio's own sentence is what an operator is left with, request id and all, because that is + // the string their dashboard searches on and the only diagnostic this call agrees to keep. + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/req_9f3c/); + + // And the object it came out of is gone: not rethrown, not attached, not reachable from what a + // handler further out will serialize. + expect(refusal).not.toBe(raised); + expect(refusal.cause).toBeUndefined(); + expect(everythingCarriedBy(refusal)).not.toContain(TYPED_SECRET); + expect(serializedWith(refusal)).not.toContain(TYPED_SECRET); + expect(everythingSaidBy(refusal).join("\n")).not.toContain(TYPED_SECRET); + }); + + test("a reply with no account id is refused rather than answered as a connection", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { list: async () => ({ items: [OURS] }) }, + connectedAccounts: { create: async () => ({ status: "ACTIVE" }) }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + /* + * AN ACCOUNT MAY BE STANDING AT COMPOSIO OVER A REFUSAL HERE, which is the one thing this + * sentence has to carry: the id is what a caller undoes its own work with, so without one there + * is a connection nothing on this deployment can name or take back. The dashboard is where it + * can be seen and removed, and it is the only remedy there is. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(/dashboard/); + expect(refusal.message).not.toContain(TYPED_SECRET); + }); + + test("an app with no config of this deployment's is refused before anything is sent", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + // The create is left at `fakeVendor`'s refusal, which is what says the value typed in went + // nowhere: a call that was made would name itself here rather than answering. + authConfigs: { list: async () => ({ items: [BY_HAND] }) }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toContain(TYPED_SECRET); + }); }); From acd34645565571f102df56c4961c4e2e898bb82c Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:10:02 +0200 Subject: [PATCH 185/215] Take back the one account a verification made, rather than sweeping the person's app Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 39 +++++++++++++++ server/tests/composio-adapter.test.ts | 69 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index fc4364e44..cfde1fa61 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3530,6 +3530,45 @@ export function buildComposioClient( return { accountId }; }, + + /** + * ONE account ended by id, and NOT {@link ComposioBroker.revoke}, which is the whole decision. + * + * `revoke` ends every account a person holds for an app. That is right for what it serves — a + * person ending their access, where any account left behind is access that still answers — and + * it is wrong for a verification undoing what it just made. The two read identically right up + * until the local row and Composio have drifted apart, and that is precisely the state a failed + * verification stands in: a connection that was working, a second account just created from a + * key that does not work, and a sweep that takes down both. So this method is handed the id and + * nothing else — nothing is listed, nothing is matched, and no account it was not given can be + * reached from here. + * + * WITH `revoke_on_delete`, FOR THE REASON {@link ComposioVendor}'s `delete` GIVES AND ONE MORE + * OF ITS OWN. Without the flag the account stops being visible to this deployment and the + * credential at the far end stands; here that credential is one somebody typed into a form + * minutes ago, into a page that is about to tell them the connection was not kept. + * + * NOTHING IS ANSWERED AND NOTHING IS SWALLOWED. There is no count to report — the id names one + * account that existed moments ago — so a failure is a failure, and it leaves through + * {@link askVendor} like every other vendor call in this file. + */ + async revokeAccount(accountId): Promise { + await askVendor( + { + outcome: + "the one account this call was handed was not withdrawn and may be standing at Composio", + /* + * NO APP IN THE QUESTION, which is what the id being the whole of it means. The caller + * holds the toolkit it connected and can say it; this call was given an account. + */ + app: null, + }, + () => + vendor.connectedAccounts.delete(accountId, { + revoke_on_delete: true, + }), + ); + }, }; return { actions, broker }; diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index deb78a5a8..fca2be5bd 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -2457,6 +2457,12 @@ describe("what a vendor failure becomes on its way out of the seam", () => { because: "Reading what the app asks a person for did not answer. No form was drawn and nothing was attached, so there is nothing about this app to say that the route's own advice — the deployment's key, the vendor's status page — does not already cover.", }, + { + method: "revokeAccount", + kind: "an outage", + because: + "The delete did not answer. The vendor's own bare message travels on untouched, naming nothing, because there is nothing else to say: the account may be standing at Composio and this deployment cannot tell whether the request landed. Its caller is undoing its own work rather than a person pressing a button, and what it is owed is the failure itself.", + }, /* * THE TWO REASONS BELOW USED TO BE FALSE, AND THIS IS THE CASE THE ALLOW-LIST CANNOT CATCH BY * ITSELF. Its assertion asks whether an AUTHORED sentence reached the reader, so an entry whose @@ -2582,6 +2588,13 @@ describe("what a vendor failure becomes on its way out of the seam", () => { values: { generic_api_key: "never-sent-anywhere" }, }), }, + { + method: "revokeAccount", + vendor: (raise) => ({ connectedAccounts: { delete: raise } }), + // No listing on the way in, which is the whole shape of this method: the id is what it was + // handed, so the delete is the first vendor call it makes and the only one it can fail at. + ask: ({ broker }) => broker.revokeAccount("ca_new"), + }, { method: "listActions", vendor: (raise) => ({ tools: { list: raise } }), @@ -5481,3 +5494,59 @@ describe("connecting one person with the secret they typed", () => { expect(refusal.message).not.toContain(TYPED_SECRET); }); }); + +/** + * ENDING ONE ACCOUNT BY ID, WHICH IS A DIFFERENT QUESTION FROM ENDING A PERSON'S ACCESS. + * + * `revoke` above is asked "this person is done with this app" and has to go and find out what that + * means: it lists, it matches, and it deletes everything it found. This one is asked "take back the + * account you just made", and the id it is handed is the whole of the question — so the listing + * that makes the other method correct is, here, both a call nothing needs and a set of accounts + * nobody asked about. The two differ exactly where the local row and Composio have drifted apart, + * which is the state a failed verification is standing in: a connection that works beside the + * attempt that did not. The absence of the listing is therefore asserted rather than assumed. + */ +describe("taking back the one account a verification just made", () => { + test("the delete names that account and asks for the grant behind it, with nothing listed first", async () => { + const deleted: unknown[] = []; + /* + * THE LISTINGS ANSWER RATHER THAN REFUSE, deliberately, and that is what makes this an + * assertion about the method instead of about {@link fakeVendor}. Left at the refusals, a + * sweeping implementation would fail here on a thrown fixture and the failure would read like + * an unrelated vendor error; answering means a sweep gets everything it needs and is caught by + * the one thing that is actually wrong with it — that it went looking at all. + */ + const listed: string[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => { + listed.push("authConfigs.list"); + return { items: [OURS] }; + }, + }, + connectedAccounts: { + list: async () => { + listed.push("connectedAccounts.list"); + return { items: [{ id: "ca_new" }] }; + }, + delete: async (id: unknown, params: unknown) => { + deleted.push([id, params]); + return WITHDRAWN; + }, + }, + }), + ); + + await broker.revokeAccount("ca_new"); + + /* + * THE ID AS IT WAS HANDED OVER, AND THE FLAG BESIDE IT. Without `revoke_on_delete` the account + * stops being visible to this deployment and the credential at the far end stands — which is + * worse here than anywhere else in this file, because the secret left live is one somebody + * typed into a form minutes ago that then told them the connection had not been kept. + */ + expect(deleted).toEqual([["ca_new", { revoke_on_delete: true }]]); + expect(listed).toEqual([]); + }); +}); From e5ab7f8ae1fee62a5a29bd33debf6853483b10fa Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:30:20 +0200 Subject: [PATCH 186/215] Refuse before the key travels, read the withdrawal's verdict, and tell a missing mode from an empty one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/composio-adapter.ts | 156 +++++++++++-- server/tests/composio-adapter.test.ts | 296 +++++++++++++++++++++++++ 2 files changed, 438 insertions(+), 14 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index cfde1fa61..2a733e8e8 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3362,11 +3362,22 @@ export function buildComposioClient( * What the app itself says it wants typed in, mapped onto the boxes a form can draw. * * READ A STEP AT A TIME OFF `unknown`, for the reason every other vendor read here is: the - * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list, a - * scheme this app does not publish and a field row that is not an object are all shapes the - * wire can send. Each of those answers an empty form rather than a crash — an app whose scheme - * publishes nothing to fill in is an app with nothing to ask, which is a true thing to say and - * the one thing a `[]` here means. + * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list and a + * field row that is not an object are both shapes the wire can send. Those answer an empty form + * rather than a crash — an app whose scheme publishes nothing to fill in is an app with nothing + * to ask, which is a true thing to say and the one thing a `[]` here means. + * + * AND A SCHEME THIS APP NO LONGER PUBLISHES IS THE ONE OF THOSE THAT IS NOT AN EMPTY FORM, + * WHICH IS THE CORRECTION. The scheme is the RECORDED one and is never re-derived — that is the + * whole point of the column, and {@link ComposioBroker.connectionFields} says so — so "Composio + * does not publish this mode for this app" is exactly the drift between the row and the vendor + * that recording it anticipates, and it used to surface as a form with no boxes in it. A person + * presses submit on that form, {@link ComposioBroker.connectWithFields} creates a connection + * carrying no credential at all, Composio answers `ACTIVE` because it does not grade what it is + * given, and the first call made with the account is what discovers anything is wrong. An empty + * form is also, from the person's end, a box they cannot fill in. So the two states are told + * apart here: no such mode refuses and names the recorded one, and a mode that exists and + * publishes nothing visible still answers `[]`. * * REQUIRED AND OPTIONAL IN THAT ORDER, because the order is what a person reads down. The * vendor publishes them as two lists and the required ones are the ones that stop the form; a @@ -3388,7 +3399,20 @@ export function buildComposioClient( const mode = modes.find( (candidate: { mode?: unknown }) => candidate?.mode === authScheme, ); - const published = mode?.fields?.connected_account_initiation; + /* + * THE REMEDY IS AN ADMINISTRATOR'S BECAUSE THE RECORDED SCHEME IS ONLY THEIRS TO REWRITE. + * Nothing a person pressing Connect can do changes which mode this app was enabled as, and + * nothing on this path may quietly pick a different one — a form drawn for whatever Composio + * publishes today, in front of an authorization config created for the word on the row, is + * the same disagreement one layer further in. Removing the app and adding it again is the one + * path that records the scheme afresh, so it is the one named. + */ + if (mode === undefined) { + throw new BrokerRefusalError( + `Composio no longer publishes a ${authScheme} connection for ${toolkit}, and ${toolkit}'s authorization config here was created as ${authScheme}, so there is nothing to ask this person for — and an empty form is a box they cannot fill in and a connection carrying no credential at all. An administrator removing the app on its Plugins page and adding it again is what records the scheme Composio publishes for it now.`, + ); + } + const published = mode.fields?.connected_account_initiation; const rows = [ ...(Array.isArray(published?.required) ? published.required : []), ...(Array.isArray(published?.optional) ? published.optional : []), @@ -3402,10 +3426,19 @@ export function buildComposioClient( * field measured across the catalogue is a plain string, so this is a guard against the * vendor rather than a routine case — and the failure it prevents is somebody typing a * path into a box labelled Certificate and being told they are connected. + * + * AND THE NAME IS IN THE SAME GUARD, because it is the least guarded field here and the + * only one that travels. `label`, `help` and `default` all pass through {@link textOf} + * and are read by a person; the name is sent back to Composio verbatim and is the key + * {@link ComposioBroker.connectWithFields} spreads into the connection's `val`. Coerced + * with `String(...)`, a row carrying `type: "string"` and no name drew a box literally + * called "undefined" and then submitted whatever was typed in it under that key — a value + * no app reads, in a connection Composio accepts. */ - if (row?.type !== "string") { + const name = textOf(row?.name); + if (row?.type !== "string" || name === null) { throw new BrokerRefusalError( - `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)}, which cannot be filled in here. Connecting this app is not something this deployment can offer yet.`, + `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)} under the name ${sent(row?.name)}, which cannot be filled in here: a box this deployment can draw is a string, and a box whose answer can be sent back has a name. Connecting this app is not something this deployment can offer yet.`, ); } /* @@ -3417,8 +3450,8 @@ export function buildComposioClient( */ const suggested = textOf(row.default); return { - name: String(row.name), - label: textOf(row.displayName) ?? String(row.name), + name, + label: textOf(row.displayName) ?? name, help: textOf(row.description) ?? "", required: row.required === true, secret: row.is_secret === true, @@ -3472,14 +3505,77 @@ export function buildComposioClient( * restrictions it cannot read, an object it must not delete — is not a guess that can be * corrected afterwards. */ - const { ours } = await configsFor(toolkit); - const config = ours[0]; - if (!config) { + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND IT IS THE SAME TWO + * REMEDIES {@link ComposioBroker.authorize} TELLS APART. "Remove the app and add it again" + * is right where the listing was legible and said none of these is ours, and wrong here: + * the row that could not be sorted may BE ours, in which case the removal meets + * {@link ComposioBroker.deleteAuthConfig}'s own refusal and the re-enable meets + * `ensureAuthConfig`'s. That is an administrator sent round a loop that cannot close, and + * this method used to hand them exactly that sentence. + * + * NO `cause` ON ANY OF THE REFUSALS IN THIS METHOD, which is the local rule rather than the + * file's. Not one of the four refusals this config read can raise carries a vendor object at + * all — they are this file's own reading of a listing — but attaching + * `everyRefusal(unreadable)` here, as `authorize` correctly does, would put an error chain + * on the one method whose call frame holds somebody's API key, and the whole of this + * method's doc comment is about not doing that. The count is the finding, and the count is + * in the sentence. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and what was typed into the form was not sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } throw new BrokerRefusalError( `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against and nothing was sent. An administrator removing the app on its Plugins page and adding it again creates one.`, ); } + /* + * THE ENABLED ONE, WHICH IS THE SAME CHOICE {@link ComposioBroker.authorize} MAKES AND FOR ONE + * REASON MORE. `configsFor` lists with `showDisabled: true` — it has to, or `ensureAuthConfig` + * creates a second config beside one it cannot see — so `ours[0]` could perfectly well be a + * DISABLED config, and an account created against one cannot work. Reading that state only + * after the create is the wrong order on this path more than on any other: the body of that + * request is the key somebody just pasted in, so the refusal has to arrive BEFORE the + * credential leaves this process rather than after Composio has been handed it and declined. + * + * AND IT IS THE SAME CONFIG THE CONSENT PATH WOULD HAVE PICKED, which is the other half. + * Two configs from a lost enable race, the first of them disabled, and `ours[0]` against + * `find(ENABLED)` attach one app's accounts to two different configs depending on which door + * a person came through — after which removing "the" config drops half of them. + */ + const config = ours.find((held) => held.status === "ENABLED"); + if (!config) { + /* + * "DISABLED" IS ONLY THIS DEPLOYMENT'S CLAIM TO MAKE WHEN COMPOSIO MADE IT, for the reason + * spelled out at length in {@link ComposioBroker.authorize}: the test above is + * `=== "ENABLED"`, so a config with no status and a config wearing a word this deployment's + * `@composio/core` has never heard of both fall through here, and telling an operator those + * are disabled sends them to enable something that may already be enabled. + */ + const unsettled = ours.filter((held) => held.status !== "DISABLED"); + if (unsettled.length > 0) { + const words = [ + ...new Set(unsettled.map((held) => named(held.status))), + ]; + const shown = words.slice(0, STATUSES_NAMED); + const said = + words.length > shown.length + ? `${shown.join(", ")} and ${words.length - shown.length} other words` + : shown.join(", "); + throw new BrokerRefusalError( + `Composio describes ${unsettled.length} of this deployment's ${ours.length} authorization configs for ${toolkit} as ${said}, which ${words.length === 1 ? "is" : "are"} neither ENABLED nor DISABLED, so whether an account connected against one could work is not something this deployment can tell. Nothing was sent, and what was typed into the form did not leave this deployment. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `Every authorization config this deployment holds at Composio for ${toolkit} is disabled, so an account connected against one could not work and nothing was sent. An administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + let created: { id?: unknown }; try { created = await vendor.connectedAccounts.create({ @@ -3551,9 +3647,29 @@ export function buildComposioClient( * NOTHING IS ANSWERED AND NOTHING IS SWALLOWED. There is no count to report — the id names one * account that existed moments ago — so a failure is a failure, and it leaves through * {@link askVendor} like every other vendor call in this file. + * + * AND "I DID NOT DELETE IT" IS ONE OF THOSE FAILURES, WHICH THE AWAIT USED TO DISCARD. The + * delete answers `{ success?: unknown }` for the reason {@link ComposioVendor}'s declaration + * gives: a 200 carrying `success: false` is Composio saying it did NOT delete the account, so + * no revocation was started and nothing was asked of the provider — the same lie + * {@link withdrawalDeclined} exists to stop one method away. It matters MORE here than there, + * because of what this call's caller does with a clean return. The verification step withdraws + * the account behind a key that failed, and where the withdrawal ITSELF fails it writes the row + * unverified so the account stays reachable and disconnectable. A `success: false` resolving + * normally takes the other branch: no row is written, and what is left standing is a live + * account holding a working-or-not credential that nothing on any screen names and nobody can + * press disconnect on. + * + * ITS OWN SENTENCES RATHER THAN {@link withdrawalDeclined}'s, because that function's two are + * written around a toolkit and around pressing disconnect again, and this method has neither: it + * was handed an id, the app is the caller's to name, and the second press it would invite is a + * button that is not on any page for an account no row points at. What carries across unchanged + * is the null/undefined exemption — a 204 or a body of content-length zero is the vendor saying + * it DID delete and having nothing to add, and reading those as a refusal is the same lie + * pointing the other way. */ async revokeAccount(accountId): Promise { - await askVendor( + const answer = await askVendor( { outcome: "the one account this call was handed was not withdrawn and may be standing at Composio", @@ -3568,6 +3684,18 @@ export function buildComposioClient( revoke_on_delete: true, }), ); + + if (answer === null || answer === undefined) return; + const verdict = answer.success; + if (verdict === true) return; + if (verdict === false) { + throw new BrokerRefusalError( + `Composio answered the withdrawal of the account this connection just made with success: false, so it did not delete the account and started no revocation of the credential behind it. That account is still standing at Composio and the credential behind it is still live there.`, + ); + } + throw new BrokerRefusalError( + `Composio sent ${sent(verdict)} where its verdict on the withdrawal of the account this connection just made belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing and the credential behind it as not withdrawn. ${VENDOR_SHAPE_REMEDY}`, + ); }, }; diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index fca2be5bd..741c938ae 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -5172,6 +5172,98 @@ describe("the fields an app asks a person to fill in", () => { expect(refusal.message).toMatch(/cannot be filled in here/); }); + /** + * "NO SUCH MODE" AND "THIS MODE ASKS FOR NOTHING" ARE TWO ANSWERS, AND THEY USED TO BE ONE `[]`. + * + * The scheme handed in is the RECORDED one and is never re-derived — that is the entire point of + * the column — so a mode Composio has stopped publishing for this app is exactly the drift + * recording it anticipates. As an empty form it is invisible: the person presses submit, a + * connection is created carrying no credential at all, Composio answers `ACTIVE` because it does + * not grade what it is given, and the later probe is the first thing that notices. From their end + * it is a box they cannot fill in. + */ + test("a mode this app no longer publishes is refused rather than drawn as an empty form", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + // The app publishes OAuth2 today; the row here was enabled as API_KEY and still says so. + retrieve: async () => ({ + auth_config_details: [ + { + mode: "OAUTH2", + fields: { + connected_account_initiation: { required: [], optional: [] }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "linear", authScheme: "API_KEY" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + // The app and the RECORDED scheme, because those two are the whole of the finding — and the one + // act that rewrites the recorded scheme, which is an administrator's rather than this person's. + expect(refusal.message).toMatch(/linear/); + expect(refusal.message).toMatch(/API_KEY/); + expect(refusal.message).toMatch(/Plugins page/); + }); + + /** + * AND THE NAME IS THE ONE FIELD THAT TRAVELS, WHICH IS WHY IT IS GUARDED LIKE THE TYPE. + * + * `label`, `help` and `default` all pass through `textOf` and are read by a person. The name is + * sent back to Composio verbatim and is the key `connectWithFields` spreads into the connection's + * `val`. Coerced with `String(...)`, this row drew a box literally called "undefined" and then + * submitted whatever was typed into it under that key: a value no app reads, inside a connection + * Composio accepts. + */ + test("a field with no name is refused rather than drawn as a box called undefined", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + toolkits: { + retrieve: async () => ({ + auth_config_details: [ + { + mode: "API_KEY", + fields: { + connected_account_initiation: { + required: [ + { + displayName: "API Key", + description: "", + type: "string", + required: true, + is_secret: true, + user_visible: true, + }, + ], + optional: [], + }, + }, + }, + ], + }), + }, + }), + ); + + const refusal = await failureOf( + broker.connectionFields({ toolkit: "nameless", authScheme: "API_KEY" }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/cannot be filled in here/); + // And never the coercion itself, which is what the form used to be handed. + expect(refusal.message).not.toMatch(/"undefined"/); + }); + test("a field Composio marks invisible is not shown", async () => { const { broker } = buildComposioClient( fakeVendor({ @@ -5493,6 +5585,106 @@ describe("connecting one person with the secret they typed", () => { expect(refusal.message).toMatch(NO_CONFIG_REMEDY); expect(refusal.message).not.toContain(TYPED_SECRET); }); + + /** + * THE TWO BELOW ASSERT AN ORDER RATHER THAN A SENTENCE, AND THE ORDER IS THE WHOLE STANCE. + * + * A create that ANSWERS is what makes them able to fail. Left at {@link fakeVendor}'s refusal, an + * implementation that sent the key would throw the double's own error, {@link failureOf} would + * hand back a refusal, and a test asking only "did this refuse" would be green over the exact + * defect — the secret having travelled. So the double here succeeds like the real vendor does on + * a key it has not graded, and what is asserted is that it was never called: `created` empty is + * the statement that the person's credential did not leave this process. + */ + test("a config of ours that is disabled is refused before the key is sent", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + /* + * `configsFor` LISTS WITH `showDisabled: true`, so this row is one this read can meet and + * `ours[0]` cannot tell it from a working config. Composio does not grade a submitted key, + * so a create against it is a request that carries the secret out of this process and comes + * back with an account that cannot work. + */ + authConfigs: { + list: async () => ({ + items: [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ], + }), + }, + connectedAccounts: { + create: async (body: unknown) => { + created.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(DISABLED_REMEDY); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toContain(TYPED_SECRET); + // The point of the test: the refusal arrived before the credential did, not after Composio had + // been handed it and answered. + expect(created).toEqual([]); + }); + + test("a listing this deployment cannot read does not send an administrator round a loop", async () => { + const created: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + authConfigs: { + list: async () => ({ items: [{ id: "ac_nameless" }] }), + }, + connectedAccounts: { + create: async (body: unknown) => { + created.push(body); + return { id: "ca_new", status: "ACTIVE" }; + }, + }, + }), + ); + + const refusal = await failureOf( + broker.connectWithFields({ + userId: "user_1", + toolkit: "linear", + authScheme: "API_KEY", + values: { generic_api_key: TYPED_SECRET }, + }), + ); + + /* + * NOT THE NO-CONFIG REMEDY, which is the finding. The unreadable row may itself BE ours, in + * which case removing the app meets `deleteAuthConfig`'s refusal over the same row and adding + * it again meets `ensureAuthConfig`'s — an administrator sent round a loop that cannot close. + * `authorize` tells the two states apart against this same listing; this path handed out the + * wrong one of the two. + */ + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).not.toMatch(NO_CONFIG_REMEDY); + expect(refusal.message).not.toMatch(DISABLED_REMEDY); + expect(refusal.message).toMatch( + /upgrading this deployment's @composio\/core/, + ); + expect(refusal.message).not.toContain(TYPED_SECRET); + // And no `cause`, because this method's refusals carry none — see the describe above. + expect(refusal.cause).toBeUndefined(); + expect(created).toEqual([]); + }); }); /** @@ -5549,4 +5741,108 @@ describe("taking back the one account a verification just made", () => { expect(deleted).toEqual([["ca_new", { revoke_on_delete: true }]]); expect(listed).toEqual([]); }); + + /** + * AND WHAT COMPOSIO ANSWERED IS READ, BECAUSE THE CALLER BRANCHES ON THIS CALL RETURNING CLEANLY. + * + * The await used to drop the reply, so a 200 carrying `success: false` — Composio saying it did + * NOT delete the account and started no revocation — resolved like a withdrawal that happened. + * The verification step above this reads exactly that: a key that fails verification has its + * account withdrawn, and where the WITHDRAWAL fails the row is written unverified so the account + * stays named on a screen and disconnectable. A `success: false` resolving normally takes the + * other branch, writes no row, and leaves a live account holding a credential that nothing in + * this deployment names and nobody can press disconnect on. + * + * THE SENTENCES ARE THIS METHOD'S OWN, which is why they are asserted rather than assumed from + * {@link withdrawalDeclined}. That function's two are written around a toolkit and around + * pressing disconnect again; this call was handed an id, and the second press it would invite is + * a button on no page. + */ + test("the one account's delete answered `success: false` is not a withdrawal", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + // A 200 whose body says the account was not deleted: the flag went out, Composio read the + // request and answered it. This is the vendor declining rather than failing. + delete: async () => ({ success: false }), + }, + }), + ); + + const refusal = await failureOf(broker.revokeAccount("ca_new")); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + expect(refusal.message).toMatch(/success: false/); + // The two facts the caller has to be able to act on: the account stands, and the credential + // behind it was never withdrawn. + expect(refusal.message).toMatch(/still standing at Composio/); + expect(refusal.message).not.toMatch( + /upgrading this deployment's @composio\/core/, + ); + }); + + test("the one account's delete with no verdict in it is not counted as one either", async () => { + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + /* + * A DOCUMENT THAT ARRIVED WITHOUT ITS VERDICT, which is not the same thing as no document + * at all. `success` is required in the declaration and absent on this wire, and the + * generated client parses the body and hands it over — so the schema's "required" is a + * promise about what Composio means to send rather than a fact about what came. + */ + delete: async () => ({}), + }, + }), + ); + + const refusal = await failureOf(broker.revokeAccount("ca_new")); + + expect(refusal).toBeInstanceOf(BrokerRefusalError); + expect(refusal.message).not.toMatch(A_CRASH); + /* + * A DIFFERENT SENTENCE FROM THE ONE ABOVE. "Composio said no" is a fact about this account; + * "Composio answered something where its verdict belongs" is a fact about the package, which + * nobody holding an admin page can correct — so it carries the remedy that names the upgrade + * and the other one must not. + */ + expect(refusal.message).toMatch( + /upgrading this deployment's @composio\/core/, + ); + expect(refusal.message).not.toMatch(/success: false/); + }); + + /** + * AND NO DOCUMENT AT ALL IS COMPOSIO SAYING IT DID DELETE, which is the opposite mistake and the + * one {@link withdrawalDeclined} was corrected for once already. The installed client resolves a + * 204 to `null` and a JSON reply carrying `content-length: 0` to `undefined`, and neither can be + * a rejection: every `!response.ok` is thrown as an `APIError` before parsing, so an answer + * arriving here at all is Composio having accepted the request. Reading those as "no verdict" + * would turn a completed withdrawal into a failure — which, on this path, has the caller write + * the account's row as unverified over an account that is already gone. + */ + for (const { shape, answer } of [ + { shape: "a 204 carrying no content", answer: null }, + { shape: "a JSON reply of content-length zero", answer: undefined }, + ]) { + test(`the one account's withdrawal answered with ${shape} is a withdrawal`, async () => { + const deleted: unknown[] = []; + const { broker } = buildComposioClient( + fakeVendor({ + connectedAccounts: { + delete: async (...call: unknown[]) => { + deleted.push(call); + return answer; + }, + }, + }), + ); + + expect(await broker.revokeAccount("ca_new")).toBeUndefined(); + // And the flag still went out, so what resolved is a delete that asked for the grant behind + // the account to be withdrawn rather than one that quietly filed the account away. + expect(deleted).toEqual([["ca_new", { revoke_on_delete: true }]]); + }); + } }); From c40666a606a55f5e9a15e51ecc67ce469c8b6264 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:50:18 +0200 Subject: [PATCH 187/215] Draw what the app asked for from what the app published, and give the suite a DOM early enough to see it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 180 ++++++++++++------ .../components/plugins/connection-fields.tsx | 94 +++++++++ app/tests/brokered-account-row.test.tsx | 65 ++++++- app/tests/preload.ts | 28 +++ bunfig.toml | 8 +- 5 files changed, 311 insertions(+), 64 deletions(-) create mode 100644 app/src/components/plugins/connection-fields.tsx create mode 100644 app/tests/preload.ts diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 11407d661..ba58cf421 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -1,7 +1,15 @@ import { IconArrowUpRight } from "@tabler/icons-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { ConnectionFields } from "@/components/plugins/connection-fields"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Item, ItemActions, @@ -275,8 +283,9 @@ export function useBrokeredAccount(input: { /** * The row itself, for a `PageRows` card on either screen. * - * Just the `Item`: where it sits in the card, and whether a `Separator` precedes it, is the screen's - * business and differs between the two. + * The `Item` and, for an app whose secret a person types, the dialog that takes it. Where the row + * sits in the card, and whether a `Separator` precedes it, is the screen's business and differs + * between the two; the dialog is portalled to the body and so sits nowhere at all. */ export function BrokeredAccountRow({ account, @@ -289,65 +298,124 @@ export function BrokeredAccountRow({ /** What connecting would do, in the voice of whoever is reading. */ disconnectedDescription: string; }) { + /* + * Whether the form is on screen, which is the whole of what this row holds. + * + * An app nobody consents to is connected by typing a key rather than by leaving for a consent + * screen, and the layout's answer to more than one value is a dialog rather than fields wedged + * into the row. What goes in those fields is asked for when this opens and is held by the form + * itself — see `connection-fields.tsx` — so closing this forgets it. + */ + const [asking, setAsking] = useState(false); + + /* + * A connection that landed takes its own form off the screen. + * + * The row behind redraws as connected on the refetch either way; without this the person is left + * reading the form they just submitted, over a row that says it worked. + */ + useEffect(() => { + if (account.connected) setAsking(false); + }, [account.connected]); + return ( - - - {/* Not "Connect your account": the row is also the connected state, and a title has to read - for both. */} - Your account - {/* Unclamped where the key is missing: that sentence is the only place the setting is named, - so it is the point rather than a hint. */} - - {account.configured - ? account.connected - ? connectedDescription - : disconnectedDescription - : "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."} - - - - {!account.configured ? ( - /* - * A value and nothing to press, which is the layout's read-only row: the deployment has - * no key, so Connect could only fail at the broker and Disconnect could only fail at it - * twice. A button that cannot work is worse than no button — it invites the second press - * that files a record of an act that did not happen. - */ - Key missing - ) : account.connected ? ( - <> - {/* Decorative: the word beside it already says which. */} - + + + + + + {/* The title the row above cannot have: this exists only while the account is not + connected, so it is free to name the act rather than the subject. */} + Connect your account + + + {account.fields ? ( + + ) : ( +

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

+ )} +
+
+
+ ); } diff --git a/app/src/components/plugins/connection-fields.tsx b/app/src/components/plugins/connection-fields.tsx new file mode 100644 index 000000000..dd1e6c3db --- /dev/null +++ b/app/src/components/plugins/connection-fields.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import type { BrokerField } from "@/lib/plugins/mutations"; + +/** + * What one app asks for, drawn from what that app published. + * + * NOTHING HERE IS PER-APP KNOWLEDGE. The label, the help sentence, the masking and the pre-filled + * default all come off the field, which came off Composio. An app this deployment has never heard of + * draws correctly for the same reason Gmail does: Perplexity publishes one secret `generic_api_key` + * whose help sentence says to look for a value starting with `pplx-`; Shopify publishes a plain + * subdomain beside a secret admin token; Firecrawl publishes a base URL carrying a default most + * people keep. All three are this list, with different rows in it. + * + * SO THERE IS NOTHING TO SWITCH ON. Across forty sampled apps every required field is a plain string + * and the most any app asks for is three, and the server refuses anything that is not a string + * before it reaches here — so this is a list of text inputs and deliberately nothing more. A field + * type to branch on would be a second vocabulary to keep level with the vendor's, invented for a + * shape nobody publishes. + * + * THE VALUES ARE HELD IN THIS COMPONENT AND NOWHERE ELSE: no query cache, no router state, no local + * storage. They are somebody's own key. They go to the mutation on submit and are gone when the + * dialog closes, because keeping a credential we were only ever asked to forward is the one thing + * this form must not do. + */ +export function ConnectionFields({ + fields, + onSubmit, + busy, +}: { + fields: BrokerField[]; + onSubmit: (values: Record) => void; + /** Whether the submission is already in flight, so the button cannot start a second one. */ + busy: boolean; +}) { + /* Seeded from the defaults the app published, so a field most people keep is already filled in. */ + const [values, setValues] = useState>(() => + Object.fromEntries( + fields.map((field) => [field.name, field.default ?? ""]), + ), + ); + + return ( +
{ + event.preventDefault(); + onSubmit(values); + }} + > + {fields.map((field) => ( + /* `muted` rather than a card: `--card` and `--popover` are the same colour, so a + card-coloured row inside a dialog is no row at all. */ + + + + + + {/* Unclamped: the app's own instructions are the point of the row, not a hint under it. */} + {field.help ? ( + + {field.help} + + ) : null} + + setValues((held) => ({ + ...held, + [field.name]: event.target.value, + })) + } + required={field.required} + /* The app said which value is the secret; nothing here guesses from its name. */ + type={field.secret ? "password" : "text"} + value={values[field.name] ?? ""} + /> + + + ))} + +
+ ); +} diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index b76c65060..867230ea6 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -18,6 +18,7 @@ import { } from "@tanstack/react-router"; import { cleanup, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { BrokerField } from "@/lib/plugins/mutations"; import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; import { Route as AdminAppRoute } from "@/routes/_authed/admin/plugins/$key"; import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; @@ -61,8 +62,9 @@ afterAll(() => GlobalRegistrator.unregister()); const APP_KEY = "gmail"; /** A minimal but complete brokered `PluginServer` — the row shape only Composio produces. */ -function brokeredServer(): PluginServer { +function brokeredServer(authScheme: string): PluginServer { return { + authScheme, id: APP_KEY, title: "Gmail", vendor: "Google", @@ -80,10 +82,13 @@ function brokeredServer(): PluginServer { }; } -function pluginsPage(composioConfigured: boolean): PluginsPage { +function pluginsPage( + composioConfigured: boolean, + authScheme: string, +): PluginsPage { return { catalogue: [], - servers: [brokeredServer()], + servers: [brokeredServer(authScheme)], skills: [], botsMayCallBack: true, redirectUri: null, @@ -102,6 +107,13 @@ type Deployment = { composioConfigured: boolean; recorded: boolean; confirms: boolean; + /** + * How this app's authorization config was created, as the vendor's own scheme literal. Defaults + * to the consent scheme, which is what every test written before there was a second kind meant. + */ + authScheme?: string; + /** What the app publishes as the things a person types in, for the `API_KEY` schemes. */ + fields?: BrokerField[]; }; type Server = { @@ -118,7 +130,7 @@ type Server = { * really take effect somewhere for a later read to be able to disagree with it. */ function installDeployment(deployment: Deployment): Server { - const state = { ...deployment }; + const state = { authScheme: "OAUTH2", fields: [], ...deployment }; const server: Server = { deletes: 0 }; global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -156,6 +168,14 @@ function installDeployment(deployment: Deployment): Server { } return json({ connected: state.confirms }); } + /* + * The first press on an app nobody consents to: what does it want typed in? The consent half of + * this same route carries `?returnTo=`, so the two are told apart by the query rather than by + * the method they share. + */ + if (path.endsWith("/connect") && method === "POST") { + return json({ fields: state.fields }); + } if (path.endsWith("/connection") && method === "DELETE") { server.deletes += 1; state.recorded = false; @@ -164,7 +184,7 @@ function installDeployment(deployment: Deployment): Server { } if (path.startsWith("/api/agents")) return json({ agents: [] }); if (path.startsWith("/api/plugins")) { - return json(pluginsPage(state.composioConfigured)); + return json(pluginsPage(state.composioConfigured, state.authScheme)); } return new Response(null, { status: 404 }); }) as typeof fetch; @@ -423,3 +443,38 @@ test("the admin page says the key is missing too, rather than offering an action // still true, rather than reading as a connector that has gone away. expect(view.queryByText("How this is reached")).toBeTruthy(); }); + +/** + * One real field, as Composio publishes it for Perplexity. + * + * Kept verbatim rather than trimmed to a label: the help sentence is the app's own, and the point of + * the test below is that this deployment reproduces a sentence it has never been taught. + */ +const PERPLEXITY_KEY: BrokerField = { + name: "generic_api_key", + label: "API Key", + help: "Your secret Perplexity API key, starting with 'pplx-'. Create one at console.perplexity.ai under API Keys — it's shown only once, so copy it immediately.", + required: true, + secret: true, +}; + +test("a key app asks for what the app asked for, with its own help text", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAccountScreen(queryClient()); + + await userEvent.click(await view.findByRole("button", { name: "Connect" })); + + // Labelled by what the app called it, which is how a person finds the box the vendor's own + // instructions are about. + const input = await view.findByLabelText("API Key"); + // The app said which value is the secret. Nothing here guessed it from the name. + expect(input.getAttribute("type")).toBe("password"); + expect(view.queryByText(/starting with 'pplx-'/)).toBeTruthy(); +}); diff --git a/app/tests/preload.ts b/app/tests/preload.ts new file mode 100644 index 000000000..4825c03b7 --- /dev/null +++ b/app/tests/preload.ts @@ -0,0 +1,28 @@ +/** + * Loaded before any test file, so a component that portals can be rendered at all. + * + * Base UI decides ONCE, while its module is first evaluated, whether its isomorphic layout effect + * is `useLayoutEffect` or a no-op: + * + * export const useIsoLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : noop; + * + * Every DOM test here registers happy-dom in its own `beforeAll`, which is far too late: bun walks + * all the test files into one process, so whichever of them imports a route first pulls Base UI in + * while `document` is still undefined, and the no-op is what every later file gets. A portal needs + * that effect to resolve its container — so `Dialog` mounts nothing, forever, and a test that opens + * one is left asserting against an empty `` with no error to explain it. + * + * Worse, it decided that by import order. The same test passed on its own and failed in the suite, + * which is the failure `test-preload.ts` exists to stop in the other direction. + * + * So the DOM is registered here, that one module is evaluated against it, and the DOM is taken away + * again. Nothing else is left holding browser globals: server and worker tests run exactly as + * before, and each DOM test file still registers and unregisters its own happy-dom. + */ + +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); +/* `@base-ui/utils` is not a dependency of this package; the react package that owns it is. */ +await import("@base-ui/react/dialog"); +GlobalRegistrator.unregister(); diff --git a/bunfig.toml b/bunfig.toml index b599e7d8d..749ce9cfd 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,6 @@ [test] -# See server/scripts/test-preload.ts: makes the module graph deterministic so a test file cannot fail to -# import depending on the order the suite happened to be walked. -preload = ["./server/scripts/test-preload.ts"] +# Both of these make the module graph deterministic, so a test file cannot pass or fail depending on +# the order the suite happened to be walked. See server/scripts/test-preload.ts, which evaluates an +# ESM-only dependency before anything requires it, and app/tests/preload.ts, which evaluates Base UI +# while a DOM exists so its layout effect is not a no-op for every file that follows. +preload = ["./server/scripts/test-preload.ts", "./app/tests/preload.ts"] From cf3b527ed4a38af2f657dd2126180b558a48c2c9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:51:13 +0200 Subject: [PATCH 188/215] Let an app that needs no account run without one, and keep the gate for every app that has one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 30 ++++++- server/tests/plugin-store.integration.test.ts | 87 ++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e3594e660..5c02a8ecf 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1148,7 +1148,16 @@ export function createPluginStore(options: PluginStoreOptions) { * here at all. */ async function connectionTokenFor( - row: { id: string; title: string; credentialId: string | null }, + row: { + id: string; + title: string; + credentialId: string | null; + /** + * The scheme recorded when the app was enabled, which decides whether a brokered call needs a + * connection row at all. The vendor's own literal, never a {@link BrokerConnection} kind. + */ + authScheme: string | null; + }, entry: CatalogueEntry | null, actorId: string, access: ServerAccess, @@ -1203,6 +1212,25 @@ export function createPluginStore(options: PluginStoreOptions) { ); } + /* + * AN APP THAT NEEDS NO AUTHENTICATION HAS NO ROW TO FIND, AND CANNOT EVER HAVE ONE. + * + * `composio_connections` is the whole of the permission for a brokered call, and every row in + * it means one thing: this person granted this deployment access to their account at this + * app. A `NO_AUTH` app has no account and no consent — Composio refuses even to hold an + * authorization config for one — so nobody presses Connect and nothing could write the row. + * + * THE ALTERNATIVE WAS WRITING ONE ANYWAY, and it is worse than it looks. Offboarding reads + * this table to find what to revoke, the audit trail reads it to say what somebody had, and + * disconnect reads it to know what to end. Rows where no person consented and no account + * exists are indistinguishable, a year on, from rows where somebody did. + * + * The scheme is the one RECORDED when the app was enabled rather than a fresh read of the + * catalogue: a vendor that re-labels an app must not turn a gate off underneath a deployment + * that is already running. + */ + if (row.authScheme === "NO_AUTH") return {}; + /* * Keyed on the app the call will run in, which is the one the url names. * diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 94440f284..2ab2f862b 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -3890,11 +3890,21 @@ async function freshStore(options: { broker?: ComposioBroker } = {}) { * `url` is an option because the id and the url are two fields and nothing holds them equal: a row * called `gmail` at `composio://slack` is the shape that used to pass the connection gate on one * spelling and run against the other. The connected person is still connected to `gmail`. + * + * `authScheme` is the vendor's own scheme literal, as it was recorded when somebody enabled the app, + * and the call gate reads it to decide whether a connection row is required at all. Absent by + * default, which is what every other test here wants: a row that is not `NO_AUTH` is a row the gate + * still asks a connection for. */ async function seedComposioGmail( database: Database, store: PluginStore, - options: { connect?: boolean; version?: string | null; url?: string } = {}, + options: { + connect?: boolean; + version?: string | null; + url?: string; + authScheme?: string; + } = {}, ) { await database.insert(mcpServers).values({ id: "gmail", @@ -3902,6 +3912,7 @@ async function seedComposioGmail( vendor: "Composio", url: options.url ?? "composio://gmail", provenance: "composio", + authScheme: options.authScheme ?? null, }); await database.insert(mcpTools).values({ serverId: "gmail", @@ -4331,6 +4342,80 @@ test("a Composio call whose row id and url name different apps is refused", asyn expect(reached).toEqual([]); }); +/* + * WHETHER A BROKERED CALL NEEDS A CONNECTION ROW AT ALL IS THE APP'S OWN QUESTION, AND THESE TWO + * TESTS ARE ONE PAIR. + * + * The fixtures differ in exactly one field — the scheme recorded on the row when somebody enabled + * the app — and otherwise dial the same app, at the same person, with no connection row anywhere. + * So the opposite outcomes below cannot be caused by anything but the recorded scheme. + * + * SUITE-SCOPED APP, for the reason the test above is suite-scoped: `("unclaimed_", + * "user_asker")` is a pair no deployment can be holding, so "nobody has connected this" is a + * property of the run rather than a hope about the database. Spelled as a real app, a stray + * connection row somebody else wrote would make the first test pass for the wrong reason and the + * second one fail for it. + */ +test("a no-auth app runs without anybody having connected it", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store, { + url: `composio://unclaimed_${suite}`, + authScheme: "NO_AUTH", + connect: false, + }); + + const result = await store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }); + + expect(result.isError).toBe(false); + // Dialled, not merely un-refused. A no-auth app has no account to open, so the deployment's own + // key is the entire credential the call goes out with, and reaching the vendor is what says the + // gate returned rather than threw. + expect(reached).toEqual(["GMAIL_FETCH_EMAILS"]); +}); + +test("an app whose accounts are somebody's still refuses without a row, and names the person's own step", async () => { + const { store, database } = await freshStore(); + const reached: string[] = []; + useComposioClient({ + listActions: async () => [], + execute: async ({ slug }) => { + reached.push(slug); + return vendorAnswered(); + }, + }); + await seedComposioGmail(database, store, { + url: `composio://unclaimed_${suite}`, + authScheme: "OAUTH2", + connect: false, + }); + + await expect( + store.callTool({ + ref: "gmail/GMAIL_FETCH_EMAILS", + args: {}, + botId: "bot_helper", + actorId: "user_asker", + }), + ).rejects.toThrow(/connect it in settings/i); + + // Never dialled. The refusal is local so the person is told their own next step instead of shown + // the broker's error about an account it cannot find, and no call is spent finding that out. + expect(reached).toEqual([]); +}); + test("a Composio call sends the version recorded for that action", async () => { const { store, database } = await freshStore(); const calls: { slug: string; version: string }[] = []; From 0309380f5e4460cc2b1370a35582dfd6f4d4486b Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:52:57 +0200 Subject: [PATCH 189/215] Carry whether the key was ever actually used, and let somebody ask again Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index ba58cf421..a3e652bab 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -24,6 +24,7 @@ import { connectAccountMutationOptions, connectBrokeredWithFieldsMutationOptions, disconnectBrokeredMutationOptions, + recheckBrokeredConnectionMutationOptions, } from "@/lib/plugins/mutations"; /** @@ -74,6 +75,23 @@ function kindOf(authScheme: string | null): BrokeredAccountKind { export type BrokeredAccount = { /** Whether this person's account is live, as the vendor last answered. */ connected: boolean; + /** + * Whether a real call was last made with this account's key and worked. + * + * A different fact from `connected`, and the reason both are here. Composio does not check a + * submitted key: a connection created with an obviously wrong value comes back ACTIVE and stays + * ACTIVE, so `connected` for a key app is only that the vendor accepted the row. This is whether + * anything was ever actually read with it. + */ + verified: boolean; + /** + * When that call was made, as the recorded instant. Null where none ever has been. + * + * Carried beside `verified` rather than derived from it, because a verification is a past tense + * and a screen that says so has to say when: "verified" on its own reads as a present-tense fact + * about a key that may have been revoked at the vendor an hour ago. + */ + verifiedAt: string | null; /** * Whether this deployment has a Composio key at all. * @@ -106,6 +124,9 @@ export type BrokeredAccount = { submitFields: (values: Record) => void; requestingFields: boolean; submittingFields: boolean; + /** Spend one read-only call at the vendor to find out whether the key still works. */ + recheck: () => void; + rechecking: boolean; }; export function useBrokeredAccount(input: { @@ -116,6 +137,10 @@ export function useBrokeredAccount(input: { configured: boolean; /** What this deployment recorded, which is what stands until the vendor has answered anything. */ recorded: boolean; + /** See {@link BrokeredAccount.verified}, as this deployment last wrote it down. */ + verified: boolean; + /** See {@link BrokeredAccount.verifiedAt}, as this deployment last wrote it down. */ + verifiedAt: string | null; /** * How the app's authorization config was CREATED, as the vendor's own scheme literal. * @@ -143,6 +168,8 @@ export function useBrokeredAccount(input: { report, returnTo, serverId, + verified, + verifiedAt, } = input; /* @@ -246,6 +273,20 @@ export function useBrokeredAccount(input: { }, }); + /* + * Find out whether the key still works, when somebody presses for it and at no other time. + * + * Deliberately not a second effect beside the confirm above. Composio never re-checks a key once + * it has taken it, so the only way to learn whether one works is to spend a real read-only call at + * the vendor with it — and a verify-on-render would spend the person's own rate limit there, on + * every mount of every screen that draws this row, to redraw a word that was already written down. + * So the row says when it last checked, and the person decides when to check again. + */ + const recheck = useMutation({ + ...recheckBrokeredConnectionMutationOptions(queryClient), + onError: (thrown: Error) => report(thrown.message), + }); + return { /* * What the vendor last answered, and only our own record until it has answered anything. The @@ -254,6 +295,22 @@ export function useBrokeredAccount(input: { * made at all, leaves the recorded row standing rather than inventing either answer. */ connected: confirmation.data?.connected ?? recorded, + /* + * What the last re-check found, and only our own record until one has been made here — the same + * rule `connected` follows above: the answer wins once there is one. + * + * Both read off the one `recheck.data` rather than each falling back on its own, because + * `verifiedAt` is legitimately null in a fresh answer — a check that came back not verified + * records no time — and a `??` on it would pair that answer with the time of the check before, + * leaving the row saying a key failed as of an hour before it was asked. + */ + verified: recheck.data ? recheck.data.verified : verified, + verifiedAt: recheck.data ? recheck.data.verifiedAt : verifiedAt, + recheck: () => { + report(null); + recheck.mutate(serverId); + }, + rechecking: recheck.isPending, configured, connect: () => { report(null); From 3fbc3a4ddc400a3a6651a654402a8d0c8b558c3f Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:55:00 +0200 Subject: [PATCH 190/215] Hand both screens the verification off the row they already read, and say nothing where there is none Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routes/_authed/admin/plugins/$key.tsx | 16 +++++++++++++++- .../_authed/settings/connected-accounts/$key.tsx | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index b0b1f5155..4779aa1ac 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -147,9 +147,15 @@ function RouteComponent() { */ const connections = useQuery(connectionsQueryOptions()); const { data: agents } = useQuery(agentListQueryOptions()); - const youConnected = (connections.data?.connections ?? []).some( + /* + * The row itself rather than whether there is one, because the brokered row below wants what the + * last re-check found and that is written on this same row. Asking a second time for it would be + * a second answer to a question this read already carried. + */ + const connection = (connections.data?.connections ?? []).find( (row) => row.serverId === key, ); + const youConnected = connection !== undefined; const nameFor = useBotNames(); const [error, setError] = useState(null); @@ -233,6 +239,14 @@ function RouteComponent() { // Back to this page afterwards, not to the personal settings screen. returnTo: "admin", serverId: key, + /* + * Absent where this person has never connected the app, and false rather than asserted: with no + * row there is nothing that could have been checked, which is exactly what the server's own + * columns default to. The pair is optional on the type because that endpoint concatenates two + * reads and only a brokered row carries it. + */ + verified: connection?.verified ?? false, + verifiedAt: connection?.verifiedAt ?? null, }); /** Adding is two writes when a token was typed: the credential, then the record pointing at it. */ diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index adeef0463..946b078c7 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -87,6 +87,14 @@ function RouteComponent() { report: setNotice, returnTo: "settings", serverId: key, + /* + * Off the same row `recorded` is read from, and absent where you have never connected this app: + * with no row there is nothing that could have been checked, which is what the server's own + * columns default to. The pair is optional on the type because that endpoint concatenates two + * reads and only a brokered row carries it. + */ + verified: connection?.verified ?? false, + verifiedAt: connection?.verifiedAt ?? null, }); if (plugins.isPending) { From f100fe38f727f85ae4b14d7aaeb5795834e513b0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 15:58:48 +0200 Subject: [PATCH 191/215] Record a consent connection as verified, through the one writer both connection paths now take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `confirmBrokeredConnection` inserted on the column defaults, so every consent connection made since migration 0030 read `verified: false` beside a null `verified_at` — byte-identical to a key somebody typed in that nobody ever checked — and only the rows the backfill touched told the truth. The upsert moves into `recordBrokeredConnection`, which spells `verified` and `verifiedAt` together and leaves `connected_at` alone, and the confirm calls it with `verified: true`: a consent screen is a verification by construction, since the person authenticated at the vendor and Composio answered that the account is live. One writer, so the verified and unverified paths cannot drift into two row shapes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/db/schema/plugins.ts | 22 +++++-- server/src/plugins/store.ts | 72 ++++++++++++++++++--- server/tests/composio-connections.test.ts | 76 ++++++++++++++++++++--- 3 files changed, 147 insertions(+), 23 deletions(-) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 13704ad39..c91d8471f 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -222,12 +222,22 @@ export const composioConnections = pgTable( * check, and a reader treating every `verified_at` as "this connection answered then" would be * wrong about exactly the rows that were here first. * - * That is a claim about the backfill and not about the column in general. For the meaning to - * hold generally the writer which records a consent connection has to set `verified` itself, - * and today it does not: `confirmBrokeredConnection` inserts on the defaults, so a consent - * connection made since the backfill reads `false` with a null `verified_at` — the same pair a - * key connection that was never checked reads. The extracted connection writer is where that is - * settled. + * AND THE SAME NOW HOLDS OF EVERY CONSENT ROW AND NOT ONLY THE BACKFILLED ONES, because the + * writer that records a connection sets `verified` itself: `recordBrokeredConnection` is the + * one place a row is written, and `confirmBrokeredConnection` calls it with `verified: true` on + * the vendor's yes. So a consent connection made today carries the same true migration 0030 + * wrote and earns it the same way — the vendor answered that the account is attached — and its + * `verified_at` is the moment of that answer: the consent itself on the first confirm, and the + * vendor's yes again on every later one, because a confirm really does go and ask. What it used + * to do was insert on the defaults, and until it stopped, every consent connection made since + * the backfill read `false` beside a null `verified_at` — the very pair a key connection nobody + * has ever checked reads — so nothing could tell the two apart, and the rows the migration + * touched were the only ones in the table saying anything true. + * + * WHAT THE PAIR SEPARATES IS A CHECKED CONNECTION FROM AN UNCHECKED ONE, and never one KIND of + * connection from another. Which kind a row is comes from the app's own `auth_scheme`, which + * the settings page branches on first; this column says only that somebody established the + * account is live, and `verified_at` when that was last done. */ verified: boolean("verified").notNull().default(false), /** When that check last passed, which is what the page reports instead of a present tense. */ diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5c02a8ecf..e5869f5ae 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3950,6 +3950,54 @@ export function createPluginStore(options: PluginStoreOptions) { return { connectedAt: iso(row.connectedAt) ?? "" }; }, + /** + * Write down that this person holds this brokered app, and how well that is known. + * + * ONE WRITER SO THE VERIFIED AND UNVERIFIED PATHS CANNOT DRIFT INTO TWO ROW SHAPES. What says + * how well a connection is known is a SET of fields and not a column: `verified` is meaningless + * without the moment it was earned, and `verified_at` without the flag is a date on a claim + * nobody made. Every path that records a connection therefore comes through here rather than + * spelling that set for itself — {@link confirmBrokeredConnection} with `true` today, and the + * verify path, which is the caller `false` exists for, when a probe against a key connection + * comes back unanswered. Two call sites each writing the set by hand is how one of them comes + * to set the flag and leave the timestamp null, or to move `connected_at` on a confirm that + * healed a row nothing changed; spelled once, a reader asking what shape a connection row takes + * has one answer and every path takes it. + * + * `verified` IS THE CALLER'S CLAIM AND `verifiedAt` FOLLOWS FROM IT, never the other way round. + * True means the caller has evidence as of now — the vendor's own yes at the end of a consent + * screen, or a call that went out and came back — so the timestamp is stamped here rather than + * passed in, and it is the moment of the write because that is the moment the evidence was in + * hand. False takes the timestamp back to null rather than leaving the old one standing: a row + * that has stopped being verified must not keep a date saying when it last was, because the one + * sentence the page builds out of the pair — "last checked 13 Sep" — would then be drawn for a + * connection this deployment is no longer claiming anything about. + * + * `connected_at` IS LEFT ALONE, which is the whole reason this is an upsert with an explicit + * `set` rather than a delete and an insert. The person connected when they connected; a write + * that moved it would make every page load look like a fresh connection on their own settings + * page, and would erase the one date the row holds that nothing else in this deployment knows. + */ + async recordBrokeredConnection(input: { + toolkit: string; + userId: string; + verified: boolean; + }): Promise { + const verifiedAt = input.verified ? new Date() : null; + await database + .insert(composioConnections) + .values({ + toolkit: input.toolkit, + userId: input.userId, + verified: input.verified, + verifiedAt, + }) + .onConflictDoUpdate({ + target: [composioConnections.toolkit, composioConnections.userId], + set: { verified: input.verified, verifiedAt, updatedAt: new Date() }, + }); + }, + /** * Ask Composio whether this person's account is really attached, and write down the answer. * @@ -4030,16 +4078,20 @@ export function createPluginStore(options: PluginStoreOptions) { // apart, and whether a row was already here is the whole of what decides if anybody acted. const existing = await this.brokeredConnection(input); - await database - .insert(composioConnections) - .values({ toolkit: input.toolkit, userId: input.userId }) - .onConflictDoUpdate({ - target: [composioConnections.toolkit, composioConnections.userId], - // `connected_at` is left alone on purpose: the person connected when they connected, and - // a confirm that moved it would make every page load look like a fresh connection on - // their own settings page. - set: { updatedAt: new Date() }, - }); + // VERIFIED, BECAUSE A CONSENT SCREEN IS A VERIFICATION AND NOT A LESSER KIND OF ONE. The + // vendor has just answered that this person's account is attached, which is the same + // question a probe goes and asks; that the evidence arrived through a consent flow rather + // than through a call this deployment made does not make it weaker. Writing on the column + // defaults instead left every consent connection reading `false` with a null `verified_at` — + // the pair a key somebody typed in and nobody ever checked reads — so the settings page could + // not tell the two apart. Written through the single writer above rather than here, so this + // path and the verify path cannot come to write two different row shapes; see + // {@link composioConnections.verified}. + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified: true, + }); if (!existing) { await recordAuditEvent(auditStore, { diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index e94364fc5..a9652cb3d 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -225,13 +225,13 @@ async function connectionsHeld(): Promise { * for its reason. "The removal asked the broker to revoke" is worth little beside "and asked it * nothing else": a removal that also listed the catalogue or began somebody's connection would be * acting on somebody's behalf in a way nothing here has reasoned about, and a stub answering - * plausibly would let that pass unremarked. Nothing in this file lists the catalogue or confirms a - * connection, so those three methods have no caller here and say so. + * plausibly would let that pass unremarked. Nothing in this file lists the catalogue or begins + * somebody's connection, so those two methods have no caller here and say so. * - * `ensureAuthConfig` is the exception, and it is recorded rather than answered silently: the two - * tests at the foot of this file enable an app for real, so it has a caller — and every assertion - * above compares {@link asksMade} whole, so recording it keeps "and asked it nothing else" true of - * the removals as well. + * `ensureAuthConfig` and `isConnected` are the exceptions, and both are recorded rather than + * answered silently: the tests at the foot of this file enable an app for real and confirm a + * connection for real, so each has a caller — and every assertion above compares {@link asksMade} + * whole, so recording them keeps "and asked it nothing else" true of the removals as well. */ const unasked = (what: string) => async (): Promise => { throw new Error(`this suite's path asked the broker to ${what}`); @@ -287,7 +287,19 @@ const broker: ComposioBroker = { }); }, authorize: unasked("begin somebody's connection"), - isConnected: unasked("check somebody's connection"), + isConnected: async (request) => { + asks.push({ + // Named by app AND person for the reason `revoke` is: a confirm is about one person's account + // at one app, and "a connection was checked" names neither. + ask: `isConnected:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + // Constant, and deliberately not a knob like {@link vendorFinds}. The no-answer is the branch + // that DELETES a row, which is somebody else's coverage; what this file asks of the confirm is + // what the yes-answer writes down, so a second polarity here would be a seam with no test + // behind it pretending the other branch were covered. + return true; + }, revoke: async (request) => { asks.push({ // Named by app AND person: "two revokes happened" says nothing about who they were for, and @@ -1160,3 +1172,53 @@ test("re-enabling an app nobody has connected picks up the vendor's change", asy .where(eq(mcpServers.id, enabledId)); expect(row.authScheme).toBe("OAUTH2"); }); + +/** + * CONFIRMING A CONNECTION RECORDS IT VERIFIED, BECAUSE A CONSENT SCREEN IS A VERIFICATION. + * + * CRITERION. After a confirm the vendor answers yes to, the row reads `verified` true and carries a + * `verified_at` no earlier than the moment the confirm was made. + * + * REASON. `verified` is what the settings page dates its sentence from — "connected, last checked + * 13 Sep" rather than a present tense this deployment has not earned — and the pair separates a + * connection whose liveness somebody established from one nobody ever checked. A consent connection + * belongs on the checked side by construction: it exists at all only because the person + * authenticated at the vendor's own screen and Composio then answered that the account is attached, + * which is the same evidence a probe goes and asks for. Writing it on the defaults instead left + * every consent connection made since migration 0030 reading `false` with a null `verified_at` — + * byte-identical to a key somebody typed in and nobody has tested — so the page had to describe the + * two the same way, and the backfilled rows were the only ones in the table telling the truth. + * + * THE TIMESTAMP IS HALF THE CRITERION AND NOT A DETAIL. `verified` true beside a null `verified_at` + * is a claim with no date on it, and the page has nothing to print; the two are written together by + * one writer or the row is a shape no reader here has reasoned about. + */ +test("a confirmed connection is recorded verified, at the moment it was earned", async () => { + await seedApp({ connect: false }); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + expect( + await store.confirmBrokeredConnection({ toolkit, userId: askerId }), + ).toEqual({ connected: true }); + // And the vendor was asked, which is what makes the row a record of Composio's answer rather than + // of a browser arriving back on a page. + expect(asksMade()).toEqual([`isConnected:${toolkit}/${askerId}`]); + + const [row] = await database + .select({ + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, + }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, toolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt).not.toBeNull(); + expect(row.verifiedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); +}); From ec01b51fd77d3798514254bf99287e3493dcb6bc Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:02:41 +0200 Subject: [PATCH 192/215] Choose the one action a key probe may call, on what the vendor said and what it asks for Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 77 ++++++++++++++++ server/tests/composio-connections.test.ts | 102 +++++++++++++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e5869f5ae..022af2c3a 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -869,6 +869,21 @@ type StoredClient = { client: OAuthClient; registeredAt: Date | null }; */ const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000; +/** + * The names that read as "tell me who this key belongs to". + * + * A PREFERENCE AND NOT THE RULE. What makes an action safe to probe with is decided by + * {@link createPluginStore}'s `probeActionFor` on the vendor's own labels; this is only which of the + * safe ones to reach for first. An identity call is the cheapest request an app has and the one + * whose failure most clearly means "this key is wrong" rather than "that record does not exist" — + * but sampling fifteen key-based apps in the live catalogue, only four publish one, so a chooser + * that INSISTED on this shape would refuse to probe most of the apps this deployment offers. + * + * Anchored at the end, because these are suffixes of a prefixed action name — `STRIPE_GET_ME`, + * `LINEAR_GET_ME` — and an unanchored match would take `SLACK_PROFILE_SET` for an identity read. + */ +const IDENTITY_ACTION = /(_GET_ME|_PROFILE|_CURRENT_USER|_USER_INFO|_WHOAMI)$/; + /** What a vendor's token endpoint gave back for a refresh token. */ export type AccessToken = { accessToken: string; @@ -3950,6 +3965,68 @@ export function createPluginStore(options: PluginStoreOptions) { return { connectedAt: iso(row.connectedAt) ?? "" }; }, + /** + * The action a key verification should call against this app, or null where it publishes none. + * + * THIS CHOOSES THE ONE ACTION THAT WILL BE CALLED WITH SOMEBODY'S JUST-TYPED API KEY, which is + * what makes it the most dangerous line in the verification: whatever comes back from here runs + * against a stranger's account, once, purely to find out whether their key works. So the two + * conditions below are BOTH non-negotiable, and neither is a stricter spelling of the other. + * + * READ EFFECT, because a probe must not change anything. The label is the vendor's own and not + * a guess of ours: `effectOf` answers `read` only where Composio sent `readOnlyHint`, and + * everything unlabelled was already recorded as a write, so `read` here means Stripe or Linear + * or Notion said so. `destructive` is checked beside it rather than trusted to be implied — the + * two are separate columns precisely so a vendor can say both things, and a row that somehow + * says read AND destructive is a row this deployment has no business calling unasked. + * + * ZERO REQUIRED INPUTS, because there is nothing to invent an argument from. A probe happens + * before this deployment knows anything about the account beyond the key, so a required customer + * id or query has no honest value to carry, and a made-up one turns "is this key good" into + * "does this identifier exist" — which fails for a perfectly good key. + * + * BOTH, NEVER EITHER, and the live catalogue is why this sentence is here rather than a comment + * saying the checks are belt-and-braces. The first argument-less action on Stripe's own list is + * `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`. A probe chosen on "takes no arguments" alone — + * the condition that looks sufficient, because it is the one that makes a call possible at all — + * would therefore write to somebody's account to find out whether their key works. The read + * effect is the whole of what stands between those two names. + * + * NULL IS AN ANSWER AND NOT A FAILURE. Of fifteen key-based apps sampled, most publish some safe + * argument-less read and PostHog publishes none at all, so an app that cannot be probed is an + * ordinary app rather than a broken one. What a caller does about it — and running the probe at + * all — belongs to the verification path; this function only chooses. + */ + async probeActionFor(serverId: string): Promise { + // Ordered, because the fallback below is "the first candidate" and Postgres promises no order + // without one: an unordered read would make which action gets called with somebody's key a + // property of whichever plan the server happened to pick. + const actions = await database + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + .orderBy(asc(mcpTools.name)); + + const safe = actions.filter((action) => { + if (action.effect !== "read" || action.destructive) return false; + // Absent and empty are the same answer, and anything that is not a list is neither: the + // column is the vendor's JSON Schema stored unchanged, so `required` may be missing, may be + // `[]`, and may be some shape no schema should hold. Only a non-empty list of names is a + // reason to pass this action over. + const schema = action.inputSchema as Record | null; + const required = schema?.required; + return !Array.isArray(required) || required.length === 0; + }); + + const identity = safe.find((action) => IDENTITY_ACTION.test(action.name)); + return identity?.name ?? safe[0]?.name ?? null; + }, + /** * Write down that this person holds this brokered app, and how well that is known. * diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index a9652cb3d..69a266b00 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -107,6 +107,16 @@ const ownedToolkits = [toolkit, secondToolkit, enabledToolkit]; * that a sweep keyed on `user_id` alone would take by mistake. */ const foreignToolkit = `foreign-${suite}`; +/** + * The app the probe chooser reads, which holds actions and nothing else. + * + * Its own `mcp_servers` id rather than {@link toolkit}'s, because every other fixture here seeds + * `APP_FETCH_ITEMS` — an argument-less read — and a chooser asked about that app would answer that + * action whatever it did with the rows a probe test cares about. Named as an app id and not added to + * {@link ownedToolkits}: nobody connects it, so it has no `composio_connections` row to sweep, and + * its actions go out with the server rows in {@link clean}. + */ +const probeAppId = `probe-${suite}`; const admin = "admin@openbot.local"; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -364,10 +374,12 @@ async function clean() { await database.delete(agents).where(eq(agents.id, botId)); await database .delete(mcpTools) - .where(inArray(mcpTools.serverId, [toolkit, renamedId, enabledId])); + .where( + inArray(mcpTools.serverId, [toolkit, renamedId, enabledId, probeAppId]), + ); await database .delete(mcpServers) - .where(inArray(mcpServers.id, [toolkit, renamedId, enabledId])); + .where(inArray(mcpServers.id, [toolkit, renamedId, enabledId, probeAppId])); await database .delete(composioConnections) .where(inArray(composioConnections.toolkit, ownedToolkits)); @@ -1222,3 +1234,89 @@ test("a confirmed connection is recorded verified, at the moment it was earned", expect(row.verifiedAt).not.toBeNull(); expect(row.verifiedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); }); + +/** + * CHOOSING THE PROBE: READ EFFECT AND ZERO REQUIRED INPUTS, AND NEITHER ALONE WILL DO. + * + * CRITERION. Given an app whose alphabetically first argument-less action is a WRITE, the chosen + * probe is the argument-less READ that sorts after it, and never the write. + * + * REASON. The action this picks is the one that will be called with somebody's just-typed API key + * to find out whether the key works, so a wrong pick is an unrequested write on a stranger's + * account. The fixture is Stripe's own list and not an invention: the first action Composio + * publishes for Stripe that requires no arguments is `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`, + * so a chooser written on "takes no arguments" — the condition that looks sufficient, because it is + * the one that makes a call possible at all — would open a billing meter event session on the + * account of every person who typed a key into this deployment. Read effect is what stands between + * those two names, and it is a fact the vendor asserted rather than a guess: `effectOf` answers + * `read` only where Composio sent `readOnlyHint`, so everything unlabelled is already recorded here + * as a write. + */ +test("the probe skips an argument-less write for the read that sorts after it", async () => { + await database.insert(mcpServers).values({ + id: probeAppId, + title: "Stripe", + vendor: "Composio", + url: `composio://${probeAppId}`, + provenance: "composio", + }); + await database.insert(mcpTools).values([ + { + serverId: probeAppId, + // Sorts first, asks for nothing, and charges somebody money. The whole test. + name: "STRIPE_CREATE_BILLING_METER_EVENT_SESSION", + description: "Creates a billing meter event session.", + effect: "write", + version: "20260903_00", + }, + { + serverId: probeAppId, + name: "STRIPE_RETRIEVE_BALANCE", + description: "Retrieves the balance.", + effect: "read", + version: "20260903_00", + }, + ]); + + expect(await store.probeActionFor(probeAppId)).toBe( + "STRIPE_RETRIEVE_BALANCE", + ); +}); + +/** + * AND AN APP WHOSE ONLY SAFE ACTION WANTS AN ARGUMENT HAS NO PROBE AT ALL. + * + * CRITERION. Where every read this deployment recorded for an app declares a required input, the + * answer is null rather than that action. + * + * REASON. There is nothing to invent an argument from. A probe is made before anybody has told this + * deployment anything about the account beyond the key itself, so a required customer id, project + * id or query has no honest value to carry — and a guessed one turns "is this key good" into a + * question about whether some made-up identifier exists, which fails for a perfectly good key. + * NULL IS A REAL ANSWER AND NOT AN ERROR: sampling the key-based apps in the live catalogue, most + * publish some argument-less read and PostHog publishes none, so every caller of this has to have + * an answer for an app that cannot be probed. + */ +test("an app whose only read takes an argument has no probe", async () => { + await database.insert(mcpServers).values({ + id: probeAppId, + title: "Needs An Argument", + vendor: "Composio", + url: `composio://${probeAppId}`, + provenance: "composio", + }); + await database.insert(mcpTools).values({ + serverId: probeAppId, + name: "APP_GET_PROJECT", + description: "Reads one project, by id.", + effect: "read", + inputSchema: { + type: "object", + properties: { project_id: { type: "string" } }, + required: ["project_id"], + }, + version: "20260903_00", + }); + + expect(await store.probeActionFor(probeAppId)).toBeNull(); +}); From 6c14e3620e754d20ed653cac90a5a84597d38816 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:04:58 +0200 Subject: [PATCH 193/215] Say what a connection rests on rather than only that there is one, and forget a check when its account goes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 275 +++++++++++++----- app/tests/brokered-account-row.test.tsx | 242 ++++++++++++++- 2 files changed, 449 insertions(+), 68 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index a3e652bab..89540af42 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -108,6 +108,14 @@ export type BrokeredAccount = { disconnect: () => void; connecting: boolean; disconnecting: boolean; + /** + * Whether a disconnect from this screen landed, as opposed to an account that was never made. + * + * Both read as not connected and they are not the same sentence. For an app whose secret somebody + * typed, what disconnecting did NOT do is the part worth saying — the key is still live at the + * vendor — and there is nobody to say it to until they have actually pressed the button. + */ + disconnected: boolean; /** * What the app wants typed in, once it has been asked. Null until then, and for every app nobody * types anything into. @@ -213,6 +221,36 @@ export function useBrokeredAccount(input: { confirmAccount(serverId); }, [brokered, configured, serverId, confirmAccount]); + /* + * Find out whether the key still works, when somebody presses for it and at no other time. + * + * Deliberately not a second effect beside the confirm above. Composio never re-checks a key once + * it has taken it, so the only way to learn whether one works is to spend a real read-only call at + * the vendor with it — and a verify-on-render would spend the person's own rate limit there, on + * every mount of every screen that draws this row, to redraw a word that was already written down. + * So the row says when it last checked, and the person decides when to check again. + */ + const recheck = useMutation({ + ...recheckBrokeredConnectionMutationOptions(queryClient), + onError: (thrown: Error) => report(thrown.message), + }); + /* + * The check's answer is thrown away whenever an action changes the account it was about. + * + * THE SAME DEFECT `forgetConfirmation` ABOVE EXISTS FOR, in the same shape and for the same + * reason: a mutation's `data` is not query state, so invalidating the queries refetches the + * recorded row and leaves this verdict exactly where it was. Disconnecting would leave the row + * saying a key was "last checked" an hour ago about an account that no longer exists, and + * connecting a fresh key would inherit the old key's verdict — a row reading "last checked" + * about a value nothing has ever tried. + * + * RESET RATHER THAN A GUARD AT THE DRAWING. Hiding it behind `connected` in the render would fix + * the disconnect and not the reconnect, and would leave the hook handing `verified: true` to any + * other reader — the honest thing is for the answer to stop existing when the thing it answered + * about does. + */ + const forgetRecheck = recheck.reset; + const connect = useMutation({ ...connectAccountMutationOptions(returnTo), onError: (thrown: Error) => report(thrown.message), @@ -223,6 +261,7 @@ export function useBrokeredAccount(input: { */ onSuccess: (authorizationUrl) => { forgetConfirmation(); + forgetRecheck(); window.location.href = authorizationUrl; }, }); @@ -238,6 +277,7 @@ export function useBrokeredAccount(input: { onError: (thrown: Error) => report(thrown.message), onSuccess: (...args) => { forgetConfirmation(); + forgetRecheck(); return disconnectOptions.onSuccess?.(...args); }, }); @@ -269,24 +309,11 @@ export function useBrokeredAccount(input: { onError: (thrown: Error) => report(thrown.message), onSuccess: (...args) => { forgetConfirmation(); + forgetRecheck(); return submitOptions.onSuccess?.(...args); }, }); - /* - * Find out whether the key still works, when somebody presses for it and at no other time. - * - * Deliberately not a second effect beside the confirm above. Composio never re-checks a key once - * it has taken it, so the only way to learn whether one works is to spend a real read-only call at - * the vendor with it — and a verify-on-render would spend the person's own rate limit there, on - * every mount of every screen that draws this row, to redraw a word that was already written down. - * So the row says when it last checked, and the person decides when to check again. - */ - const recheck = useMutation({ - ...recheckBrokeredConnectionMutationOptions(queryClient), - onError: (thrown: Error) => report(thrown.message), - }); - return { /* * What the vendor last answered, and only our own record until it has answered anything. The @@ -322,6 +349,12 @@ export function useBrokeredAccount(input: { disconnect.mutate(serverId); }, disconnecting: disconnect.isPending, + /* + * A disconnect this person made, rather than an account that was never there. Held by the + * mutation because that is where the fact is: the recorded row says only that there is nothing, + * which is equally true of an app nobody ever connected. + */ + disconnected: disconnect.isSuccess, kind: kindOf(authScheme), fields: fieldsRequest.data ?? null, requestFields: () => { @@ -337,6 +370,77 @@ export function useBrokeredAccount(input: { }; } +/** + * The day a check was made, in the reader's own locale. + * + * A day rather than "2 hours ago": the point of the sentence is that the check is a past tense that + * keeps receding, and a relative phrase recomputed on every render reads as a fact about now. + */ +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +/** + * The line beneath the word, which is where the three kinds actually differ. + * + * THEY DO NOT DIFFER IN THE WORD. A consent screen and a key somebody typed both end in a live + * account, and "Connected" is true of both; a second word for the second kind would invite a + * distinction there is no fact behind. What differs is what that connection rests on, and how much + * this deployment can honestly claim to know about it — which is a sentence, not a label. + * + * COMPOSIO NEVER RE-CHECKS A SUBMITTED KEY. It answers ACTIVE forever, so a key revoked at the + * vendor last week still reads as connected here. The verification makes exactly one moment true, + * so the row names that moment instead of asserting a present tense it does not hold. + */ +function accountSentence(input: { + account: BrokeredAccount; + /** The app's own name, as the screen drawing this row knows it. */ + title: string; + connectedDescription: string; + disconnectedDescription: string; +}): string { + const { account, connectedDescription, disconnectedDescription, title } = + input; + + if (!account.configured) { + return "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; + } + + if (account.kind === "no-auth") { + /* + * Neither screen's own sentence fits: both are about an account, and there is none to have. + * Capitalised because this is the one position the name opens a sentence in. + */ + return `${title.charAt(0).toUpperCase()}${title.slice(1)} needs no account. A Bot granted these tools can use it as it is.`; + } + + if (account.kind === "fields") { + if (account.connected) { + return account.verified && account.verifiedAt + ? `Connected with a key you provided, last checked ${formatDate(account.verifiedAt)}.` + : `Connected with a key you provided. ${title} publishes nothing this deployment can check it against, so it was accepted without being tried.`; + } + /* + * WHAT DISCONNECTING DID NOT DO. The account ends at Composio and the key does not end + * anywhere: it is still valid at the vendor and still works for anyone holding it. Saying + * "disconnected" and stopping would leave somebody believing they had ended access they still + * have live, so the row names the step this deployment cannot take for them. + */ + return account.disconnected + ? `Removed from Composio. Your key still works at ${title} — rotate it there if you meant to end its access.` + : disconnectedDescription; + } + + /* + * A consent app keeps the screen's own sentence, prefixed by what the connection rests on. What + * differs between an administrator checking their setup and a person checking who reads their + * mail is an argument, not a branch — see this file's opening comment. + */ + return account.connected + ? `Connected through ${title}'s consent screen. ${connectedDescription}` + : disconnectedDescription; +} + /** * The row itself, for a `PageRows` card on either screen. * @@ -348,12 +452,21 @@ export function BrokeredAccountRow({ account, connectedDescription, disconnectedDescription, + title, }: { account: BrokeredAccount; /** What being connected means on this screen, said beside the button rather than after it. */ connectedDescription: string; /** What connecting would do, in the voice of whoever is reading. */ disconnectedDescription: string; + /** + * The app's own name, for the sentences that name it. + * + * Optional, and falling back to a bare noun rather than to the word `undefined`: a screen that + * has not got a title yet — one drawing a row for an app the catalogue has not answered for — + * still reads as a sentence, and the only thing it loses is the vendor's name in it. + */ + title?: string; }) { /* * Whether the form is on screen, which is the whole of what this row holds. @@ -387,66 +500,96 @@ export function BrokeredAccountRow({ - {account.configured - ? account.connected - ? connectedDescription - : disconnectedDescription - : "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."} + {accountSentence({ + account, + connectedDescription, + disconnectedDescription, + title: title ?? "the app", + })} - - {!account.configured ? ( - /* - * A value and nothing to press, which is the layout's read-only row: the deployment has - * no key, so Connect could only fail at the broker and Disconnect could only fail at it - * twice. A button that cannot work is worse than no button — it invites the second press - * that files a record of an act that did not happen. - */ - Key missing - ) : account.connected ? ( - <> - {/* Decorative: the word beside it already says which. */} - + )}
diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 867230ea6..9f2d1b369 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -16,8 +16,12 @@ import { Outlet, RouterProvider, } from "@tanstack/react-router"; -import { cleanup, render, waitFor } from "@testing-library/react"; +import { cleanup, render, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { + type BrokeredAccount, + BrokeredAccountRow, +} from "@/components/plugins/brokered-account-row"; import type { BrokerField } from "@/lib/plugins/mutations"; import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; import { Route as AdminAppRoute } from "@/routes/_authed/admin/plugins/$key"; @@ -61,6 +65,17 @@ afterAll(() => GlobalRegistrator.unregister()); const APP_KEY = "gmail"; +/** When the key was last known to work, as this deployment wrote it down. */ +const CHECKED_AT = "2026-09-10T09:00:00.000Z"; + +/** When a re-check pressed during a test finds out again. A different day, so the two read apart. */ +const RECHECKED_AT = "2026-09-13T09:00:00.000Z"; + +/** The same day, spelled the way the row spells it — the reader's own locale, not this file's. */ +function asDay(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + /** A minimal but complete brokered `PluginServer` — the row shape only Composio produces. */ function brokeredServer(authScheme: string): PluginServer { return { @@ -114,6 +129,12 @@ type Deployment = { authScheme?: string; /** What the app publishes as the things a person types in, for the `API_KEY` schemes. */ fields?: BrokerField[]; + /** Whether a real call was ever made with this key and worked, as this deployment recorded it. */ + verified?: boolean; + /** When that happened. Null wherever `verified` is false — a check that failed records no time. */ + verifiedAt?: string | null; + /** What a re-check answers when somebody presses for one. */ + recheckAnswer?: { verified: boolean; verifiedAt: string | null }; }; type Server = { @@ -130,7 +151,14 @@ type Server = { * really take effect somewhere for a later read to be able to disagree with it. */ function installDeployment(deployment: Deployment): Server { - const state = { authScheme: "OAUTH2", fields: [], ...deployment }; + const state = { + authScheme: "OAUTH2", + fields: [] as BrokerField[], + verified: false, + verifiedAt: null as string | null, + recheckAnswer: { verified: true, verifiedAt: RECHECKED_AT }, + ...deployment, + }; const server: Server = { deletes: 0 }; global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -149,6 +177,8 @@ function installDeployment(deployment: Deployment): Server { serverId: APP_KEY, scope: "", connectedAt: "2026-09-10T00:00:00.000Z", + verified: state.verified, + verifiedAt: state.verifiedAt, }, ] : [], @@ -174,12 +204,33 @@ function installDeployment(deployment: Deployment): Server { * the method they share. */ if (path.endsWith("/connect") && method === "POST") { + /* + * The second press carries the values on it, and is the one that writes. Told apart by the + * body rather than by a second path, because the route itself is one route: the first press + * asks the app what it wants and the second hands it over. + */ + if (typeof init?.body === "string" && init.body.includes("values")) { + state.recorded = true; + state.confirms = true; + // A fresh key, checked against nothing: this app published no probe to spend it on. + state.verified = false; + state.verifiedAt = null; + return json({ connected: true, verified: false }); + } return json({ fields: state.fields }); } + if (path.endsWith("/connection/recheck") && method === "POST") { + state.verified = state.recheckAnswer.verified; + state.verifiedAt = state.recheckAnswer.verifiedAt; + return json(state.recheckAnswer); + } if (path.endsWith("/connection") && method === "DELETE") { server.deletes += 1; state.recorded = false; state.confirms = false; + // The row is gone, and so is everything that was ever checked about it. + state.verified = false; + state.verifiedAt = null; return json({ ok: true }); } if (path.startsWith("/api/agents")) return json({ agents: [] }); @@ -478,3 +529,190 @@ test("a key app asks for what the app asked for, with its own help text", async expect(input.getAttribute("type")).toBe("password"); expect(view.queryByText(/starting with 'pplx-'/)).toBeTruthy(); }); + +/** + * A `BrokeredAccount` standing on its own, for the cases that are about what the row SAYS. + * + * The hook is exercised through the two screens above, which is where its own defects live. These + * cases differ only in the three facts the row branches on — `kind`, `connected`, `verified` — and + * a deployment built for each would be testing the stub rather than the sentence. + */ +function accountState(overrides: Partial): BrokeredAccount { + return { + configured: true, + connect: () => {}, + connected: false, + connecting: false, + disconnect: () => {}, + disconnected: false, + disconnecting: false, + fields: null, + kind: "consent", + recheck: () => {}, + rechecking: false, + requestFields: () => {}, + requestingFields: false, + submitFields: () => {}, + submittingFields: false, + verified: false, + verifiedAt: null, + ...overrides, + }; +} + +/** The row with both screens' arguments filled in, so only the account differs between cases. */ +function renderRow(account: BrokeredAccount) { + return render( + , + ); +} + +test("both kinds say Connected, and the line beneath says how", () => { + const consent = renderRow(accountState({ connected: true, kind: "consent" })); + + expect(consent.getByText("Connected")).toBeTruthy(); + expect(consent.getByText(/through Gmail's consent screen/)).toBeTruthy(); + + cleanup(); + + const key = renderRow( + accountState({ + connected: true, + kind: "fields", + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + // The same word, deliberately: what differs between a consent screen and a key somebody typed is + // not whether the account is live, and a second word for it would invite a distinction there is + // no fact behind. + expect(key.getByText("Connected")).toBeTruthy(); + expect( + key.getByText( + `Connected with a key you provided, last checked ${asDay(CHECKED_AT)}.`, + ), + ).toBeTruthy(); +}); + +test("an app needing no account offers nothing to press", () => { + const view = renderRow(accountState({ kind: "no-auth" })); + + // Not a disabled Connect, and not a Connect that would make an account nobody needs: there is no + // account here to make, so there is no control. + expect(view.queryByRole("button")).toBeNull(); + expect(view.getByText(/Gmail needs no account/)).toBeTruthy(); +}); + +test("Re-check appears only where a check is possible, and asks when pressed", async () => { + let checks = 0; + const checkable = renderRow( + accountState({ + connected: true, + kind: "fields", + recheck: () => { + checks += 1; + }, + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + await userEvent.click(checkable.getByRole("button", { name: "Re-check" })); + expect(checks).toBe(1); + + cleanup(); + + /* + * An app that published nothing to check a key against. It was never verified and re-checking it + * would ask the same unanswerable question again, so the button is not offered — and the sentence + * says why rather than leaving the missing word looking like a failure. + */ + const unverifiable = renderRow( + accountState({ connected: true, kind: "fields" }), + ); + + expect(unverifiable.queryByRole("button", { name: "Re-check" })).toBeNull(); + expect( + unverifiable.getByText( + /publishes nothing this deployment can check it against/, + ), + ).toBeTruthy(); +}); + +test("disconnecting a key names the step this deployment cannot take", () => { + const view = renderRow( + accountState({ connected: false, disconnected: true, kind: "fields" }), + ); + + // The account ends at Composio and the key does not end anywhere. Saying "disconnected" and + // stopping would leave somebody believing they had ended access they still have live. + expect(view.getByText(/Removed from Composio/)).toBeTruthy(); + expect( + view.getByText(/Your key still works at Gmail — rotate it there/), + ).toBeTruthy(); +}); + +test("a key re-checked and then disconnected stops claiming it was checked", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + verified: true, + verifiedAt: CHECKED_AT, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText( + new RegExp(`last checked ${asDay(CHECKED_AT)}`.replace(/\//g, "\\/")), + ), + ).toBeTruthy(); + + await userEvent.click(view.getByRole("button", { name: "Re-check" })); + await waitFor(() => + expect( + view.queryByText( + new RegExp(`last checked ${asDay(RECHECKED_AT)}`.replace(/\//g, "\\/")), + ), + ).toBeTruthy(), + ); + + await userEvent.click(view.getByRole("button", { name: "Disconnect" })); + + await waitFor(() => + expect(view.queryByText(/Removed from Composio/)).toBeTruthy(), + ); + // The account is gone; the answer the re-check gave was about it and must go with it. + expect(view.queryByText(/last checked/)).toBeNull(); + expect(view.queryByRole("button", { name: "Re-check" })).toBeNull(); + + /* + * AND THE ANSWER MUST NOT COME BACK WITH THE NEXT KEY. A mutation's `data` is not query state: + * without it being thrown away, connecting again leaves the re-check's old verdict standing, and + * the row reads "last checked" about a key entered seconds ago that nothing has ever tried. + */ + await userEvent.click(view.getByRole("button", { name: "Connect" })); + const dialog = await view.findByRole("dialog"); + await userEvent.type( + await view.findByLabelText("API Key"), + "pplx-a-fresh-one", + ); + await userEvent.click( + within(dialog).getByRole("button", { name: "Connect" }), + ); + + await waitFor(() => expect(view.queryByText("Connected")).toBeTruthy()); + expect( + view.queryByText(/publishes nothing this deployment can check it against/), + ).toBeTruthy(); + expect(view.queryByText(/last checked/)).toBeNull(); +}); From af643a27b31a0148068de8d74da51a37fb0e58ef Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:08:51 +0200 Subject: [PATCH 194/215] Name the app in the row's own sentences, and say what pressing Connect on a key app will actually ask for Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 24 ++++++--- app/src/routes/_authed/admin/plugins/$key.tsx | 1 + .../settings/connected-accounts/$key.tsx | 1 + app/tests/brokered-account-row.test.tsx | 53 +++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 89540af42..a803d2337 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -426,9 +426,16 @@ function accountSentence(input: { * "disconnected" and stopping would leave somebody believing they had ended access they still * have live, so the row names the step this deployment cannot take for them. */ - return account.disconnected - ? `Removed from Composio. Your key still works at ${title} — rotate it there if you meant to end its access.` - : disconnectedDescription; + if (account.disconnected) { + return `Removed from Composio. Your key still works at ${title} — rotate it there if you meant to end its access.`; + } + /* + * NOT THE SCREEN'S OWN SENTENCE, which is written for the kind that leaves: one of the two says + * connecting takes you to Composio and then to the vendor to consent, and pressing Connect here + * opens a form and asks for a secret instead. A sentence that promises a trip nobody is about to + * take is a worse preparation for the dialog than no sentence at all. + */ + return `This app is connected with a key you already hold, not a trip to ${title}'s consent screen. Connect asks for it.`; } /* @@ -462,11 +469,12 @@ export function BrokeredAccountRow({ /** * The app's own name, for the sentences that name it. * - * Optional, and falling back to a bare noun rather than to the word `undefined`: a screen that - * has not got a title yet — one drawing a row for an app the catalogue has not answered for — - * still reads as a sentence, and the only thing it loses is the vendor's name in it. + * Required, because the sentences it appears in are the ones whose whole job is to name a place: + * where a key still works after a disconnect, whose consent screen a connection rests on, which + * app needs no account at all. "Your key still works at the app" tells somebody nothing they can + * act on, so a screen that cannot name the app has no business drawing this row. */ - title?: string; + title: string; }) { /* * Whether the form is on screen, which is the whole of what this row holds. @@ -504,7 +512,7 @@ export function BrokeredAccountRow({ account, connectedDescription, disconnectedDescription, - title: title ?? "the app", + title, })} diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 4779aa1ac..665b2364c 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -573,6 +573,7 @@ function RouteComponent() { mailbox, and connecting again is a fresh consent. */ connectedDescription={`Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.`} disconnectedDescription="Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only." + title={title} /> ) : null} diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 946b078c7..f18352746 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -137,6 +137,7 @@ function RouteComponent() { and connecting again is a fresh consent. */ connectedDescription={`A Bot granted its tools reads your ${server.title} as you. Disconnecting ends the account at Composio, not just here.`} disconnectedDescription="No Bot can read this as you. Connecting takes you to Composio and then to the vendor to consent." + title={server.title} /> diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 9f2d1b369..a87800ace 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -530,6 +530,59 @@ test("a key app asks for what the app asked for, with its own help text", async expect(view.queryByText(/starting with 'pplx-'/)).toBeTruthy(); }); +test("a key app nobody has connected says it will ask for a key, not send you off", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAccountScreen(queryClient()); + + expect( + await view.findByText( + /connected with a key you already hold, not a trip to Gmail's consent screen/, + ), + ).toBeTruthy(); + /* + * The screen's own not-connected sentence is written for the kind that leaves, and pressing + * Connect on this app opens a form instead. Promising a trip to the vendor here is not a vaguer + * sentence than the truth; it is a different act from the one about to happen. + */ + expect( + view.queryByText(/takes you to Composio and then to the vendor to consent/), + ).toBeNull(); +}); + +test("the row names the app rather than calling it the app", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: true, + fields: [PERPLEXITY_KEY], + recorded: true, + }); + + const view = renderAdminScreen(queryClient()); + + /* + * The point of every sentence that names the vendor is that it names a place somebody has to go: + * the console where a key is rotated, the consent screen a connection rests on. A screen that + * drew this row without handing over the title left them all saying "the app", which names + * nowhere at all. + */ + expect( + await view.findByText( + /Gmail publishes nothing this deployment can check it against/, + ), + ).toBeTruthy(); + expect( + view.queryByText(/the app publishes nothing this deployment can check it/), + ).toBeNull(); +}); + /** * A `BrokeredAccount` standing on its own, for the cases that are about what the row SAYS. * From 409baf1e2086fbbf3e1135ae31bb5a8690292167 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:09:25 +0200 Subject: [PATCH 195/215] Pass a person's own key straight to Composio and keep its names, never its values Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 110 +++++++++++++++++ server/tests/composio-connections.test.ts | 141 ++++++++++++++++++++++ 2 files changed, 251 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 022af2c3a..3eb749910 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -39,8 +39,10 @@ import { } from "./access"; import { type BrokerConnection, + BrokerRefusalError, BrokerUnconfiguredError, type ComposioBroker, + isFieldScheme, } from "./broker"; import { type CatalogueEntry, @@ -4190,6 +4192,114 @@ export function createPluginStore(options: PluginStoreOptions) { return { connected: true }; }, + /** + * Connect this person with the secret they typed, and write down everything except the secret. + * + * THE VALUES TRAVEL IN ONE DIRECTION AND THE WHOLE METHOD IS BUILT AROUND THAT. They arrive on + * the request, they are handed to {@link ComposioBroker.connectWithFields}, and they reach + * Composio. Nothing else here is given them: not the row, not the audit payload, not a log + * line, not a thrown error — the broker's own doc comment is where that promise is kept on the + * far side, and it is the one call in this tree that rethrows with no `cause` precisely because + * the vendor's error object holds the key. Every other participant in this method is a + * long-lived, widely-readable record, so a credential landing in one is not a leak somebody can + * clean up afterwards; it is a leak with a retention schedule. + * + * THE SCHEME IS THE ONE RECORDED ON THE APP'S ROW, never a fresh read of the catalogue and + * never a value a caller passed. It is what this deployment's authorization config was created + * AS, and a connection is attached to that config: a second derivation is a second answer — a + * key sent as `BASIC` against a config made for `API_KEY` — which is the reasoning {@link + * ComposioBroker.connectionFields} gives for taking the scheme rather than resolving it, one + * step earlier in the same flow. + * + * AND AN APP WHOSE SCHEME IS NOT A FIELD SCHEME IS REFUSED BEFORE THE KEY TRAVELS. A consent + * app, a `NO_AUTH` app and an app this deployment could not resolve at all have no form and + * nothing to attach typed values to, so sending them on would spend somebody's credential on a + * config that cannot hold it — and would do it having already taken the secret out of the + * request. {@link isFieldScheme} is asked rather than the string compared, for the reason it + * exists: one list, read by the guard and by the type, so the schemes this admits cannot come + * apart from the schemes the broker's signature takes. + * + * `connected` IS THE LITERAL `true` BECAUSE THERE IS NO OTHER WAY OUT OF HERE. Unlike {@link + * confirmBrokeredConnection}, which asks a question the vendor may answer no to, this performs + * an act: it either made the connection or it threw. A `boolean` would invite a caller to + * branch on a `false` this method cannot produce. + */ + async connectBrokeredWithFields(input: { + toolkit: string; + userId: string; + values: Record; + }): Promise<{ connected: true; verified: boolean }> { + // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to + // connect anybody at, and the refusal must happen before the values are touched at all. + if (!broker) throw new BrokerUnconfiguredError(); + + // Keyed on the url, which is where a brokered row records which app it is; `mcp_servers.id` + // is a display name and nothing holds the two equal. It is the same reasoning the connection + // gate in `connectionTokenFor` is keyed on, and for the sharper version of the same stake: a + // row called `gmail` at `composio://slack` would have somebody's Slack key attached to a + // scheme read off Gmail's row. + const [app] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.url, `composio://${input.toolkit}`)) + .limit(1); + + const authScheme = app?.authScheme ?? null; + if (!isFieldScheme(authScheme)) { + throw new BrokerRefusalError( + `${input.toolkit} is not an app this deployment connects with values somebody types, so nothing was sent. Open the app on the Plugins page and connect it the way it asks for; if it is not listed there at all, an administrator has to enable it first.`, + ); + } + + await broker.connectWithFields({ + userId: input.userId, + toolkit: input.toolkit, + authScheme, + values: input.values, + }); + + /* + * UNVERIFIED FOR NOW, AND "FOR NOW" IS THE WHOLE OF WHAT THIS FALSE MEANS. + * + * It is a PLACEHOLDER and not a settled answer: the next step calls a probe against the key + * that was just attached and writes `Boolean(probe)` here instead. Until that exists, + * `false` is the honest value rather than a pessimistic one — Composio accepted the values + * and nothing has asked the app on the other side whether the credential works, which is + * exactly the state {@link composioConnections.verified} documents as "a key somebody typed + * in that nobody has checked". Written through the single writer rather than spelled here, + * so this path and the confirm path cannot drift into two row shapes. + */ + const verified = false; + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified, + }); + + await recordAuditEvent(auditStore, { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, the same id `confirmBrokeredConnection` and `retireConnectionsFor` file under, + // so one query answers what happened to one person's access to one app however it began + // and however it ended. + targetId: input.toolkit, + payload: { + actor: input.userId, + server: input.toolkit, + reconnected: false, + /* + * THE NAMES AND NEVER THE VALUES. What a reader of the trail needs is which app somebody + * connected and what it asked them for; the values are the credential itself, and an + * audit row is exactly the kind of long-lived, widely-readable record they must never + * reach. + */ + fields: Object.keys(input.values).sort(), + }, + }); + + return { connected: true, verified }; + }, + /** * End this person's brokered account at the vendor, and then forget where it was. * diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 69a266b00..1102dc409 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -10,6 +10,7 @@ import type { import { createDatabase } from "../src/db/client"; import { agents, + auditEvents, composioConnections, mcpServers, mcpTools, @@ -118,6 +119,22 @@ const foreignToolkit = `foreign-${suite}`; */ const probeAppId = `probe-${suite}`; const admin = "admin@openbot.local"; +/** + * THE SECRET A PERSON TYPES, which the test below looks for everywhere it must not be. + * + * A RUN-UNIQUE SPELLING, for the same reason every id here carries one and for one more. The + * assertions about it are ABSENCE assertions read out of two shared tables, and `audit_events` is + * append-only — nothing in this file can sweep it — so a fixed spelling would have one run's rows + * answering another run's question. With the suffix, "this string is nowhere" is a sentence about + * rows this run wrote. + * + * AND IT IS A KEY THE REDACTOR WOULD NOT SAVE, WHICH IS WHAT MAKES THE TEST WORTH RUNNING. + * `redactAuditPayload` masks a value by the NAME of the key holding it, and neither `values` nor + * `generic_api_key` — the name Composio publishes for Perplexity's key — is on its list. So a + * payload that carried what somebody typed would carry it verbatim into the trail, and the absence + * asserted below is the implementation's doing rather than the redactor's. + */ +const typedKey = `pplx-secret-value-${suite}`; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -259,6 +276,17 @@ const unasked = (what: string) => async (): Promise => { */ const asks: { ask: string; held: string[] }[] = []; +/** + * What the vendor was handed to connect somebody with, which is the ONE place it belongs. + * + * Kept beside {@link asks} rather than folded into it, because the two record opposite things. An + * ask is a sentence safe to compare and to print; this holds a person's own credential, and the + * only reason it is held at all is that "the secret is nowhere else" is worth nothing unless + * something also asserts it ARRIVED. A test that only looked for the absence would pass just as + * well against a method that sent Composio nothing. + */ +const valuesSent: Record[] = []; + /** The asks alone, which is what an ordering assertion is about. */ function asksMade(): string[] { return asks.map((entry) => entry.ask); @@ -335,6 +363,22 @@ const broker: ComposioBroker = { held: await connectionsHeld(), }); }, + // Nothing here draws a connect form, so nobody asks what an app wants typed. + connectionFields: unasked("ask what an app wants typed"), + connectWithFields: async (request) => { + asks.push({ + // Named by app AND person, for `isConnected`'s reason: a connection is one person's account + // at one app, and "a connection was made" names neither. The values are deliberately NOT in + // this string — it is compared, printed on failure, and read by whoever is debugging. + ask: `connectWithFields:${request.toolkit}/${request.userId}`, + held: await connectionsHeld(), + }); + valuesSent.push(request.values); + return { accountId: `ca_${suite}` }; + }, + // The verification that undoes its own account is micro-task 10.1's; nothing here makes one to + // take back, so this has no caller yet and says so. + revokeAccount: unasked("take one account back"), }; const store = createPluginStore({ @@ -491,6 +535,7 @@ beforeEach(async () => { events.length = 0; reached.length = 0; asks.length = 0; + valuesSent.length = 0; // The vendor finding an account is the ordinary case — somebody connected, so there is a grant to // withdraw. The one test about the answer itself says otherwise for itself. vendorFinds = () => true; @@ -1320,3 +1365,99 @@ test("an app whose only read takes an argument has no probe", async () => { expect(await store.probeActionFor(probeAppId)).toBeNull(); }); + +/** + * CONNECTING WITH A KEY SOMEBODY TYPED: THE VALUES REACH COMPOSIO AND NOTHING ELSE. + * + * CRITERION. After a connection made from typed values, the secret is in the vendor's hands and in + * no row this deployment wrote — not in the `mcp.account_connected` payload, not in + * `composio_connections` — and what the trail carries instead is the NAMES of the fields that were + * filled in. + * + * REASON. This is the only flow in the product where a person hands this deployment a credential of + * their own, and the whole design of it is that the credential travels in one direction: off the + * request, into `connectWithFields`, out to Composio. Every other participant here is a long-lived, + * widely-readable record. `composio_connections` is read by offboarding, by disconnect and by the + * gate on every brokered call; `audit_events` is append-only by trigger, exported, and kept for as + * long as a deployment's retention window says — so a key that lands in either is not a leak + * somebody can clean up afterwards, it is a leak with a schedule. + * + * WHICH IS WHY THIS ASSERTS ABSENCE OUT OF THE TABLES RATHER THAN OFF THE RETURN VALUE. A method + * can be read for what it puts in a payload; what a reviewer cannot read is what some later writer + * on the same path adds. Stringifying the rows themselves is the assertion that survives that, and + * it is not saved by the redactor: neither `values` nor `generic_api_key` is on `sensitiveKeys`, so + * a payload carrying what somebody typed would carry it through verbatim. See {@link typedKey}. + * + * AND IT ASSERTS THE ARRIVAL TOO. "The secret is nowhere" is true of a method that sends Composio + * nothing at all, so {@link valuesSent} is checked in the same breath: the values went to the one + * place they are for. + * + * `verified: false` IS THE STATE THIS TASK LEAVES AND NOT THE SETTLED ANSWER. The probe that earns + * the flag is its own step; until it exists a key connection is honestly unchecked, which is the + * pair `composio_connections.verified` documents for exactly this row. + */ +test("the values reach Composio and nothing else", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + expect( + await store.connectBrokeredWithFields({ + toolkit: enabledToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: false }); + + // The vendor was asked, and asked with what the person typed. Without this the absences below + // would be satisfied by a method that connected nobody. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${enabledToolkit}/fields`, + `connectWithFields:${enabledToolkit}/${askerId}`, + ]); + expect(valuesSent).toEqual([{ generic_api_key: typedKey }]); + + /* + * THE TRAIL, READ OUT OF THE TABLE RATHER THAN OFF THE RECORDING STORE. The rows are what a + * reader of the trail will actually see — after the redactor, after the insert — and this file's + * `auditStore` keeps a copy of the input beside it, not instead of it. Narrowed to this app + * because `audit_events` is append-only: no cleanup here can reach it, so the other tests in this + * run have already written `mcp.account_connected` rows under {@link toolkit}. + */ + const trail = await database + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_connected"), + eq(auditEvents.targetId, enabledToolkit), + ), + ); + expect(trail).toHaveLength(1); + expect(trail[0].payload).toMatchObject({ + actor: askerId, + server: enabledToolkit, + reconnected: false, + // The names, sorted, because a reader needs to know what the app asked this person for — and + // that is the whole of what a credential may contribute to a record like this one. + fields: ["generic_api_key"], + }); + expect(JSON.stringify(trail)).not.toContain(typedKey); + + const rows = await database + .select() + .from(composioConnections) + .where(inArray(composioConnections.toolkit, ownedToolkits)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + toolkit: enabledToolkit, + userId: askerId, + verified: false, + }); + expect(rows[0].verifiedAt).toBeNull(); + expect(JSON.stringify(rows)).not.toContain(typedKey); +}); From fd401c26be3bce62ee7428ff487fe2bd543f6cd4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:27:05 +0200 Subject: [PATCH 196/215] Ask whether a key connection replaced one, rather than telling the trail it never does Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 15 +++- server/tests/composio-connections.test.ts | 102 +++++++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 3eb749910..d7ec6733a 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4270,6 +4270,19 @@ export function createPluginStore(options: PluginStoreOptions) { * so this path and the confirm path cannot drift into two row shapes. */ const verified = false; + + // Read before the write, for `confirmBrokeredConnection`'s reason: the record below is an + // upsert, so it leaves nothing behind that tells a first key from a replacement, and whether + // a row was already here is the whole of what `reconnected` says. The route that reaches + // this today refuses a second account for the same app, which makes a constant `false` + // accidentally true — but the guard lives in another file and this method is callable + // without it, so the trail would be claiming, on that guard's word, something it never + // checked. + const existing = await this.brokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + }); + await this.recordBrokeredConnection({ toolkit: input.toolkit, userId: input.userId, @@ -4286,7 +4299,7 @@ export function createPluginStore(options: PluginStoreOptions) { payload: { actor: input.userId, server: input.toolkit, - reconnected: false, + reconnected: existing !== null, /* * THE NAMES AND NEVER THE VALUES. What a reader of the trail needs is which app somebody * connected and what it asked them for; the values are the credential itself, and an diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 1102dc409..c1cba5255 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -98,8 +98,19 @@ const secondToolkit = `${toolkit}-more`; const enabledToolkit = `enablable-${suite}`; /** What `addBrokeredApp` spells that app's row, which is the id the two tests below read back. */ const enabledId = `composio-${enabledToolkit}`; +/** + * The app somebody CONNECTS TWICE, which is the only shape that can tell a first key from a second. + * + * Its own name rather than {@link enabledToolkit}'s, because `audit_events` is append-only and no + * cleanup in this file can reach it: the test below that asserts ONE `mcp.account_connected` row + * under that app would be reading this test's rows too, and the two would pass or fail on whichever + * order the runner happened to pick. + */ +const rekeyedToolkit = `rekeyable-${suite}`; +/** What `addBrokeredApp` spells that app's row, so {@link clean} can take it back. */ +const rekeyedId = `composio-${rekeyedToolkit}`; /** Every app this run owns, which is the scope of every read and every delete below. */ -const ownedToolkits = [toolkit, secondToolkit, enabledToolkit]; +const ownedToolkits = [toolkit, secondToolkit, enabledToolkit, rekeyedToolkit]; /** * An app this file does NOT own, standing in for another run's fixture — or another file's. * @@ -135,6 +146,14 @@ const admin = "admin@openbot.local"; * asserted below is the implementation's doing rather than the redactor's. */ const typedKey = `pplx-secret-value-${suite}`; +/** + * THE SECOND KEY, the one somebody types when the first has been rotated or typed wrong. + * + * A different spelling from {@link typedKey} rather than the same value sent twice, because what + * the reconnect test is about is a grant being REPLACED: two sends of one string would be a shape + * an idempotent no-op could also produce. + */ +const rotatedKey = `pplx-rotated-value-${suite}`; const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; @@ -419,11 +438,25 @@ async function clean() { await database .delete(mcpTools) .where( - inArray(mcpTools.serverId, [toolkit, renamedId, enabledId, probeAppId]), + inArray(mcpTools.serverId, [ + toolkit, + renamedId, + enabledId, + rekeyedId, + probeAppId, + ]), ); await database .delete(mcpServers) - .where(inArray(mcpServers.id, [toolkit, renamedId, enabledId, probeAppId])); + .where( + inArray(mcpServers.id, [ + toolkit, + renamedId, + enabledId, + rekeyedId, + probeAppId, + ]), + ); await database .delete(composioConnections) .where(inArray(composioConnections.toolkit, ownedToolkits)); @@ -1461,3 +1494,66 @@ test("the values reach Composio and nothing else", async () => { expect(rows[0].verifiedAt).toBeNull(); expect(JSON.stringify(rows)).not.toContain(typedKey); }); + +/** + * CONNECTING A SECOND TIME: THE TRAIL SAYS A GRANT WAS REPLACED. + * + * CRITERION. Somebody who types a key for an app they had already connected leaves a second + * `mcp.account_connected` row saying `reconnected: true`, while the first one they left says + * `false` — and there is still ONE connection row, because the second key replaced the first. + * + * REASON. `recordBrokeredConnection` is an upsert, so the row it leaves behind is byte-identical + * whether it was the first grant or the fourth; `reconnected` is the only thing in the record that + * tells those apart, and a reader chasing "whose key is on this account" has nothing else to go on. + * A constant `false` there does not merely omit the fact — it asserts the opposite of it, about a + * row that really did replace one. + * + * AND THE ROUTE'S GUARD IS NOT A SUBSTITUTE, which is why this asks the store directly. The one + * caller today refuses a second account for the same app, so in production the constant happened to + * be true; but the guard lives in another file, nothing in this method points at it, and a method + * that is honest only because of a check somewhere else is one refactor away from filing a false + * record. What is asserted here is that the store looks. + */ +test("a second key for the same app is recorded as a reconnection", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: rekeyedToolkit, + title: "Rekeyable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + + await store.connectBrokeredWithFields({ + toolkit: rekeyedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }); + await store.connectBrokeredWithFields({ + toolkit: rekeyedToolkit, + userId: askerId, + values: { generic_api_key: rotatedKey }, + }); + + // Both keys reached the vendor, in order. Without this the trail assertion below would be + // satisfied by a second call that refused before it connected anybody. + expect(valuesSent).toEqual([ + { generic_api_key: typedKey }, + { generic_api_key: rotatedKey }, + ]); + + // ONE ROW FOR TWO CONNECTS, which is the whole of why the flag cannot be inferred later: the + // upsert left nothing behind saying there had been two. + expect(await connectedToolkitsFor(askerId)).toEqual([rekeyedToolkit]); + + // Read in the order the acts happened, which the recorder keeps and `created_at` does not + // promise to: two inserts a millisecond apart are two rows an ordered read may return either way + // round, and the whole assertion is about which of them said what. + const connected = recordedOfType("mcp.account_connected").filter( + (event) => event.targetId === rekeyedToolkit, + ); + expect( + connected.map( + (event) => (event.payload as { reconnected: boolean }).reconnected, + ), + ).toEqual([false, true]); +}); From fcc89b6b34f3b0fa6ff978f93b402326d63b945e Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:30:46 +0200 Subject: [PATCH 197/215] Offer a re-check only on the kind that has one, and put a refused key's reason in the form that asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verified` stopped meaning a probe the moment the confirm began writing it true off a consent screen and migration 0030 backfilled every consent row that came before, so the button gated on it alone was drawn on every connected Gmail and could only fail. It is gated on the kind as well, and the flag's own doc no longer claims a call was spent. The sentence for an unverified key asserted a property of the app — that it publishes nothing checkable — while the store writes `false` on every key connection it makes. It now says only what is true of all three states behind that word, and the reason waits for the verification probe to send one. A key Composio refused said so in the vendor's words, on a banner behind the dialog's own backdrop. The refusal is rendered in the form too, under the button that was pressed. And an administrator gets back the line the row replaced: the half saying setup is complete without them is passed separately and kept by the kind that does not leave. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 113 +++++++++++++--- app/src/routes/_authed/admin/plugins/$key.tsx | 6 + .../settings/connected-accounts/$key.tsx | 4 + app/tests/brokered-account-row.test.tsx | 122 ++++++++++++++++-- 4 files changed, 216 insertions(+), 29 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index a803d2337..17b02b648 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -76,12 +76,22 @@ export type BrokeredAccount = { /** Whether this person's account is live, as the vendor last answered. */ connected: boolean; /** - * Whether a real call was last made with this account's key and worked. + * Whether this deployment holds a verdict that the account works, which is a different fact for + * each kind and is NOT "a real call was made with this key". * * A different fact from `connected`, and the reason both are here. Composio does not check a * submitted key: a connection created with an obviously wrong value comes back ACTIVE and stays - * ACTIVE, so `connected` for a key app is only that the vendor accepted the row. This is whether - * anything was ever actually read with it. + * ACTIVE, so `connected` for a key app is only that the vendor accepted the row. + * + * ON A CONSENT APP THERE IS NO PROBE BEHIND THIS. The confirm writes it true off the vendor's own + * answer that the account is attached — a consent screen somebody completed is the check — and + * migration 0030 backfilled every consent row that came before. So nothing here may read `true` + * as evidence that a call was spent, nor offer an act that needs one. + * + * AND `false` IS A PLACEHOLDER AS MUCH AS A VERDICT. The store writes it unconditionally on every + * key connection, whether or not the app publishes anything to check a key against, so nothing + * here may explain the `false` either. Three states share this one word — never tried, tried and + * failed, nothing to try — and they stay unsaid until the server sends the reason. */ verified: boolean; /** @@ -132,6 +142,15 @@ export type BrokeredAccount = { submitFields: (values: Record) => void; requestingFields: boolean; submittingFields: boolean; + /** + * Why the last attempt to hand over what somebody typed was refused, or null. + * + * Carried out of the hook as well as reported to the screen's banner, because the form is a + * modal. The banner is behind its backdrop, so Composio's own sentence — the one this path spends + * a dropped `cause` to preserve — arrived where the person could not read it, over a form still + * holding the key it was about. + */ + submissionError: string | null; /** Spend one read-only call at the vendor to find out whether the key still works. */ recheck: () => void; rechecking: boolean; @@ -359,6 +378,12 @@ export function useBrokeredAccount(input: { fields: fieldsRequest.data ?? null, requestFields: () => { report(null); + /* + * A fresh attempt, so the last one's refusal goes with it. This press is also what opens the + * form, and a mutation's error outlives the dialog that showed it: without this, reopening + * would present the sentence the previous key was refused with, above an empty field. + */ + submission.reset(); fieldsRequest.mutate(serverId); }, requestingFields: fieldsRequest.isPending, @@ -367,6 +392,7 @@ export function useBrokeredAccount(input: { submission.mutate({ serverId, values }); }, submittingFields: submission.isPending, + submissionError: submission.error?.message ?? null, }; } @@ -398,9 +424,15 @@ function accountSentence(input: { title: string; connectedDescription: string; disconnectedDescription: string; + disconnectedReassurance: string | undefined; }): string { - const { account, connectedDescription, disconnectedDescription, title } = - input; + const { + account, + connectedDescription, + disconnectedDescription, + disconnectedReassurance, + title, + } = input; if (!account.configured) { return "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; @@ -416,9 +448,22 @@ function accountSentence(input: { if (account.kind === "fields") { if (account.connected) { - return account.verified && account.verifiedAt - ? `Connected with a key you provided, last checked ${formatDate(account.verifiedAt)}.` - : `Connected with a key you provided. ${title} publishes nothing this deployment can check it against, so it was accepted without being tried.`; + if (account.verified && account.verifiedAt) { + return `Connected with a key you provided, last checked ${formatDate(account.verifiedAt)}.`; + } + /* + * NO REASON GIVEN, BECAUSE THIS ROW HAS NOT BEEN TOLD ONE. The sentence here used to explain + * the missing check as a fact about the app — that it publishes nothing to try a key on — + * and the store writes `verified: false` on every key connection it makes, publisher or not. + * So the row was asserting that Perplexity publishes nothing checkable while Perplexity + * publishes a perfectly good probe. What is true of all three states behind that one word is + * only that the key was taken and nothing has tried it, so that is the whole of what is said. + * + * THE FOLLOW-UP IS THE VERIFICATION PROBE, not a bigger sentence here: three states need the + * server to say WHICH — never tried, tried and failed, nothing to try — and the reason + * arrives with the probe that lands next. See {@link BrokeredAccount.verified}. + */ + return `Connected with a key you provided. It was accepted without being checked against ${title}.`; } /* * WHAT DISCONNECTING DID NOT DO. The account ends at Composio and the key does not end @@ -434,8 +479,16 @@ function accountSentence(input: { * connecting takes you to Composio and then to the vendor to consent, and pressing Connect here * opens a form and asks for a secret instead. A sentence that promises a trip nobody is about to * take is a worse preparation for the dialog than no sentence at all. + * + * THE SCREEN'S REASSURANCE IS KEPT THOUGH ITS SENTENCE IS NOT. What an administrator needs to + * read here — that finishing the connector does not wait on them connecting — is true whichever + * way this app is connected, and a row that replaced the whole line took it away with the trip + * it was right to drop. */ - return `This app is connected with a key you already hold, not a trip to ${title}'s consent screen. Connect asks for it.`; + const asked = `This app is connected with a key you already hold, not a trip to ${title}'s consent screen. Connect asks for it.`; + return disconnectedReassurance + ? `${asked} ${disconnectedReassurance}` + : asked; } /* @@ -459,6 +512,7 @@ export function BrokeredAccountRow({ account, connectedDescription, disconnectedDescription, + disconnectedReassurance, title, }: { account: BrokeredAccount; @@ -466,6 +520,15 @@ export function BrokeredAccountRow({ connectedDescription: string; /** What connecting would do, in the voice of whoever is reading. */ disconnectedDescription: string; + /** + * What stays true whether or not this person ever connects, in that same voice. + * + * Separate from `disconnectedDescription` because only part of a screen's line survives the kind + * that does not leave: the half describing the trip to a consent screen is wrong for an app whose + * key somebody types, and the half telling an administrator their setup is already complete is + * right for both. A screen with nothing of the second kind to say passes nothing. + */ + disconnectedReassurance?: string; /** * The app's own name, for the sentences that name it. * @@ -512,6 +575,7 @@ export function BrokeredAccountRow({ account, connectedDescription, disconnectedDescription, + disconnectedReassurance, title, })} @@ -544,13 +608,18 @@ export function BrokeredAccountRow({ {/* The same word for both kinds. The line beneath says what it rests on. */} Connected {/* - * OFFERED ONLY WHERE A CHECK IS POSSIBLE, which is exactly where one has already been - * made: `verified` is written by a probe, and a probe exists only where the app - * published a read that takes no arguments to spend on it. An app that published none - * connected unverified and would be asked the same unanswerable question again, so - * the button is not there and the sentence above says why. + * OFFERED ONLY ON A KEY APP, BECAUSE THAT IS THE ONLY KIND A RE-CHECK IS AN ACT ON. + * `verified` on its own is not that question and never was — see + * {@link BrokeredAccount.verified}: a consent connection is written verified by the + * confirm, with no probe behind it, and migration 0030 backfilled every consent row + * that came before. The flag alone therefore drew Re-check on every connected Gmail + * a deployment already had, where pressing it could only fail. + * + * THE FLAG STILL COUNTS BESIDE THE KIND. A key nothing has ever checked has no check + * to repeat, so there is nothing to press and the sentence above says where that + * leaves it — without claiming to know why, which this row is not told. */} - {account.verified ? ( + {account.kind === "fields" && account.verified ? ( diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 665b2364c..5d35d0e4f 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -573,6 +573,12 @@ function RouteComponent() { mailbox, and connecting again is a fresh consent. */ connectedDescription={`Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.`} disconnectedDescription="Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only." + /* The half of the line above that is true whichever way this app connects. An + app whose key somebody types gets a different first sentence from the row — + pressing Connect opens a form rather than leaving for a consent screen — and + an administrator still has to be told they are not the step that finishes the + connector. */ + disconnectedReassurance="Setup is complete without it, and it reaches your documents only." title={title} /> diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index f18352746..9e9c3fa81 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -137,6 +137,10 @@ function RouteComponent() { and connecting again is a fresh consent. */ connectedDescription={`A Bot granted its tools reads your ${server.title} as you. Disconnecting ends the account at Composio, not just here.`} disconnectedDescription="No Bot can read this as you. Connecting takes you to Composio and then to the vendor to consent." + /* What is true either way. The trip to Composio is not — an app whose key somebody + types never leaves this page — but which Bots can read this person's mail is the + whole point of the screen and holds for both kinds. */ + disconnectedReassurance="No Bot can read this as you." title={server.title} /> diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index a87800ace..ded934e35 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -135,6 +135,8 @@ type Deployment = { verifiedAt?: string | null; /** What a re-check answers when somebody presses for one. */ recheckAnswer?: { verified: boolean; verifiedAt: string | null }; + /** What Composio refuses a submitted key with, where this deployment refuses it at all. */ + rejects?: string; }; type Server = { @@ -156,6 +158,7 @@ function installDeployment(deployment: Deployment): Server { fields: [] as BrokerField[], verified: false, verifiedAt: null as string | null, + rejects: undefined as string | undefined, recheckAnswer: { verified: true, verifiedAt: RECHECKED_AT }, ...deployment, }; @@ -210,9 +213,21 @@ function installDeployment(deployment: Deployment): Server { * asks the app what it wants and the second hands it over. */ if (typeof init?.body === "string" && init.body.includes("values")) { + /* + * The vendor's own refusal, carried on the envelope `client` unwraps. It is the sentence + * this whole path exists to preserve, and the only thing that tells somebody their key was + * mistyped rather than their deployment broken. + */ + if (state.rejects) { + return new Response(JSON.stringify({ error: state.rejects }), { + headers: { "content-type": "application/json" }, + status: 400, + }); + } state.recorded = true; state.confirms = true; - // A fresh key, checked against nothing: this app published no probe to spend it on. + // A fresh key, and the store writes every one of those unverified: nothing has been spent + // on it, whatever the app does or does not publish to spend. state.verified = false; state.verifiedAt = null; return json({ connected: true, verified: false }); @@ -556,6 +571,67 @@ test("a key app nobody has connected says it will ask for a key, not send you of ).toBeNull(); }); +test("a key app nobody has connected keeps the screen's own reassurance", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + }); + + const view = renderAdminScreen(queryClient()); + + // The row's own sentence, which is the one the screen's cannot be: pressing Connect here opens a + // form rather than leaving for a consent screen. + expect(await view.findByText(/Connect asks for it\./)).toBeTruthy(); + /* + * And the half of the screen's line that survives it. Replacing the whole line took away the one + * thing an administrator reading this row needs to know — that the connector is finished whether + * or not they ever connect themselves — and left them looking at a step they do not have to take. + */ + expect( + view.getByText( + /Setup is complete without it, and it reaches your documents only/, + ), + ).toBeTruthy(); +}); + +/** Composio's own words for a key it would not take, which is the sentence worth carrying. */ +const REFUSED = "Composio rejected that key: invalid API key for perplexityai."; + +test("a key the broker refuses says so inside the dialog, not only behind it", async () => { + installDeployment({ + authScheme: "API_KEY", + composioConfigured: true, + confirms: false, + fields: [PERPLEXITY_KEY], + recorded: false, + rejects: REFUSED, + }); + + const view = renderAccountScreen(queryClient()); + + await userEvent.click(await view.findByRole("button", { name: "Connect" })); + const dialog = await view.findByRole("dialog"); + await userEvent.type(await view.findByLabelText("API Key"), "pplx-mistyped"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Connect" }), + ); + + /* + * WHERE THE PERSON IS LOOKING. The screen's banner is behind this dialog's backdrop, so a + * refusal that lands only there lands nowhere: the form sits open over it as though nothing had + * been answered, and the one sentence that says "you mistyped it" rather than "we are broken" is + * unreadable until somebody closes the thing they were trying to finish. + */ + await waitFor(() => expect(within(dialog).queryByText(REFUSED)).toBeTruthy()); + // Reported to the screen as well, not instead: the dialog is closable and the reason outlives it. + expect(view.getAllByText(REFUSED).length).toBe(2); + // And the form stays up holding what was typed. A mistyped key is corrected, not retyped. + expect(within(dialog).queryByLabelText("API Key")).toBeTruthy(); +}); + test("the row names the app rather than calling it the app", async () => { installDeployment({ authScheme: "API_KEY", @@ -574,12 +650,10 @@ test("the row names the app rather than calling it the app", async () => { * nowhere at all. */ expect( - await view.findByText( - /Gmail publishes nothing this deployment can check it against/, - ), + await view.findByText(/accepted without being checked against Gmail/), ).toBeTruthy(); expect( - view.queryByText(/the app publishes nothing this deployment can check it/), + view.queryByText(/accepted without being checked against the app/), ).toBeNull(); }); @@ -682,22 +756,42 @@ test("Re-check appears only where a check is possible, and asks when pressed", a cleanup(); /* - * An app that published nothing to check a key against. It was never verified and re-checking it - * would ask the same unanswerable question again, so the button is not offered — and the sentence - * says why rather than leaving the missing word looking like a failure. + * A key nothing has ever checked, which is every key on the day it is typed: the store writes + * `verified: false` on every one of them. There is no check to repeat, so nothing is offered — + * and the sentence does not say why, because this row is not told why. */ - const unverifiable = renderRow( + const unchecked = renderRow( accountState({ connected: true, kind: "fields" }), ); - expect(unverifiable.queryByRole("button", { name: "Re-check" })).toBeNull(); + expect(unchecked.queryByRole("button", { name: "Re-check" })).toBeNull(); expect( - unverifiable.getByText( - /publishes nothing this deployment can check it against/, - ), + unchecked.getByText(/accepted without being checked against Gmail/), ).toBeTruthy(); }); +test("a connected consent app offers no Re-check at all", () => { + /* + * WHAT A CONSENT ROW ACTUALLY LOOKS LIKE, and what every one of them was backfilled to by + * migration 0030: connected and verified, with no probe anywhere behind the flag. Gating the + * button on `verified` alone drew Re-check on all of them, and pressing it reached an endpoint + * this deployment does not serve — a red banner, guaranteed, on the one kind that works today. + */ + const view = renderRow( + accountState({ + connected: true, + kind: "consent", + verified: true, + verifiedAt: CHECKED_AT, + }), + ); + + expect(view.queryByRole("button", { name: "Re-check" })).toBeNull(); + // The row is otherwise itself: a live account somebody can still end. + expect(view.getByRole("button", { name: "Disconnect" })).toBeTruthy(); + expect(view.getByText(/through Gmail's consent screen/)).toBeTruthy(); +}); + test("disconnecting a key names the step this deployment cannot take", () => { const view = renderRow( accountState({ connected: false, disconnected: true, kind: "fields" }), @@ -765,7 +859,7 @@ test("a key re-checked and then disconnected stops claiming it was checked", asy await waitFor(() => expect(view.queryByText("Connected")).toBeTruthy()); expect( - view.queryByText(/publishes nothing this deployment can check it against/), + view.queryByText(/accepted without being checked against Gmail/), ).toBeTruthy(); expect(view.queryByText(/last checked/)).toBeNull(); }); From 7bce641e5d0b9412d360f6a9f6052e80b2adac75 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:36:00 +0200 Subject: [PATCH 198/215] Write each screen's reassurance once rather than twice, and complete the account a row test builds Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/routes/_authed/admin/plugins/$key.tsx | 9 +++++++-- .../routes/_authed/settings/connected-accounts/$key.tsx | 8 ++++++-- app/tests/brokered-account-row.test.tsx | 5 +++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index 5d35d0e4f..f645f791e 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -223,6 +223,11 @@ function RouteComponent() { const auth = connectionKindFor(server, entry?.auth); const title = entry?.title ?? server?.title ?? key; + /* Bound once because the brokered row below is told it twice — once inside the whole + disconnected sentence and once on its own — and two copies of it would drift. */ + const reassurance = + "Setup is complete without it, and it reaches your documents only."; + /* * Everything the brokered row below reads and does, shared with the personal connected-accounts * screen that draws the same row. See `brokered-account-row.tsx`. @@ -572,13 +577,13 @@ function RouteComponent() { Composio, so what it undoes is not the row here but the grant on this person's mailbox, and connecting again is a fresh consent. */ connectedDescription={`Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.`} - disconnectedDescription="Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only." + disconnectedDescription={`Connect your own account to try this connector. ${reassurance}`} /* The half of the line above that is true whichever way this app connects. An app whose key somebody types gets a different first sentence from the row — pressing Connect opens a form rather than leaving for a consent screen — and an administrator still has to be told they are not the step that finishes the connector. */ - disconnectedReassurance="Setup is complete without it, and it reaches your documents only." + disconnectedReassurance={reassurance} title={title} /> diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 9e9c3fa81..d1662f249 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -115,6 +115,10 @@ function RouteComponent() { * better: its title and summary are the catalogue's, and there is none. */ if (brokered && server) { + /* Bound once because the row below is told it twice — once inside the whole disconnected + sentence and once on its own — and two copies of it would drift. */ + const reassurance = "No Bot can read this as you."; + return ( diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index ded934e35..720b0aec0 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -663,6 +663,10 @@ test("the row names the app rather than calling it the app", async () => { * The hook is exercised through the two screens above, which is where its own defects live. These * cases differ only in the three facts the row branches on — `kind`, `connected`, `verified` — and * a deployment built for each would be testing the stub rather than the sentence. + * + * Every field of `BrokeredAccount` has to be kept here by hand: `app/tsconfig.json` covers `src` + * and not `app/tests`, and `bun test` does not typecheck, so a field added to the type and missed + * here is `undefined` at render time and nothing says so. */ function accountState(overrides: Partial): BrokeredAccount { return { @@ -679,6 +683,7 @@ function accountState(overrides: Partial): BrokeredAccount { rechecking: false, requestFields: () => {}, requestingFields: false, + submissionError: null, submitFields: () => {}, submittingFields: false, verified: false, From 41a15e0b157c417ca424092491285190c6ac660c Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:45:12 +0200 Subject: [PATCH 199/215] Check a typed key with one argument-less read, and say which of three states the answer left Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 197 +++++++++- server/tests/composio-connections.test.ts | 455 +++++++++++++++++++++- 2 files changed, 630 insertions(+), 22 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d7ec6733a..7cbec5616 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -52,7 +52,11 @@ import { resolveServerUrl, serverCredentialKind, } from "./catalogue"; -import { toolkitOf, VERSION_ARG } from "./composio"; +import { + callTool as composioCallTool, + toolkitOf, + VERSION_ARG, +} from "./composio"; import { inspectToolArguments } from "./content-governance"; import { type ListedTool, McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -4223,12 +4227,32 @@ export function createPluginStore(options: PluginStoreOptions) { * confirmBrokeredConnection}, which asks a question the vendor may answer no to, this performs * an act: it either made the connection or it threw. A `boolean` would invite a caller to * branch on a `false` this method cannot produce. + * + * `probe` IS RETURNED BESIDE `verified` BECAUSE THE FLAG ALONE NOW MEANS THREE DIFFERENT THINGS, + * AND THE BROWSER READS THIS FIELD TO CHOOSE ITS SENTENCE. While the row was written `false` + * unconditionally the flag had one meaning — nobody has checked — and a screen could say so from + * the flag alone. With a probe that can fail there are three states, and two of them share the + * flag: + * + * null probe, `verified: false` — this app publishes nothing safe to call, so nothing was + * tried. "It was accepted without being checked" is true. + * named probe, `verified: true` — the action ran in this person's account and answered. + * named probe, `verified: false` — it ran, the vendor said no, and the account could not be + * withdrawn. The key is BAD and the row exists anyway. + * + * That last one is the worst state this feature has, and under the old wording a person in it + * would be told their key was never checked — when it was checked, the vendor rejected it, and + * this deployment failed to undo the account it made. INFERRING THE STATE CLIENT-SIDE FROM + * `verified` IS EXACTLY WHAT THIS FIELD EXISTS TO PREVENT: there is nothing in the flag that + * separates "nothing to try" from "tried and failed", so a browser deriving a sentence from it + * would tell one of those two people the opposite of what happened. The audit row carries the + * same distinction under `action`, for a reader of the trail rather than of the screen. */ async connectBrokeredWithFields(input: { toolkit: string; userId: string; values: Record; - }): Promise<{ connected: true; verified: boolean }> { + }): Promise<{ connected: true; verified: boolean; probe: string | null }> { // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to // connect anybody at, and the refusal must happen before the values are touched at all. if (!broker) throw new BrokerUnconfiguredError(); @@ -4251,7 +4275,7 @@ export function createPluginStore(options: PluginStoreOptions) { ); } - await broker.connectWithFields({ + const { accountId } = await broker.connectWithFields({ userId: input.userId, toolkit: input.toolkit, authScheme, @@ -4259,17 +4283,132 @@ export function createPluginStore(options: PluginStoreOptions) { }); /* - * UNVERIFIED FOR NOW, AND "FOR NOW" IS THE WHOLE OF WHAT THIS FALSE MEANS. + * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. + * + * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence + * that the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the + * call safe: the vendor must have labelled the action a read and it must take no arguments, + * and both matter because the first argument-less action on Stripe's own list creates a + * billing session. Null is an ordinary answer — see that method — and it is the FIRST of the + * three states this method reports. + */ + const serverId = `composio-${input.toolkit}`; + const candidate = await this.probeActionFor(serverId); + + /* + * THE VERSION THE LISTING RECORDED FOR THAT ACTION, WITHOUT WHICH THERE IS NOTHING TO CALL. + * + * Composio refuses an execution without a specific version and rejects "latest", so the + * transport refuses before dialling where none travels with the call — which is a refusal of + * OURS wearing the shape of a failed probe. Read here rather than returned by the chooser + * because what the chooser answers is which action is SAFE, and that question has nothing to + * do with whether this deployment happens to have recorded a version for it. * - * It is a PLACEHOLDER and not a settled answer: the next step calls a probe against the key - * that was just attached and writes `Boolean(probe)` here instead. Until that exists, - * `false` is the honest value rather than a pessimistic one — Composio accepted the values - * and nothing has asked the app on the other side whether the credential works, which is - * exactly the state {@link composioConnections.verified} documents as "a key somebody typed - * in that nobody has checked". Written through the single writer rather than spelled here, - * so this path and the confirm path cannot drift into two row shapes. + * AN ACTION WITH NO RECORDED VERSION IS THEREFORE NOTHING TO TRY, and is reported as the null + * probe rather than as a failed one. Composio publishes some actions with no version at all, + * and the alternative is indefensible: a person with a perfectly good key would have their + * account withdrawn and their connection refused because an app's listing was thin, and the + * sentence they were shown would say the vendor rejected their key. `false` with a null probe + * says the honest thing — nothing was checked — which is the state the app is really in. + */ + const recorded = candidate + ? await database + .select({ version: mcpTools.version }) + .from(mcpTools) + .where( + and( + eq(mcpTools.serverId, serverId), + eq(mcpTools.name, candidate), + ), + ) + .limit(1) + : []; + const version = recorded[0]?.version ?? null; + const probe = version === null ? null : candidate; + + if (probe !== null) { + /* + * THE TRANSPORT DIRECTLY, AND NOT `callTool` ABOVE. This deployment's own `callTool` checks + * a grant, evaluates the policy and writes an `mcp.call_*` row, and there is no Bot here to + * check a grant for, no policy context to evaluate and no Bot to attribute a row to. What + * holds this narrow is structural rather than disciplinary: no endpoint, no arguments, and + * an action chosen from recorded metadata rather than from anything a request said. See + * `mcp.connection_verified` in `./audit`, which records the same three properties as the + * reason this call may skip the checks the ordinary path cannot. + * + * THE VERSION IS NOT AN ARGUMENT. It travels under the transport's reserved key, which the + * Composio transport strips before anything reaches the vendor and asserts that it did, so + * what Composio is handed is the action and an empty argument object. + */ + const answer = await composioCallTool( + { url: `composio://${input.toolkit}`, actorId: input.userId }, + probe, + { [VERSION_ARG]: version }, + ); + + if (answer.isError) { + /* + * NOTHING IS LEFT BEHIND ON A KEY THAT DOES NOT WORK. The account is deleted at Composio + * before the refusal is raised, so a mistyped key does not leave a live connection that + * every screen here would draw as connected — which is precisely the state the + * verification exists to prevent, and it would be worse for having been created by the + * check itself. + * + * THE ACCOUNT THIS CALL MADE, BY ID, AND NEVER THE APP. `revoke` ends every account the + * person holds for the app; here the intent is narrower than that — undo the thing just + * done — and the two differ exactly when the local row and Composio have drifted apart, + * which is the case where a sweep would delete a connection that was working. + */ + const removed = await broker + .revokeAccount(accountId) + .then(() => true) + .catch(() => false); + + if (!removed) { + /* + * AND WHERE THE UNDO ITSELF FAILS, THE ROW IS WRITTEN ANYWAY. That reverses this + * method's own rule, and it reverses it in the one case where the rule is no longer + * available: Composio has an account attached and will not take it back. Leaving no row + * then does not mean "nothing was left behind" — it means a live account nothing on any + * screen names, which the person cannot disconnect, because disconnect works off the + * row. This connector's standing order everywhere else is that a failure leaves access + * dead rather than live and unreachable; here only the second half is reachable, so the + * row is written as UNVERIFIED and the sentence says all three facts. + * + * WHICH IS WHY THE SENTENCE CARRIES THE VENDOR'S OWN. This is the THIRD state — the row + * exists, unverified, and the key is bad — and it is the state the `probe` field above + * was added for. A refusal that said only "it could not be checked" would leave the + * person believing their key might be fine, standing in front of a row that says + * unchecked, with a live account at the vendor. + */ + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified: false, + }); + throw new PluginRefusedError( + `What you entered for ${input.toolkit} did not work — ${answer.text} — and Composio would not take the account back either, so it is recorded here as unchecked rather than left somewhere nothing could name it. Disconnect it on the Plugins page and try again.`, + null, + ); + } + + throw new PluginRefusedError( + `${input.toolkit} would not answer with what was entered: ${answer.text} Nothing was saved, so entering it again is the whole of the retry.`, + null, + ); + } + } + + /* + * THE SECOND STATE, AND THE ONLY ONE THAT EARNS THE FLAG. A probe that ran and answered is + * evidence the key works, exactly as the vendor's yes at the end of a consent screen is + * evidence for {@link confirmBrokeredConnection}; a null probe is the honest unchecked state + * {@link composioConnections.verified} documents. Written through the single writer below + * rather than spelled here, so this path and the confirm path cannot drift into two row + * shapes — `verified_at` follows from the flag there and is not passed in. */ - const verified = false; + const verified = probe !== null; // Read before the write, for `confirmBrokeredConnection`'s reason: the record below is an // upsert, so it leaves nothing behind that tells a first key from a replacement, and whether @@ -4310,7 +4449,39 @@ export function createPluginStore(options: PluginStoreOptions) { }, }); - return { connected: true, verified }; + /* + * THE CHECK ITSELF, ON THE TRAIL, WHETHER OR NOT ONE HAPPENED. + * + * Filed on every connection rather than only where a probe ran, because "this app published + * nothing safe to call, so the key was never tried" is the fact a reader most needs and the + * one nothing else records. `action` is the null the response field is: it separates an + * unchecked connection from a checked one, which `verified: false` alone cannot. + * + * AND IT IS FILED ONLY ON THE PATH THAT REACHES HERE, because both refusals above throw. + * Where the account was withdrawn that is right: no connection exists, and a verification row + * about one would have the trail recording a state this deployment deliberately does not + * hold. Where the withdrawal ITSELF failed a row does stand — unverified, with a live account + * behind it — and nothing on this trail names it. That is the one gap this event leaves: what + * tells the person is the refusal, which names all three facts, and the row they are told to + * disconnect files `mcp.account_disconnected` when they do. + * + * Under the SERVER row's id and not the app slug, unlike the `mcp.account_connected` row + * above. That row is about a person's access to an app, which is what the connection table is + * keyed on; this one is about an ACTION of a server — the id `probeActionFor` was asked and + * the id an operator sees on the Plugins page — and `targetType` here is `mcp_server`. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: input.userId, + action: probe, + verified, + }, + }); + + return { connected: true, verified, probe }; }, /** diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index c1cba5255..3d0f82725 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -109,8 +109,49 @@ const enabledId = `composio-${enabledToolkit}`; const rekeyedToolkit = `rekeyable-${suite}`; /** What `addBrokeredApp` spells that app's row, so {@link clean} can take it back. */ const rekeyedId = `composio-${rekeyedToolkit}`; +/** + * THE APP WHOSE TYPED KEY IS ACTUALLY SPENT ON A CALL, which no other fixture here is. + * + * Its own name rather than {@link enabledToolkit}'s, because that app is the one the secrecy test + * connects and it publishes no actions at all — which is the whole reason it stays unverified. An + * app that gets probed has to hold an action a probe may use, and seeding one on that app would + * change what that test is about. Added to {@link ownedToolkits} so its connection rows are swept, + * and its `mcp_servers` row goes out with {@link probedId} in {@link clean}. + */ +const probedToolkit = `probed-${suite}`; +/** What `addBrokeredApp` spells that app's row, which is also the id the probe chooser is asked. */ +const probedId = `composio-${probedToolkit}`; +/** The one action the chooser can pick for it: a read, asking for nothing, at a recorded version. */ +const probeAction = "PROBED_GET_ME"; +/** + * The version the listing recorded for that action, and the reason it is on the fixture at all. + * + * Composio refuses a call without a specific version and the transport refuses one before dialling, + * so an action recorded with no version is an action nothing here can call. A fixture that left it + * null would have every probe below fail for this deployment's reason rather than the vendor's — + * and the failure tests would pass while asserting nothing about a key. + */ +const probeVersion = "20260903_00"; +/** The account Composio answers with when the key that was just typed is attached. */ +const madeAccountId = `ca_${suite}`; +/** + * AN ACCOUNT THE VENDOR HOLDS THAT THIS DEPLOYMENT NEVER MADE, which is the drift a sweep destroys. + * + * `revoke` ends every account a person holds for an app; `revokeAccount` ends the one it is handed. + * The two differ only when Composio holds an account no row here names — an earlier connection this + * deployment lost the row for, one made in Composio's own dashboard — and that is somebody's + * WORKING connection. This id stands in for it, so "the undo was narrow" is an assertion about what + * the vendor still holds afterwards rather than about how a call was spelled. + */ +const strandedAccountId = `ca_working_${suite}`; /** Every app this run owns, which is the scope of every read and every delete below. */ -const ownedToolkits = [toolkit, secondToolkit, enabledToolkit, rekeyedToolkit]; +const ownedToolkits = [ + toolkit, + secondToolkit, + enabledToolkit, + rekeyedToolkit, + probedToolkit, +]; /** * An app this file does NOT own, standing in for another run's fixture — or another file's. * @@ -332,6 +373,28 @@ let vendorFinds: (request: { userId: string; toolkit: string }) => boolean = let vendorRefuses: (request: { userId: string; toolkit: string }) => boolean = () => false; +/** + * EVERY ACCOUNT THE VENDOR STILL HOLDS, which is what an undo has to be judged against. + * + * A list rather than a counter, because the question the narrow undo answers is WHICH account went: + * a sweep and a by-id withdrawal both leave "one fewer ask made" behind them, and they differ only + * in what Composio is still holding afterwards. {@link connectWithFields} adds the account it made, + * {@link ComposioBroker.revokeAccount} takes back the one it is handed, and {@link + * ComposioBroker.revoke} empties it for the app — so a test can seed {@link strandedAccountId} and + * assert it survived. + */ +let vendorHolds: string[] = []; + +/** + * Whether the vendor REFUSES TO TAKE ONE ACCOUNT BACK, which is the worst state this feature has. + * + * Separate from {@link vendorRefuses} rather than folded into it for the reason that knob is + * separate from {@link vendorFinds}: that one is about the sweep a retirement makes, this is about + * the by-id withdrawal a failed verification makes, and one flag spelling both would let a test + * about an undo pass on a stub that only ever refused a retirement. + */ +let vendorKeepsAccount = false; + const broker: ComposioBroker = { listApps: unasked("list the catalogue"), ensureAuthConfig: async (config) => { @@ -371,6 +434,10 @@ const broker: ComposioBroker = { `the vendor would not withdraw ${request.toolkit}/${request.userId}`, ); } + // A SWEEP, which is the whole difference from `revokeAccount` and the reason it is modelled + // here at all: this ends every account the person holds for the app, including one this + // deployment never made. See {@link strandedAccountId}. + vendorHolds = []; return vendorFinds(request); }, deleteAuthConfig: async (forToolkit) => { @@ -393,11 +460,26 @@ const broker: ComposioBroker = { held: await connectionsHeld(), }); valuesSent.push(request.values); - return { accountId: `ca_${suite}` }; + // Held from here, so that what the vendor is left with afterwards is a fact about the act + // rather than about the fixture: the account exists because this call made it. + vendorHolds.push(madeAccountId); + return { accountId: madeAccountId }; + }, + revokeAccount: async (accountId) => { + asks.push({ + // Named by the ACCOUNT ID and by nothing else, because that is the whole of what this method + // is handed and the whole of what makes it narrow. An ask spelled by app and person would be + // indistinguishable from `revoke`'s, which is the call this one exists not to be. + ask: `revokeAccount:${accountId}`, + held: await connectionsHeld(), + }); + // Recorded before it refuses, for `revoke`'s reason: an undo that failed is still an undo that + // was attempted, and the tests about that state assert both halves. + if (vendorKeepsAccount) { + throw new Error(`the vendor would not take ${accountId} back`); + } + vendorHolds = vendorHolds.filter((held) => held !== accountId); }, - // The verification that undoes its own account is micro-task 10.1's; nothing here makes one to - // take back, so this has no caller yet and says so. - revokeAccount: unasked("take one account back"), }; const store = createPluginStore({ @@ -444,6 +526,7 @@ async function clean() { enabledId, rekeyedId, probeAppId, + probedId, ]), ); await database @@ -455,6 +538,7 @@ async function clean() { enabledId, rekeyedId, probeAppId, + probedId, ]), ); await database @@ -523,6 +607,36 @@ async function seedApp(options: { connect?: boolean } = {}) { } } +/** + * The probed app as an administrator's press of Add leaves it, plus the action a probe may use. + * + * ENABLED FOR REAL RATHER THAN INSERTED BY HAND, because what the connect path reads off the row is + * the `auth_scheme` — and a fixture that wrote it itself would be asserting this file's idea of what + * Add records instead of `addBrokeredApp`'s. The action is inserted directly afterwards, the way the + * two chooser tests above insert theirs: nothing here is about how a listing turns Composio's tags + * into an effect, and a stub that had to spell those tags would make every test below depend on it. + * + * `withProbe: false` LEAVES THE APP WITH NO ACTIONS AT ALL, which is an ordinary app and not a + * broken one: most key-based apps in the live catalogue publish some argument-less read and PostHog + * publishes none. + */ +async function addProbedApp(options: { withProbe?: boolean } = {}) { + await store.addBrokeredApp({ + slug: probedToolkit, + title: "Probed App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + if (options.withProbe === false) return; + await database.insert(mcpTools).values({ + serverId: probedId, + name: probeAction, + description: "Says who the key belongs to.", + effect: "read", + version: probeVersion, + }); +} + /** * Which of THIS RUN'S apps this deployment still believes somebody has connected. * @@ -575,6 +689,12 @@ beforeEach(async () => { // And answering at all is the ordinary case too. A vendor that will not answer is the subject of // its own two tests and of nothing else. vendorRefuses = () => false; + // The vendor holding nothing is where every test starts, so an account in the list below is one + // the test under way either made or seeded on purpose. + vendorHolds = []; + // And taking an account back when asked is the ordinary case, for {@link vendorRefuses}' reason: + // the refusal is the subject of exactly one test. + vendorKeepsAccount = false; }); afterEach(() => useComposioClient(null)); @@ -1425,9 +1545,11 @@ test("an app whose only read takes an argument has no probe", async () => { * nothing at all, so {@link valuesSent} is checked in the same breath: the values went to the one * place they are for. * - * `verified: false` IS THE STATE THIS TASK LEAVES AND NOT THE SETTLED ANSWER. The probe that earns - * the flag is its own step; until it exists a key connection is honestly unchecked, which is the - * pair `composio_connections.verified` documents for exactly this row. + * `verified: false` HERE IS "NOTHING TO TRY", WHICH `probe: null` IS WHAT SAYS. This app publishes + * no actions at all, so the chooser has nothing safe to spend the key on and the connection is + * honestly unchecked — the pair `composio_connections.verified` documents for exactly this row. The + * same `false` beside a NAMED probe would mean the opposite thing about the key, which is why the + * two travel together; see {@link connectBrokeredWithFields} and the test at the foot of this file. */ test("the values reach Composio and nothing else", async () => { useAnsweringClient(); @@ -1444,7 +1566,7 @@ test("the values reach Composio and nothing else", async () => { userId: askerId, values: { generic_api_key: typedKey }, }), - ).toEqual({ connected: true, verified: false }); + ).toEqual({ connected: true, verified: false, probe: null }); // The vendor was asked, and asked with what the person typed. Without this the absences below // would be satisfied by a method that connected nobody. @@ -1557,3 +1679,318 @@ test("a second key for the same app is recorded as a reconnection", async () => ), ).toEqual([false, true]); }); + +/** + * A KEY THAT DOES NOT WORK LEAVES NOTHING BEHIND, NOT EVEN THE ACCOUNT THE CHECK ITSELF MADE. + * + * CRITERION. When the probe comes back an error, the call refuses with the vendor's own sentence, + * the account Composio just made is withdrawn by id, and this deployment holds no connection row. + * + * REASON. Composio accepts a key without ever trying it, so "connected" at the vendor says nothing + * about whether the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. The first anybody would hear of it is the + * vendor's own error in the middle of a Bot doing something. So the verification is the whole point + * of this path, and a failure that left the account standing would be worse than no check at all: + * the live-but-useless connection would have been created BY the check. + * + * AND THE PROBE HAD TO REALLY RUN, which is what {@link reached} asserts beside it. Every absence + * below is also true of a method that refused before it dialled — for want of a version, say — so + * without it this test would pass against a deployment that never spent the key at all. + */ +test("a key that does not work leaves nothing behind", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + // The vendor reporting a failure in a 200, which is how Composio says a credential is wrong. + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(/Invalid API key provided\./); + + // The key was spent on the action the chooser picked, and on nothing else. + expect(reached).toEqual([probeAction]); + // Nothing was saved, which is the sentence the refusal ends on. + expect(await connectedToolkitsFor(askerId)).toEqual([]); + // And the account the check created is gone from the vendor, by the id it was made under. + expect(vendorHolds).toEqual([]); + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); +}); + +/** + * AN UNDO THAT FAILS LEAVES THE ACCOUNT REACHABLE RATHER THAN INVISIBLE. + * + * CRITERION. When the probe fails AND Composio will not take the account back, the row is written + * unverified, the refusal says all three things — the key did not work, the account could not be + * withdrawn, it is recorded here unchecked — and the account is still the vendor's to see. + * + * REASON. This is the worst state the feature admits, and the rule that governs every other failure + * here is not available in it. "Leave nothing behind" assumes the account can be ended; when it + * cannot, leaving no row does not mean nothing was left behind — it means a LIVE account that + * nothing in this deployment names, that no screen draws and that the person cannot disconnect, + * because disconnect works off the row. A row saying "unchecked" is worse than a clean failure and + * far better than an invisible account. What makes the row honest is that the refusal carries what + * happened: the person is not told their key is fine, and they are given the one step that helps. + */ +test("an undo that fails leaves the account reachable rather than invisible", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + vendorKeepsAccount = true; + await addProbedApp(); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(/would not take the account back/); + + // The undo was attempted and refused, rather than skipped: the row below is the consequence of a + // vendor that would not act, and an implementation that never asked would leave the same row. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); + expect(vendorHolds).toEqual([madeAccountId]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row).toMatchObject({ + toolkit: probedToolkit, + userId: askerId, + verified: false, + }); + // No date on a claim nobody made, which is the pair `recordBrokeredConnection` writes together. + expect(row.verifiedAt).toBeNull(); +}); + +/** + * A FAILED KEY TAKES DOWN THE ACCOUNT IT MADE AND NOTHING BESIDE IT. + * + * CRITERION. With the vendor holding a second account this deployment has no row for, the undo + * names the id it was just handed, and the other account is still there afterwards. + * + * REASON. The drift case, and it is ordinary rather than exotic: an account made in Composio's own + * dashboard, or one whose row this deployment lost, is a WORKING connection the vendor holds and + * nothing here names. `revoke` sweeps every account a person holds for an app, so an undo written + * that way ends somebody's working connection because somebody mistyped a key — a second person's + * access destroyed by the first one's typo, by a call made to clean up after a check. The id is the + * whole of what makes the narrow call narrow, and it is the only thing a test can hold it to. + */ +test("a failed key takes down the account it made and nothing beside it", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + // Seeded before the connect, so it is an account that predates this act rather than one of its + // making — which is the whole of what a sweep cannot tell apart. + vendorHolds.push(strandedAccountId); + + await expect( + store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).rejects.toThrow(); + + // The working account survived the failure of somebody else's key. + expect(vendorHolds).toEqual([strandedAccountId]); + // And the sweep was never asked for, which is the other half of the same statement: a `revoke` + // here would have emptied the list above. + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + `revokeAccount:${madeAccountId}`, + ]); +}); + +/** + * AN APP WITH NO PROBE CONNECTS UNVERIFIED RATHER THAN NOT AT ALL. + * + * CRITERION. Where the app publishes nothing safe to call, the connection is made, the row says + * unverified with no date, `probe` comes back null, and no action ran at the vendor. + * + * REASON. Null from the chooser is an answer and not a failure — most key-based apps publish some + * argument-less read and PostHog publishes none — so a verification that refused what it could not + * check would make this deployment's ability to connect an app depend on that app's action list. + * + * AND `probe: null` IS WHAT KEEPS THAT ROW'S SENTENCE TRUE. `verified: false` now has three + * possible meanings, and only one of them is this one: nothing was tried. Where a probe ran and + * failed the same flag means the key is bad, and a browser inferring a sentence from the flag alone + * would tell one of those two people the opposite of what happened. The audit row carries the same + * distinction under `action`. + */ +test("an app with no probe connects unverified rather than not at all", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + + expect( + await store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: false, probe: null }); + + // Nothing was called, which is what "nothing to try" means at the vendor. + expect(reached).toEqual([]); + // And nothing was taken back either: there was no failure to undo. + expect(vendorHolds).toEqual([madeAccountId]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(false); + expect(row.verifiedAt).toBeNull(); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + // Null rather than a name, for the same reason the response field is: the trail must be able to + // say that no action ran, which "verified: false" alone cannot. + action: null, + verified: false, + }); +}); + +/** + * A KEY THAT WORKS IS RECORDED VERIFIED, WITH THE ACTION IT WAS CHECKED WITH. + * + * CRITERION. When the probe answers, the row is verified with a date, the response names the action + * that was called, the trail carries the same name, and what reached the vendor was that action, in + * the asking person's account, at the version the listing recorded, WITH NO ARGUMENTS. + * + * REASON. The three failure tests above all end in a refusal, so every one of them would pass + * against a probe that could never succeed — and the transport refuses before dialling unless the + * call carries the version its listing recorded, which is exactly the shape a probe sent with a + * bare `{}` would have. Without this test "the key was checked and it passed" is a state the suite + * never reaches, and a verification that fails for this deployment's own reason would look from + * every other test here exactly like a vendor saying the key is bad — while withdrawing the account + * of every person who typed a good one. + * + * THE ARGUMENTS ARE ASSERTED EMPTY, because that is half of what makes this the one vendor call in + * the deployment that runs outside `callTool`'s grant, policy and content checks. The version + * travels under the transport's reserved key and is stripped before anything reaches Composio, so + * what the vendor is handed is an action chosen from recorded metadata and nothing else. + */ +test("a key that works is recorded verified, with the action it was checked with", async () => { + const sent: { + slug: string; + userId: string; + version: string; + args: unknown; + }[] = []; + useAnsweringClient({ + execute: async (call, args) => { + reached.push(call.slug); + sent.push({ + slug: call.slug, + userId: call.userId, + version: call.version, + args, + }); + return answered; + }, + }); + await addProbedApp(); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + expect( + await store.connectBrokeredWithFields({ + toolkit: probedToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }), + ).toEqual({ connected: true, verified: true, probe: probeAction }); + + expect(sent).toEqual([ + { + slug: probeAction, + // The person's own account, which is the only account a probe could be a check on. + userId: askerId, + version: probeVersion, + args: {}, + }, + ]); + // The account stands, because there was nothing to undo. + expect(vendorHolds).toEqual([madeAccountId]); + expect(asksMade()).toEqual([ + `ensureAuthConfig:${probedToolkit}/fields`, + `connectWithFields:${probedToolkit}/${askerId}`, + ]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt).not.toBeNull(); + expect(row.verifiedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: true, + }); + // The row names the person and no Bot, which is what `mcp.connection_verified` documents: nothing + // ran on a Bot's behalf here, and borrowing one to make the row look uniform would be a lie. + expect(JSON.stringify(checked[0].payload)).not.toContain(botId); + // And the key itself is in none of it, the promise every write on this path keeps. + expect(JSON.stringify(checked)).not.toContain(typedKey); +}); From cce28c04280b1789cf7422c4159d9e2c8a1be5a7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:50:44 +0200 Subject: [PATCH 200/215] Record the state nobody can undo on the trail, and file both rows under the app Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 64 ++++++++++++++++++----- server/tests/composio-connections.test.ts | 35 +++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 7cbec5616..dfedc2598 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4387,6 +4387,44 @@ export function createPluginStore(options: PluginStoreOptions) { userId: input.userId, verified: false, }); + + /* + * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The + * refusal is told to one person in one moment; what outlives it is an unverified row + * and a live account at the vendor, and the person who most needs to know both exist + * is an operator reading this trail a week later. Filed BEFORE the throw for the only + * reason that matters here: every way out of this branch is that throw, so a row + * written after it is a row never written — which is exactly how this state came to + * be the one thing the trail did not record. + * + * `action` IS THE PROBE THAT WAS TRIED, and it is what tells this row from the + * unchecked one. Both say `verified: false`; only the name separates "this app + * published nothing safe to call" from "it ran, the vendor said no, and the account + * could not be withdrawn" — the same distinction the `probe` response field exists + * for, made for a reader of the trail rather than of a screen. + * + * AND NOTHING IS FILED ON THE CLEAN UNDO ABOVE, which is a decision rather than the + * same omission repeated. A probe that failed and whose account WAS withdrawn leaves + * no account, no row and nothing for anybody to do: this trail records state that + * persists, the criterion {@link disconnectBrokered} already files its own row on — + * no row deleted and no grant withdrawn means nobody was disconnected and nothing is + * written. Filing one anyway would also cost this row the meaning it was just given. + * A `verified: false` verification row under an app would stop meaning "there is a + * live account here somebody has to deal with", because most of them would mean "a + * key was mistyped and cleaned up after" — and the one state an operator must act on + * would be unfindable again, in a different way. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, + verified: false, + }, + }); + throw new PluginRefusedError( `What you entered for ${input.toolkit} did not work — ${answer.text} — and Composio would not take the account back either, so it is recorded here as unchecked rather than left somewhere nothing could name it. Disconnect it on the Plugins page and try again.`, null, @@ -4457,23 +4495,25 @@ export function createPluginStore(options: PluginStoreOptions) { * one nothing else records. `action` is the null the response field is: it separates an * unchecked connection from a checked one, which `verified: false` alone cannot. * - * AND IT IS FILED ONLY ON THE PATH THAT REACHES HERE, because both refusals above throw. - * Where the account was withdrawn that is right: no connection exists, and a verification row - * about one would have the trail recording a state this deployment deliberately does not - * hold. Where the withdrawal ITSELF failed a row does stand — unverified, with a live account - * behind it — and nothing on this trail names it. That is the one gap this event leaves: what - * tells the person is the refusal, which names all three facts, and the row they are told to - * disconnect files `mcp.account_disconnected` when they do. + * THE FAILED UNDO ABOVE FILES THE SAME ROW BEFORE IT THROWS, so the worst state this feature + * has — a live account, an unverified row, and a withdrawal the vendor refused — is no longer + * named only in a sentence one person read once. The clean undo files nothing, and the reason + * is written out at that branch: this trail records state that persists, and a row there + * would cost the failed-undo row the one meaning that makes it worth reading. * - * Under the SERVER row's id and not the app slug, unlike the `mcp.account_connected` row - * above. That row is about a person's access to an app, which is what the connection table is - * keyed on; this one is about an ACTION of a server — the id `probeActionFor` was asked and - * the id an operator sees on the Plugins page — and `targetType` here is `mcp_server`. + * UNDER THE APP SLUG, which is the id `mcp.account_connected` above, {@link + * confirmBrokeredConnection}, {@link disconnectBrokered} and {@link retireConnectionsFor} all + * file under — so the two rows this method can write about one person's access to one app + * come back in ONE query, however that access began and however it ended. This row was filed + * under the SERVER row's id instead, on the reasoning that it is about an ACTION of a server + * rather than about access; that is true and it cost the reader the only question anybody + * asks this trail, which was answered half by one id and half by the other. Nothing goes with + * the change: the server id is `composio-` and the slug, and the action is in the payload. */ await recordAuditEvent(auditStore, { eventType: "mcp.connection_verified", targetType: "mcp_server", - targetId: serverId, + targetId: input.toolkit, payload: { actor: input.userId, action: probe, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 3d0f82725..9760c69f3 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -1794,6 +1794,22 @@ test("an undo that fails leaves the account reachable rather than invisible", as }); // No date on a claim nobody made, which is the pair `recordBrokeredConnection` writes together. expect(row.verifiedAt).toBeNull(); + + // AND THE TRAIL CARRIES THE STATE, not just the person who happened to be at the screen. This is + // the one outcome here that leaves a live account nobody can reach through this deployment, and + // an operator reading the trail later is exactly who needs to find it — so the refusal being + // thorough is not a substitute for a row. + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + // The action that was TRIED, which is the whole of what separates this row from the unchecked + // one: both say `verified: false`, and only the name says the vendor was asked and said no. + action: probeAction, + verified: false, + }); + // And the key is in none of it, on the path that fails as much as on the one that works. + expect(JSON.stringify(checked)).not.toContain(typedKey); }); /** @@ -1991,6 +2007,25 @@ test("a key that works is recorded verified, with the action it was checked with // The row names the person and no Bot, which is what `mcp.connection_verified` documents: nothing // ran on a Bot's behalf here, and borrowing one to make the row look uniform would be a lie. expect(JSON.stringify(checked[0].payload)).not.toContain(botId); + /* + * BOTH ROWS THIS METHOD WRITES COME BACK UNDER ONE ID, which is the question anybody asks this + * trail: what happened to this person's access to this app. The two used to be filed under + * different keys — the connection under the app slug, the verification under the server row id — + * so a reader got half the story depending on which one they asked with, and neither half said + * it was a half. {@link probedId} is named first because it is the id that was used and the one + * a regression puts back; the set is asked after it, because the criterion is a single query + * finding all of these rows and that is true of no other id either. + */ + const forApp = events.filter( + (event) => + event.eventType === "mcp.connection_verified" || + event.eventType === "mcp.account_connected", + ); + expect(forApp).toHaveLength(2); + expect(forApp.map((event) => event.targetId)).not.toContain(probedId); + expect(new Set(forApp.map((event) => event.targetId))).toEqual( + new Set([probedToolkit]), + ); // And the key itself is in none of it, the promise every write on this path keeps. expect(JSON.stringify(checked)).not.toContain(typedKey); }); From 1ee070d41054eb13851b833ac67bba21a80ed649 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 16:53:27 +0200 Subject: [PATCH 201/215] Answer a key app with its own form, and send on only the names it published Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- app/src/lib/plugins/mutations.ts | 14 +- server/src/plugins/routes.ts | 158 ++++++++++++++ server/tests/plugin-routes.test.ts | 317 ++++++++++++++++++++++++++++- 3 files changed, 487 insertions(+), 2 deletions(-) diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index e4d282e8b..9b7f220a9 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -384,6 +384,13 @@ export function brokeredConnectionFieldsMutationOptions() { * Answers with the body rather than a bare success, because Composio does not check a submitted key. * `connected` is only that the vendor accepted the row; `verified` is whether a real call was made * with it, and a screen says different things about the two. + * + * AND `probe` IS WHAT SEPARATES THE TWO THINGS `verified: false` MEANS. A null probe is an app that + * publishes nothing safe to spend a key on, so nothing was tried and "accepted without being + * checked" is the truth. A NAMED probe beside that same false is the other state entirely: the + * action ran in this person's account, the vendor rejected the key, and the account could not be + * withdrawn — so the row exists and the key is bad. A screen reading the boolean alone cannot tell + * those apart, and would tell the second person that nothing had ever been checked. */ export function connectBrokeredWithFieldsMutationOptions( queryClient: QueryClient, @@ -392,7 +399,11 @@ export function connectBrokeredWithFieldsMutationOptions( mutationFn: async (variables: { serverId: string; values: Record; - }): Promise<{ connected: boolean; verified: boolean }> => { + }): Promise<{ + connected: boolean; + verified: boolean; + probe: string | null; + }> => { const response = await client( `/api/plugins/servers/${encodeURIComponent(variables.serverId)}/connect`, { @@ -404,6 +415,7 @@ export function connectBrokeredWithFieldsMutationOptions( return (await response.json()) as { connected: boolean; verified: boolean; + probe: string | null; }; }, onSuccess: () => invalidatePlugins(queryClient), diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index df24ad2d5..d8fcefd71 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -5,10 +5,12 @@ import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { type BrokerApp, + type BrokerField, BrokerUnconfiguredError, brokerReturnUrl, brokerSentence, type ComposioBroker, + isFieldScheme, } from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; import { toolkitOf, vendorSentence } from "./composio"; @@ -865,6 +867,162 @@ export function createPluginRoutes( ); } + /* + * AN APP WHOSE SECRET THE PERSON HOLDS IS ANSWERED HERE AND NEVER SENT AT A CONSENT SCREEN. + * + * Most of Composio's catalogue connects this way rather than through a consent screen: the + * person already holds an API key, so there is nothing to consent to, no url to mint and no + * return leg to build — the press that opens a vendor page for a consent app has to answer + * with a form instead, and the press after it carries what was typed into it. One route + * serves both halves because the browser asks the same question both times: connect me to + * this app. + * + * THE FORK IS THE SCHEME RECORDED ON THE ROW AT ENABLE TIME, which is what this deployment's + * authorization config was actually created as — never a fresh catalogue read and never + * anything the request said. {@link isFieldScheme} is asked rather than the string compared, + * for the reason that function exists: one list, read by the guard and by the type, so the + * schemes admitted here cannot come apart from the ones the broker's signature takes. + * + * AFTER THE ONE-ACCOUNT GUARD RATHER THAN BESIDE IT, and the order is a decision. Somebody + * who already has an account attached has no business in front of a form: drawing one invites + * them to type a key that would be refused once they had entered it, and even the first press + * would spend a call at Composio on behalf of a request that is going to be refused anyway. + * The refusal above names the step to take, and it is the same step for both kinds of app. + * + * THE PERSON IS STILL THE SESSION'S, as everywhere else in this branch. Nothing below reads + * a user id out of the body — and nothing below logs the body either, which matters more + * here than anywhere else in this file: it is the one request this deployment handles that + * carries somebody's own credential. + */ + const authScheme = row.authScheme; + if (isFieldScheme(authScheme)) { + /* + * A SUBMISSION IS AN OBJECT OF VALUES, AND ANYTHING ELSE IS THE FIRST PRESS. The browser + * sends no body at all when it is asking what the app wants, so an absent, empty or + * unparseable one is that question rather than a malformed answer to it, and the worst a + * caller gets for sending something stranger is the form back. + */ + const body = (await context.req.json().catch(() => null)) as { + values?: unknown; + } | null; + const submitted = + typeof body?.values === "object" && + body.values !== null && + !Array.isArray(body.values) + ? (body.values as Record) + : null; + + /* + * ASKED OF THE VENDOR ON BOTH PRESSES, because it is the answer to both questions. On the + * first it is the form itself; on the second it is the list the submission is checked + * against, and reading it from anywhere else — a cached copy, the fields the form was drawn + * from — would be checking a body against what the app used to ask for. + */ + let published: BrokerField[]; + try { + published = await composio.broker.connectionFields({ + toolkit, + authScheme, + }); + } catch (error) { + const refusal = brokerRefusal( + error, + `Composio would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + + if (submitted === null) return context.json({ fields: published }); + + /* + * WHAT THE APP PUBLISHED, AND A NAME IT DID NOT IS REFUSED RATHER THAN QUIETLY DROPPED. + * + * ONE GUARD, THREE HOLES. What is submitted is spread into the field object the adapter + * hands Composio, beside the literal `status: "ACTIVE"` that call sets — so an unfiltered + * body lets a caller write over it. Anything else invented travels to the vendor + * unexamined. And a field this deployment recorded that the app has stopped publishing + * shows up here, at the request, rather than as a connection made without the value nobody + * was asked for and a first tool call that discovers it. + * + * REFUSED, BECAUSE A PERSON CANNOT TYPE A NAME THE FORM DID NOT DRAW. The form is drawn + * from this same list a moment earlier, so every name in an ordinary submission is one of + * these; a name that is not leaves exactly two readings, and dropping it silently is the + * wrong answer to both. If the app's published fields have moved, the person is holding a + * stale form and the honest thing is to send them back for the current one — connecting + * them with the part that still matches makes a credential-less account that every screen + * here draws as connected. And if it is a caller reaching past the form on purpose, + * "connected" is the one answer they must not get for a request this deployment edited + * behind their back. Pressing Connect again costs a person one press and redraws the form + * from what the app asks for now. + * + * THE VALUES ARE BUILT FROM THE NAMES THAT PASSED rather than the request object forwarded + * once the check is done, so what reaches the store is the published subset by + * construction and not on the strength of the loop above having run. + * + * A VALUE THAT IS NOT TEXT IS THE SAME REFUSAL. The store's signature promises strings, and + * a number or an object under a published name is a lie told to that signature that reaches + * Composio as whatever JSON makes of it. + */ + const names = new Set(published.map((field) => field.name)); + const values: Record = {}; + for (const [name, value] of Object.entries(submitted)) { + if (!names.has(name) || typeof value !== "string") { + /* + * THE SENTENCE CARRIES NOTHING THAT WAS SUBMITTED — not the value, which is somebody's + * own credential and belongs in no message, and not the name either, which on this + * request is the caller's own text rather than the vendor's. It names the app and the + * press that fixes it, which is the whole of what the person needs. + */ + return context.json( + { + error: `That is not the form ${row.title} publishes, so nothing was sent to Composio. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, + }, + 400, + ); + } + values[name] = value; + } + + try { + /* + * THE STORE'S ANSWER, WHOLE. `connected`, `verified` and `probe` are three facts and not + * one dressed up: a null probe with `verified: false` is an app that publishes nothing + * safe to check a key against, and a named probe with the same flag is a key the vendor + * rejected on an account this deployment could not take back. A route that forwarded the + * boolean alone would leave the row to infer which of those two it was, and it would tell + * the second person that nothing had ever been tried. + */ + return context.json( + await store.connectBrokeredWithFields({ + toolkit, + userId: context.var.actor.id, + values, + }), + ); + } catch (error) { + /* + * A REFUSAL THE STORE AUTHORED IS PASSED THROUGH AS ITSELF, BEFORE THE BROKER MAPPING. + * + * A mistyped key is the ordinary failure on this path — a token from the wrong workspace, + * a key pasted with a newline — and the store's sentence for it already carries the + * vendor's own words and the step to take. `brokerRefusal` cannot see that: a + * {@link PluginRefusedError} is neither an authored broker refusal nor a vendor object, + * so it would come back as "Composio said nothing about why", sending somebody whose key + * was rejected to ask an administrator about this deployment's Composio key. 400 for the + * reason the skills route gives its own refusal one: it is something the person who made + * the request can fix, and the message says what to fix. + */ + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message }, 400); + } + const refusal = brokerRefusal( + error, + `${row.title} could not be connected with what you entered, and Composio said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + } + /* * WHERE THE CONSENT COMES BACK TO, BUILT HERE AND NEVER READ OFF THE REQUEST. * diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 451b67396..3dc9f7c9e 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -5,12 +5,14 @@ import { ServerRowAmbiguousError } from "../src/plugins/access"; import { type BrokerApp, type BrokerConnection, + type BrokerField, BrokerRefusalError, } from "../src/plugins/broker"; import { CatalogueEntryUnknownError, CustomServerRefusedError, PluginInvariantError, + PluginRefusedError, } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; @@ -1238,6 +1240,32 @@ const WRONG_KEY = Object.assign(new Error("Request failed"), { }, }); +/** + * What the key app publishes, which is both the form a person is drawn and the list a submission + * is checked against. + * + * TWO FIELDS AND ONE OF THEM OPTIONAL, because the filter has to be shown to be about NAMES rather + * than about completeness: a submission naming only the required one is a person leaving the + * optional box alone, and it must connect. + */ +const PUBLISHED: BrokerField[] = [ + { + name: "api_key", + label: "API key", + help: "Your Firecrawl API key, a token starting with fc-", + required: true, + secret: true, + }, + { + name: "base_url", + label: "Base URL", + help: "Leave this alone unless you run Firecrawl yourself.", + required: false, + secret: false, + default: "https://api.firecrawl.dev", + }, +]; + /** * One brokered app, added, and the person connecting their own account to it. * @@ -1266,6 +1294,8 @@ function brokeredApp( deployment: { authorizeThrows?: unknown; storeThrows?: unknown; + /** A broker that will not say what an app asks for, which is the fields call failing. */ + fieldsThrow?: unknown; environment?: Record; } = {}, ) { @@ -1282,6 +1312,20 @@ function brokeredApp( by: string; reason: string; }> = []; + /** Every time the route went and asked Composio what an app wants typed in. */ + const asked: Array<{ toolkit: string; authScheme: string }> = []; + /** + * Every submission that reached the store, which is what the filter is asserted through. + * + * The values are recorded because the assertion is about WHICH NAMES travel and not about what a + * person typed: a key the app never published must not be in here, and the published ones must + * arrive unchanged. + */ + const submitted: Array<{ + toolkit: string; + userId: string; + values: Record; + }> = []; const rows = [ { @@ -1290,6 +1334,27 @@ function brokeredApp( // reads it off the row, and the id beside it is what that refusal used to quote instead. title: "Linear", url: "composio://linear", + /* + * The scheme this app's config was created as, which is what decides which of the two + * brokered flows the route opens. `OAUTH2` is a consent app: there is no form to draw, and + * every assertion above about a minted link depends on this row being read as one. + */ + authScheme: "OAUTH2", + }, + /* + * An app whose secret the person holds and types in, which is the other half of the brokered + * surface and the one with a form rather than a consent screen. + * + * A SECOND ROW RATHER THAN A SECOND SCHEME ON THE FIRST, because the two flows have to be + * shown not to reach each other: the consent tests below press Connect on Linear and must + * still get a link, and the form tests press it on this row and must never mint one. One row + * switching scheme between tests would prove one flow at a time and nothing about the fork. + */ + { + id: "composio-firecrawl", + title: "Firecrawl", + url: "composio://firecrawl", + authScheme: "API_KEY", }, /* * An ordinary OAuth row, so that "this app is not brokered" is a real row and not a missing @@ -1298,7 +1363,13 @@ function brokeredApp( * deployment really has whose connection simply does not live at Composio is where a * confusing answer would come from. */ - { id: "notion", title: "Notion", url: "https://notion.test/mcp" }, + { + id: "notion", + title: "Notion", + url: "https://notion.test/mcp", + // Null is not an older brokered row; it is a row that is not brokered at all. + authScheme: null, + }, ]; const store = { @@ -1327,6 +1398,24 @@ function brokeredApp( if (deployment.storeThrows) throw deployment.storeThrows; return { connected: connection !== null }; }, + connectBrokeredWithFields: async (input: { + toolkit: string; + userId: string; + values: Record; + }) => { + submitted.push(input); + if (deployment.storeThrows) throw deployment.storeThrows; + /* + * All three fields, because all three are what the browser reads. `probe` named beside + * `verified: true` is the one state that says a call was really made with the key; the row + * cannot tell that apart from "there was nothing safe to try" without it. + */ + return { + connected: true as const, + verified: true, + probe: "FIRECRAWL_SCRAPE", + }; + }, disconnectBrokered: async (input: { toolkit: string; userId: string; @@ -1369,6 +1458,14 @@ function brokeredApp( if (deployment.authorizeThrows) throw deployment.authorizeThrows; return { redirectUrl }; }, + connectionFields: async (request: { + toolkit: string; + authScheme: string; + }) => { + asked.push(request); + if (deployment.fieldsThrow) throw deployment.fieldsThrow; + return PUBLISHED; + }, }, } as never) : undefined, @@ -1379,6 +1476,24 @@ function brokeredApp( queried, confirmed, disconnected, + asked, + submitted, + /** + * The same route aimed at the app whose secret a person types. + * + * Its own helper rather than a fifth argument to `connect` below, because the two are different + * requests: that one is a consent app and carries a query and headers the tests about return + * addresses need, and this one carries a body and nothing else. + */ + connectFields: (body?: unknown) => + app.request( + "http://openbot.test/api/plugins/servers/composio-firecrawl/connect", + { + method: "POST", + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, + ), connect: ( body: unknown, query = "", @@ -1643,6 +1758,206 @@ describe("connecting a brokered app", () => { }); }); +/** + * The other half of the same route: an app whose secret the person holds and types in. + * + * CRITERION. A key app answers the form on the first press and the connection on the second, the + * person it connects is the session's whatever the body says, every three of `connectBrokeredWithFields`'s + * fields reach the browser, and a name the app did not publish never reaches the store at all. + * + * REASON. Most Composio apps are not consent apps, so this branch is the ordinary path rather than + * the exotic one, and it is the only place in this deployment where a request body is forwarded to + * a vendor. What is submitted is spread into Composio's own field object, so an unfiltered body is + * two separate holes at once: a `status` key would sit beside the literal this deployment sets, and + * anything else a caller invents would travel unexamined. The filter is asserted by what is + * recorded in `submitted` rather than by the answer, because the answer is the same either way. + */ +describe("connecting an app whose secret a person types", () => { + test("the first press answers what the app publishes, and connects nobody", async () => { + /* + * NO BODY AT ALL, which is what the browser really sends on this press: it is a question about + * the app rather than about anybody's account. A route that required a body to answer the form + * would answer this request by trying to connect an empty one. + */ + const { asked, submitted, authorized, connectFields } = brokeredApp(); + + const response = await connectFields(); + + expect(response.status).toBe(200); + // The vendor's own list, passed through rather than restated: `help` is written for the person + // filling the box in, and nothing here is in a position to improve on it. + expect(await response.json()).toEqual({ fields: PUBLISHED }); + /* + * ASKED WITH THE SCHEME RECORDED ON THE ROW, never one derived again here. A form drawn for + * `BASIC` in front of a config created for `API_KEY` asks for boxes the person's app does not + * have. + */ + expect(asked).toEqual([{ toolkit: "firecrawl", authScheme: "API_KEY" }]); + // And nothing was connected and no consent link minted: this press writes nothing. + expect(submitted).toEqual([]); + expect(authorized).toEqual([]); + }); + + test("what the person typed connects them, and all three states come back", async () => { + /* + * THE PERSON IS THE SESSION'S HERE TOO, which is why the body carries somebody else's id: the + * values are a credential being attached to whichever account the user id names, so a route + * reading one off the body would hang this person's key off another person's row. + */ + const { submitted, authorized, connectFields } = brokeredApp(); + + const response = await connectFields({ + values: { api_key: "fc-live-a-secret" }, + userId: SOMEBODY_ELSE.id, + }); + + expect(response.status).toBe(200); + /* + * ALL THREE FIELDS, AND `probe` IS THE ONE THE ROW CANNOT DO WITHOUT. `verified` alone means + * three different things — nothing safe to try, tried and passed, tried and rejected — and two + * of them share the flag. A browser given only the boolean would tell somebody whose key the + * vendor rejected that nothing was ever checked. + */ + expect(await response.json()).toEqual({ + connected: true, + verified: true, + probe: "FIRECRAWL_SCRAPE", + }); + expect(submitted).toEqual([ + { + toolkit: "firecrawl", + userId: ADMIN.id, + values: { api_key: "fc-live-a-secret" }, + }, + ]); + // And no consent link was minted for an app that has no consent screen. + expect(authorized).toEqual([]); + }); + + test("a name the app never published is refused, and nothing is sent", async () => { + /* + * `status` IS THE SHARPEST CASE AND THAT IS WHY IT IS THE ONE SUBMITTED. The values are spread + * into the object the adapter builds for Composio, beside the literal `status: "ACTIVE"` that + * call sets, so an unfiltered body lets a caller write over it. Every other invented name is + * the same hole with a less interesting key in it. + * + * A person cannot type a name the form did not draw, so this request is either a caller doing + * something deliberate or an app whose published fields have moved — and neither is answered by + * connecting them anyway with part of what they sent. + */ + const { submitted, connectFields } = brokeredApp(); + + const response = await connectFields({ + values: { api_key: "fc-live-a-secret", status: "ACTIVE" }, + }); + + expect(response.status).toBe(400); + const refusal = (await response.json()).error as string; + expect(refusal).toContain("Firecrawl"); + /* + * THE SENTENCE CARRIES NOTHING THAT WAS SUBMITTED. The values are somebody's own credential and + * belong in no message, and the names are the caller's text rather than the vendor's on exactly + * the request where they are wrong. + */ + expect(refusal).not.toContain("fc-live-a-secret"); + expect(refusal).not.toContain("ACTIVE"); + // The half that matters: the key never left this deployment. + expect(submitted).toEqual([]); + }); + + test("a value that is not text is refused rather than forwarded as one", async () => { + // The names are checked against the published list and the values against being values at all: + // a number under a published name typechecks nowhere and reaches the vendor as whatever JSON + // makes of it. + const { submitted, connectFields } = brokeredApp(); + + const response = await connectFields({ values: { api_key: 12 } }); + + expect(response.status).toBe(400); + expect(submitted).toEqual([]); + }); + + test("an account already connected is refused before the form is drawn", async () => { + /* + * THE ONE-ACCOUNT GUARD STILL RUNS FIRST, which is the ordering both branches were put after on + * purpose. Drawing the form for somebody who already has an account attached invites them to + * type a key that would be refused after they had entered it, and asking Composio what the app + * wants is a call made on behalf of a request that is going to be refused anyway. + */ + const { asked, submitted, connectFields } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await connectFields(); + + expect(response.status).toBe(409); + const refusal = (await response.json()).error as string; + expect(refusal.toLowerCase()).toContain("disconnect"); + // The app's name rather than the row's id, as on the consent half above. + expect(refusal).toContain("Firecrawl"); + expect(refusal).not.toContain("composio-firecrawl"); + expect(asked).toEqual([]); + expect(submitted).toEqual([]); + }); + + test("a consent app is untouched by either branch, whatever the body carries", async () => { + /* + * THE FORK IS ON THE SCHEME RECORDED ON THE ROW AND ON NOTHING IN THE REQUEST. A body carrying + * values is not a statement about how an app connects, and a route that read it as one would + * send somebody's typed key at a config created for a consent screen — which has nowhere to put + * it — instead of minting the link they pressed for. + */ + const { asked, submitted, authorized, connect } = brokeredApp(); + + const response = await connect({ values: { api_key: "fc-live-a-secret" } }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorizationUrl: AUTHORIZATION_URL, + }); + expect(authorized).toEqual([ + { userId: ADMIN.id, toolkit: "linear", returnUrl: RETURN_URL }, + ]); + expect(asked).toEqual([]); + expect(submitted).toEqual([]); + }); + + test("a broker that will not say what an app asks for answers with its own sentence", async () => { + // The same mapping every other brokered call in this file makes: the vendor's one sentence, a + // 502 rather than a 500, and nothing else that travelled with it. + const { connectFields } = brokeredApp(null, AUTHORIZATION_URL, { + fieldsThrow: WRONG_KEY, + }); + + const response = await connectFields(); + const body = await response.text(); + + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + }); + + test("a key the vendor rejected comes back in the words the store refused it with", async () => { + /* + * A MISTYPED KEY IS THE ORDINARY FAILURE HERE AND ITS SENTENCE IS THE WHOLE REMEDY. The store + * raises a refusal that already says what happened and what to do about it, and flattening that + * into "Composio said nothing about why" would leave the person who pasted a key with a newline + * in it reading a sentence about this deployment's API key. + */ + const { connectFields } = brokeredApp(null, AUTHORIZATION_URL, { + storeThrows: new PluginRefusedError( + "firecrawl would not answer with what was entered: 401 unauthorized. Nothing was saved, so entering it again is the whole of the retry.", + null, + ), + }); + + const response = await connectFields({ values: { api_key: "wrong" } }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("401 unauthorized"); + }); +}); + /** * Confirming a brokered connection, and ending one. * From 5324ee57cce07d7ed1702d18a4532f21216ff721 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 17:05:01 +0200 Subject: [PATCH 202/215] Let somebody re-check the key they just fixed, and raise where the vendor rejects it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/routes.ts | 93 +++- server/src/plugins/store.ts | 490 ++++++++++++++++------ server/tests/composio-connections.test.ts | 269 ++++++++++++ server/tests/plugin-routes.test.ts | 151 +++++++ 4 files changed, 862 insertions(+), 141 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d8fcefd71..98749d0dc 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -1169,12 +1169,12 @@ export function createPluginRoutes( }); /** - * Which app one of the two routes below is about, or the refusal that ends it. + * Which app one of the three routes below is about, or the refusal that ends it. * - * Both of them act on a brokered connection and on nothing else, so both ask the same two - * questions in the same order and answer them in the same words. It is one function because the - * sentence somebody reads when they aim either route at an ordinary OAuth row should not be able - * to drift into two sentences. + * Each of them acts on a brokered connection and on nothing else, so each asks the same two + * questions in the same order and answers them in the same words. It is one function because the + * sentence somebody reads when they aim any of those routes at an ordinary OAuth row should not + * be able to drift into three sentences. * * THE APP COMES OFF THE ROW'S URL AND NEVER OFF ITS ID, for the reason the directory route and * the connect branch above both give: the url is where the transport reads which app a call is @@ -1292,6 +1292,89 @@ export function createPluginRoutes( }, ); + /** + * Try this person's key against the app, because they pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT, which is the one thing a caller of this route has to + * know. Composio never re-checks a key — it accepts one when it is typed and says nothing about it + * again — so this is the only thing in the product that can correct a row whose key was rotated, + * revoked or left to expire. That is also the argument somebody will make for calling it from an + * effect on mount, and it is wrong: the call goes out to the app on the person's OWN account and + * against their own rate limit at the vendor, so verifying on every render would spend somebody's + * quota at Linear to redraw one word on a settings page. The confirm route above is the one that + * runs on mount; it asks Composio about its own records and costs the person nothing. + * + * AND IT IS NOT A CONNECT. The store's argument is made there in full: the account already exists, + * so nothing here creates one, nothing withdraws one when the key turns out to be bad — their + * account stays, it is their key that is wrong — and nothing changes but the verification and its + * date. + * + * A PROBE THAT RAN AND FAILED COMES BACK AS A FAILURE, never as a 200 saying `verified: false`. + * That flag is also what an app publishing nothing safe to call produces, and the row drawing this + * answer cannot tell the two apart — so an answer would quietly drop the Re-check button in + * exactly the state somebody needs it, having just fixed their key, while telling them nothing had + * ever been checked. The store raises with Composio's own sentence in it, and this passes that + * through as a refusal the browser surfaces. The only `verified: false` that arrives as an answer + * is the one carrying `probe: null`, which says there was nothing to check with. + * + * THE PERSON IS THE SESSION'S, as on the two routes around it and for the sharper reason this one + * adds: a user id a caller could name would let one POST spend a stranger's rate limit at the + * vendor and rewrite the verification on their row. Nothing here reads a user id out of the body + * or the query. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED, for confirm's reason: this is somebody checking their + * own account, not an administrator checking anybody's. + */ + routes.post( + "/servers/:id/connection/recheck", + requireUser, + async (context) => { + const resolved = await brokeredAppFor(context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + try { + // The store's answer, whole. `verified` is the flag, `verifiedAt` is the date the row's + // sentence is drawn from, and `probe` is what says a call was really made — three facts, + // and a route that forwarded the boolean alone would leave the row to guess the other two. + return context.json( + await store.recheckBrokeredConnection({ + toolkit: resolved.toolkit, + userId: context.var.actor.id, + }), + ); + } catch (error) { + /* + * A REFUSAL THE STORE AUTHORED IS PASSED THROUGH AS ITSELF, BEFORE THE BROKER MAPPING, for + * the reason the connect route's field branch gives: a key the vendor rejected is the + * ordinary failure on this path, and the store's sentence for it already carries Composio's + * own words and the step to take. `brokerRefusal` cannot see that — a + * {@link PluginRefusedError} is neither an authored broker refusal nor a vendor object — so + * it would answer somebody whose key is wrong with "Composio said nothing about why" and + * send them to an administrator about this deployment's key. 400 because it is theirs to + * fix and the message says what to fix. + */ + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message }, 400); + } + /* + * And a failure this deployment cannot explain says what is on the screen instead of + * guessing. Nothing was written on the way out of the store here, so the row still carries + * the last answer anybody earned rather than a verdict invented by a call that failed. + */ + const refusal = brokerRefusal( + error, + "Composio would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + /** * End this person's own brokered account, at the vendor first and here after. * diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index dfedc2598..eeb10300f 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4060,12 +4060,18 @@ export function createPluginStore(options: PluginStoreOptions) { * `set` rather than a delete and an insert. The person connected when they connected; a write * that moved it would make every page load look like a fresh connection on their own settings * page, and would erase the one date the row holds that nothing else in this deployment knows. + * + * AND THE STAMP IT WROTE IS WHAT IT ANSWERS, for the same reason the caller does not pass one + * in. A caller that needs the moment — {@link recheckBrokeredConnection}, which hands it to the + * browser as the date the row's sentence is drawn from — would otherwise have to read the row + * back and hope it was reading its own write. The timestamp is still this writer's; what + * changed is that it is no longer thrown away. */ async recordBrokeredConnection(input: { toolkit: string; userId: string; verified: boolean; - }): Promise { + }): Promise { const verifiedAt = input.verified ? new Date() : null; await database .insert(composioConnections) @@ -4079,6 +4085,111 @@ export function createPluginStore(options: PluginStoreOptions) { target: [composioConnections.toolkit, composioConnections.userId], set: { verified: input.verified, verifiedAt, updatedAt: new Date() }, }); + return verifiedAt; + }, + + /** + * Spend one call on this person's key, and say what the vendor made of it. + * + * ONE PROBE, TWO CALLERS, AND THE ANSWER TO "WHAT DOES A FAILED PROBE MEAN" LIVES HERE ONCE. + * {@link connectBrokeredWithFields} probes a key somebody has just typed; + * {@link recheckBrokeredConnection} probes one this deployment has held for days. Both have to + * choose the action the same way, send it the same way, and read the vendor's answer the same + * way — and a second copy of that reading is how one of them comes to treat a rejected key as a + * connection that merely could not be checked. What the two callers do NEXT is all that differs, + * and it is all either of them keeps for itself: one withdraws the account it just made, the + * other leaves an account it did not make alone. + * + * THREE ANSWERS AND NOT A BOOLEAN, because `verified: false` means two different things about + * somebody's key and only the action's name separates them: + * + * `probe: null` — the app published nothing safe to call, or nothing at a + * version this deployment recorded. NOTHING WAS TRIED. + * `probe: , failure null` — it ran in this person's account and answered. + * `probe: , failure set` — it ran and the vendor refused, and `failure` is Composio's + * own sentence about why. + * + * A FAILURE IS RETURNED RATHER THAN THROWN, which is the one thing this function does not + * decide. Its two callers end a bad key differently — one undoes an account and refuses, the + * other writes the row unverified and refuses — so a throw here would force the undo on both or + * neither. What it owes them is the vendor's sentence and the name of what was tried. + * + * NOTHING IS WRITTEN, NOTHING IS AUDITED, AND NO ACCOUNT IS TOUCHED. This is a question asked of + * the vendor; recording the answer belongs to whoever asked it. + */ + async probeBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise< + { probe: null; failure: null } | { probe: string; failure: string | null } + > { + /* + * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. + * + * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence + * that the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the + * call safe: the vendor must have labelled the action a read and it must take no arguments, + * and both matter because the first argument-less action on Stripe's own list creates a + * billing session. Null is an ordinary answer — see that method — and it is the FIRST of the + * three states above. + */ + const serverId = `composio-${input.toolkit}`; + const candidate = await this.probeActionFor(serverId); + + /* + * THE VERSION THE LISTING RECORDED FOR THAT ACTION, WITHOUT WHICH THERE IS NOTHING TO CALL. + * + * Composio refuses an execution without a specific version and rejects "latest", so the + * transport refuses before dialling where none travels with the call — which is a refusal of + * OURS wearing the shape of a failed probe. Read here rather than returned by the chooser + * because what the chooser answers is which action is SAFE, and that question has nothing to + * do with whether this deployment happens to have recorded a version for it. + * + * AN ACTION WITH NO RECORDED VERSION IS THEREFORE NOTHING TO TRY, and is reported as the null + * probe rather than as a failed one. Composio publishes some actions with no version at all, + * and the alternative is indefensible: a person with a perfectly good key would be told the + * vendor rejected it because an app's listing was thin. Null says the honest thing — nothing + * was checked — which is the state the app is really in. + */ + const recorded = candidate + ? await database + .select({ version: mcpTools.version }) + .from(mcpTools) + .where( + and( + eq(mcpTools.serverId, serverId), + eq(mcpTools.name, candidate), + ), + ) + .limit(1) + : []; + const version = recorded[0]?.version ?? null; + if (candidate === null || version === null) { + return { probe: null, failure: null }; + } + + /* + * THE TRANSPORT DIRECTLY, AND NOT `callTool` ABOVE. This deployment's own `callTool` checks a + * grant, evaluates the policy and writes an `mcp.call_*` row, and there is no Bot here to + * check a grant for, no policy context to evaluate and no Bot to attribute a row to. What + * holds this narrow is structural rather than disciplinary: no endpoint, no arguments, and an + * action chosen from recorded metadata rather than from anything a request said. See + * `mcp.connection_verified` in `./audit`, which records the same three properties as the + * reason this call may skip the checks the ordinary path cannot — and which names both of + * this function's callers as the whole of who may make it. + * + * THE VERSION IS NOT AN ARGUMENT. It travels under the transport's reserved key, which the + * Composio transport strips before anything reaches the vendor and asserts that it did, so + * what Composio is handed is the action and an empty argument object. + */ + const answer = await composioCallTool( + { url: `composio://${input.toolkit}`, actorId: input.userId }, + candidate, + { [VERSION_ARG]: version }, + ); + + return { probe: candidate, failure: answer.isError ? answer.text : null }; }, /** @@ -4283,159 +4394,112 @@ export function createPluginStore(options: PluginStoreOptions) { }); /* - * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. - * - * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence - * that the credential works — and a row written on that acceptance is a gate every later - * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the - * call safe: the vendor must have labelled the action a read and it must take no arguments, - * and both matter because the first argument-less action on Stripe's own list creates a - * billing session. Null is an ordinary answer — see that method — and it is the FIRST of the - * three states this method reports. - */ - const serverId = `composio-${input.toolkit}`; - const candidate = await this.probeActionFor(serverId); - - /* - * THE VERSION THE LISTING RECORDED FOR THAT ACTION, WITHOUT WHICH THERE IS NOTHING TO CALL. + * THE CHECK, WHICH IS THE SAME ONE A RE-CHECK MAKES AND IS SPELLED ONCE FOR THAT REASON. * - * Composio refuses an execution without a specific version and rejects "latest", so the - * transport refuses before dialling where none travels with the call — which is a refusal of - * OURS wearing the shape of a failed probe. Read here rather than returned by the chooser - * because what the chooser answers is which action is SAFE, and that question has nothing to - * do with whether this deployment happens to have recorded a version for it. + * {@link probeBrokeredConnection} chooses the action out of what the app published, finds the + * version the listing recorded for it, calls it with no arguments and reads what came back. + * What belongs to THIS path and to no other is what happens next: an account this call has + * just made, which a key the vendor rejects must not be allowed to leave standing. A re-check + * runs the identical probe against an account that already existed and leaves it alone, and + * those two undo behaviours are exactly why the shared part stops where it does. * - * AN ACTION WITH NO RECORDED VERSION IS THEREFORE NOTHING TO TRY, and is reported as the null - * probe rather than as a failed one. Composio publishes some actions with no version at all, - * and the alternative is indefensible: a person with a perfectly good key would have their - * account withdrawn and their connection refused because an app's listing was thin, and the - * sentence they were shown would say the vendor rejected their key. `false` with a null probe - * says the honest thing — nothing was checked — which is the state the app is really in. + * `probe: null` IS THE FIRST OF THE THREE STATES THIS METHOD REPORTS — the app published + * nothing safe to call, or nothing at a version this deployment recorded, so the key was + * never tried. It is an ordinary answer and not a failure; see that method. */ - const recorded = candidate - ? await database - .select({ version: mcpTools.version }) - .from(mcpTools) - .where( - and( - eq(mcpTools.serverId, serverId), - eq(mcpTools.name, candidate), - ), - ) - .limit(1) - : []; - const version = recorded[0]?.version ?? null; - const probe = version === null ? null : candidate; + const { probe, failure } = await this.probeBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + }); - if (probe !== null) { + if (failure !== null) { /* - * THE TRANSPORT DIRECTLY, AND NOT `callTool` ABOVE. This deployment's own `callTool` checks - * a grant, evaluates the policy and writes an `mcp.call_*` row, and there is no Bot here to - * check a grant for, no policy context to evaluate and no Bot to attribute a row to. What - * holds this narrow is structural rather than disciplinary: no endpoint, no arguments, and - * an action chosen from recorded metadata rather than from anything a request said. See - * `mcp.connection_verified` in `./audit`, which records the same three properties as the - * reason this call may skip the checks the ordinary path cannot. + * NOTHING IS LEFT BEHIND ON A KEY THAT DOES NOT WORK. The account is deleted at Composio + * before the refusal is raised, so a mistyped key does not leave a live connection that + * every screen here would draw as connected — which is precisely the state the + * verification exists to prevent, and it would be worse for having been created by the + * check itself. * - * THE VERSION IS NOT AN ARGUMENT. It travels under the transport's reserved key, which the - * Composio transport strips before anything reaches the vendor and asserts that it did, so - * what Composio is handed is the action and an empty argument object. + * THE ACCOUNT THIS CALL MADE, BY ID, AND NEVER THE APP. `revoke` ends every account the + * person holds for the app; here the intent is narrower than that — undo the thing just + * done — and the two differ exactly when the local row and Composio have drifted apart, + * which is the case where a sweep would delete a connection that was working. */ - const answer = await composioCallTool( - { url: `composio://${input.toolkit}`, actorId: input.userId }, - probe, - { [VERSION_ARG]: version }, - ); + const removed = await broker + .revokeAccount(accountId) + .then(() => true) + .catch(() => false); - if (answer.isError) { + if (!removed) { /* - * NOTHING IS LEFT BEHIND ON A KEY THAT DOES NOT WORK. The account is deleted at Composio - * before the refusal is raised, so a mistyped key does not leave a live connection that - * every screen here would draw as connected — which is precisely the state the - * verification exists to prevent, and it would be worse for having been created by the - * check itself. + * AND WHERE THE UNDO ITSELF FAILS, THE ROW IS WRITTEN ANYWAY. That reverses this + * method's own rule, and it reverses it in the one case where the rule is no longer + * available: Composio has an account attached and will not take it back. Leaving no row + * then does not mean "nothing was left behind" — it means a live account nothing on any + * screen names, which the person cannot disconnect, because disconnect works off the + * row. This connector's standing order everywhere else is that a failure leaves access + * dead rather than live and unreachable; here only the second half is reachable, so the + * row is written as UNVERIFIED and the sentence says all three facts. * - * THE ACCOUNT THIS CALL MADE, BY ID, AND NEVER THE APP. `revoke` ends every account the - * person holds for the app; here the intent is narrower than that — undo the thing just - * done — and the two differ exactly when the local row and Composio have drifted apart, - * which is the case where a sweep would delete a connection that was working. + * WHICH IS WHY THE SENTENCE CARRIES THE VENDOR'S OWN. This is the THIRD state — the row + * exists, unverified, and the key is bad — and it is the state the `probe` field above + * was added for. A refusal that said only "it could not be checked" would leave the + * person believing their key might be fine, standing in front of a row that says + * unchecked, with a live account at the vendor. */ - const removed = await broker - .revokeAccount(accountId) - .then(() => true) - .catch(() => false); + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified: false, + }); - if (!removed) { - /* - * AND WHERE THE UNDO ITSELF FAILS, THE ROW IS WRITTEN ANYWAY. That reverses this - * method's own rule, and it reverses it in the one case where the rule is no longer - * available: Composio has an account attached and will not take it back. Leaving no row - * then does not mean "nothing was left behind" — it means a live account nothing on any - * screen names, which the person cannot disconnect, because disconnect works off the - * row. This connector's standing order everywhere else is that a failure leaves access - * dead rather than live and unreachable; here only the second half is reachable, so the - * row is written as UNVERIFIED and the sentence says all three facts. - * - * WHICH IS WHY THE SENTENCE CARRIES THE VENDOR'S OWN. This is the THIRD state — the row - * exists, unverified, and the key is bad — and it is the state the `probe` field above - * was added for. A refusal that said only "it could not be checked" would leave the - * person believing their key might be fine, standing in front of a row that says - * unchecked, with a live account at the vendor. - */ - await this.recordBrokeredConnection({ - toolkit: input.toolkit, - userId: input.userId, + /* + * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The + * refusal is told to one person in one moment; what outlives it is an unverified row + * and a live account at the vendor, and the person who most needs to know both exist + * is an operator reading this trail a week later. Filed BEFORE the throw for the only + * reason that matters here: every way out of this branch is that throw, so a row + * written after it is a row never written — which is exactly how this state came to + * be the one thing the trail did not record. + * + * `action` IS THE PROBE THAT WAS TRIED, and it is what tells this row from the + * unchecked one. Both say `verified: false`; only the name separates "this app + * published nothing safe to call" from "it ran, the vendor said no, and the account + * could not be withdrawn" — the same distinction the `probe` response field exists + * for, made for a reader of the trail rather than of a screen. + * + * AND NOTHING IS FILED ON THE CLEAN UNDO ABOVE, which is a decision rather than the + * same omission repeated. A probe that failed and whose account WAS withdrawn leaves + * no account, no row and nothing for anybody to do: this trail records state that + * persists, the criterion {@link disconnectBrokered} already files its own row on — + * no row deleted and no grant withdrawn means nobody was disconnected and nothing is + * written. Filing one anyway would also cost this row the meaning it was just given. + * A `verified: false` verification row under an app would stop meaning "there is a + * live account here somebody has to deal with", because most of them would mean "a + * key was mistyped and cleaned up after" — and the one state an operator must act on + * would be unfindable again, in a different way. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, verified: false, - }); - - /* - * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The - * refusal is told to one person in one moment; what outlives it is an unverified row - * and a live account at the vendor, and the person who most needs to know both exist - * is an operator reading this trail a week later. Filed BEFORE the throw for the only - * reason that matters here: every way out of this branch is that throw, so a row - * written after it is a row never written — which is exactly how this state came to - * be the one thing the trail did not record. - * - * `action` IS THE PROBE THAT WAS TRIED, and it is what tells this row from the - * unchecked one. Both say `verified: false`; only the name separates "this app - * published nothing safe to call" from "it ran, the vendor said no, and the account - * could not be withdrawn" — the same distinction the `probe` response field exists - * for, made for a reader of the trail rather than of a screen. - * - * AND NOTHING IS FILED ON THE CLEAN UNDO ABOVE, which is a decision rather than the - * same omission repeated. A probe that failed and whose account WAS withdrawn leaves - * no account, no row and nothing for anybody to do: this trail records state that - * persists, the criterion {@link disconnectBrokered} already files its own row on — - * no row deleted and no grant withdrawn means nobody was disconnected and nothing is - * written. Filing one anyway would also cost this row the meaning it was just given. - * A `verified: false` verification row under an app would stop meaning "there is a - * live account here somebody has to deal with", because most of them would mean "a - * key was mistyped and cleaned up after" — and the one state an operator must act on - * would be unfindable again, in a different way. - */ - await recordAuditEvent(auditStore, { - eventType: "mcp.connection_verified", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.userId, - action: probe, - verified: false, - }, - }); - - throw new PluginRefusedError( - `What you entered for ${input.toolkit} did not work — ${answer.text} — and Composio would not take the account back either, so it is recorded here as unchecked rather than left somewhere nothing could name it. Disconnect it on the Plugins page and try again.`, - null, - ); - } + }, + }); throw new PluginRefusedError( - `${input.toolkit} would not answer with what was entered: ${answer.text} Nothing was saved, so entering it again is the whole of the retry.`, + `What you entered for ${input.toolkit} did not work — ${failure} — and Composio would not take the account back either, so it is recorded here as unchecked rather than left somewhere nothing could name it. Disconnect it on the Plugins page and try again.`, null, ); } + + throw new PluginRefusedError( + `${input.toolkit} would not answer with what was entered: ${failure} Nothing was saved, so entering it again is the whole of the retry.`, + null, + ); } /* @@ -4524,6 +4588,160 @@ export function createPluginStore(options: PluginStoreOptions) { return { connected: true, verified, probe }; }, + /** + * Try a key this deployment already holds, because somebody pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT. Composio never re-checks a key: it accepts one when it + * is typed and says nothing about it again, so a row that was verified in March goes on saying + * so after the key behind it was rotated, revoked or let expire. Nothing but this can correct + * that — which is exactly the argument somebody will use for calling it from an effect when the + * settings page mounts, and it is the wrong conclusion. The call this makes is spent against the + * PERSON'S OWN rate limit at the vendor, on their account, so verifying on every render would + * burn somebody's quota at Linear to redraw one word on a page they were only passing through. + * {@link confirmBrokeredConnection} is the one that runs on mount, and it asks Composio a + * question about its own records; this one goes out to the app. + * + * A RE-CHECK IS NOT A CONNECT, AND THE DIFFERENCE IS THE WHOLE METHOD. It runs against an + * account that already exists: it must not create one, it must not withdraw one when the probe + * fails — the person's account stays, it is their KEY that is wrong — and it must change + * nothing here but the verification and its timestamp. {@link connectBrokeredWithFields} does + * undo its account on a bad key, and it is right to: the account is a thing it had just made, + * seconds earlier, for a key that turned out not to work. Here the account predates the press by + * days, the person asked to have it CHECKED, and taking it away to tell them their key is wrong + * would destroy the thing they are trying to repair. The shared probe stops short of both + * behaviours for that reason. + * + * A PROBE THAT RAN AND FAILED RAISES, AND DOES NOT COME BACK AS `verified: false`. Those two + * answers are not different spellings of one outcome. `false` is also what an app that publishes + * nothing safe to call produces, and a row handed the flag alone cannot tell "the vendor + * rejected your key" from "there was nothing here to try" — so it would draw the unchecked + * sentence, and drop the Re-check button, for the one person who most needs it: somebody who has + * just fixed their key and pressed it. The refusal carries Composio's own sentence, which is the + * whole of what they can act on. The ONLY legitimate `verified: false` from here is the one that + * arrives with `probe: null` saying there was nothing to check with. + * + * NOTHING TO PROBE WRITES NOTHING AT ALL, and answers with the row as it stands. A check that + * could try nothing has learned nothing, and writing `false` on that would take the date off a + * connection verified at a consent screen — a fact nothing else in this deployment records, + * erased by a button that claims to check one. So the answer is what the row says after the + * press, and `probe` is what says whether the press was able to try anything. + * + * THE ROW IS READ BEFORE THE VENDOR IS CALLED, and its absence is a refusal. The writer below is + * an upsert, so a re-check that probed first and recorded the answer would INSERT a connection + * for somebody who has none — the row that is the whole of the gate every later brokered call + * passes through, created by a button that only asks a question. The probe itself would be spent + * on an account the vendor does not hold, and would come back "no connected account found": this + * deployment's own state, shown to somebody as though their key had been rejected. + * + * NO BROKER IS REFUSED BEFORE ANY OF IT, though nothing here calls the broker. The broker and the + * transport are built from the same key, so a deployment without one has neither — and the probe + * would come back as the transport's "Composio is not configured for this deployment", which + * this method would otherwise report as the vendor rejecting a perfectly good key, and would + * write the row unverified on the strength of it. + */ + async recheckBrokeredConnection(input: { + toolkit: string; + userId: string; + }): Promise<{ + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }> { + if (!broker) throw new BrokerUnconfiguredError(); + + const [held] = await database + .select({ + verified: composioConnections.verified, + verifiedAt: composioConnections.verifiedAt, + }) + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, input.toolkit), + eq(composioConnections.userId, input.userId), + ), + ) + .limit(1); + + if (!held) { + throw new PluginRefusedError( + `You have no connection to ${input.toolkit} here, so there is nothing to re-check. Connect it on the Plugins page and it will be checked as it is made.`, + null, + ); + } + + const { probe, failure } = await this.probeBrokeredConnection(input); + + /* + * NOTHING WAS TRIED, SO NOTHING IS WRITTEN AND NOTHING IS FILED. The row keeps whatever it + * held — a consent verification and its date, or the honest unchecked pair — and the answer + * reports that state beside the null probe that says why this press could not improve on it. + * `mcp.connection_verified` records an account exercised with a REAL CALL; a row filed here + * would make the one event that means "a key was tried" also mean "somebody pressed a button". + */ + if (probe === null) { + return { + verified: held.verified, + verifiedAt: iso(held.verifiedAt), + probe: null, + }; + } + + /* + * THE ANSWER IS WRITTEN FOR BOTH OUTCOMES, and through the single writer for its reason: the + * flag and its timestamp are one set, and a second hand spelling that set is how one of them + * comes to leave a date standing on a claim nobody is making any more. A key the vendor has + * just rejected stops being verified HERE — that is the state this button exists to correct, + * in the direction nothing else in the product can move it. + */ + const verified = failure === null; + const verifiedAt = await this.recordBrokeredConnection({ + toolkit: input.toolkit, + userId: input.userId, + verified, + }); + + /* + * AND THE TRAIL CARRIES THE CHECK, whichever way it went, filed BEFORE the refusal below for + * the reason the connect path files its own row before its throw: every way out of a failure + * is that throw, so a row written after it is a row never written. `action` is the probe that + * ran, which is what separates this from a connection nothing was ever tried on. + * + * FILED UNDER THE APP'S BARE SLUG, as every row in this family is — the same id + * `confirmBrokeredConnection`, `connectBrokeredWithFields` and `retireConnectionsFor` file + * under — so one query still answers what happened to one person's access to one app. + * + * THE ROW NAMES A PERSON AND NO BOT, because none ran: this is somebody checking their own + * account. See `mcp.connection_verified` in `./audit`, whose safety argument names a person + * re-checking their own connection as one of the two callers this call may ever have. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.userId, + action: probe, + verified, + }, + }); + + if (failure !== null) { + /* + * THE VENDOR'S OWN SENTENCE, AND THE TWO FACTS AROUND IT: the row here now says unchecked, + * and their account was left exactly as it was. The second half is what makes the retry one + * step rather than three — there is nothing to disconnect and nothing to reconnect, only a + * key to fix at the app and this button to press again. + */ + throw new PluginRefusedError( + `${input.toolkit} would not answer with the key it is holding: ${failure} Your connection here is recorded as unchecked until it does; nothing was disconnected, so fixing the key at ${input.toolkit} and pressing Re-check again is the whole of the retry.`, + null, + ); + } + + return { verified: true, verifiedAt: iso(verifiedAt), probe }; + }, + /** * End this person's brokered account at the vendor, and then forget where it was. * diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 9760c69f3..771c12db5 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -2029,3 +2029,272 @@ test("a key that works is recorded verified, with the action it was checked with // And the key itself is in none of it, the promise every write on this path keeps. expect(JSON.stringify(checked)).not.toContain(typedKey); }); + +/** + * The account a re-check runs against, which is one that ALREADY EXISTS. + * + * Inserted by hand rather than made through `connectBrokeredWithFields`, and that is the whole + * point of the fixture: a re-check is not a connect. It is pressed days later, by somebody who has + * just fixed a key at the vendor, against a row and an account that were already here — so a test + * that reached this state by connecting would be asserting about a row this run had just written + * with a probe of its own, and could not tell a method that re-checks from one that reconnects. + * + * `verifiedAt` IS A DATE FROM THE PAST WHERE ONE IS ASKED FOR, so "the timestamp was left alone" is + * an assertion about a value rather than about whether a column is null. + */ +async function holdProbedApp(verifiedAt: Date | null = null) { + await database.insert(composioConnections).values({ + toolkit: probedToolkit, + userId: askerId, + verified: verifiedAt !== null, + verifiedAt, + }); + // The vendor's side of that row: an account it is holding before this run's act, which is what + // makes "nothing was withdrawn" an assertion about what Composio still has afterwards. + vendorHolds.push(madeAccountId); +} + +/** + * A RE-CHECK THAT ANSWERS RECORDS THE CONNECTION VERIFIED, WITH THE ACTION IT WAS CHECKED WITH. + * + * CRITERION. Against a connection that already exists, the probe runs in the asking person's + * account, the row is written verified with a fresh date, the answer carries that date and the name + * of the action, and the trail records the same check. + * + * REASON. This is the button somebody presses having just rotated a key that had stopped working. + * Nothing else in the product will ever re-check it: Composio accepts a key once and never tests it + * again, and every other path that writes `verified` is a connect or a consent — so without this + * the row's sentence is frozen at whatever was true the day the key was typed, and a person who has + * fixed their key has no way to make this deployment agree. + * + * AND IT IS A BUTTON AND NEVER A PAGE-LOAD EFFECT, which is why nothing here calls it twice. The + * call is spent against the VENDOR'S rate limit on the person's own account, so verifying on every + * render would burn somebody's quota at Linear to redraw one word on a settings page. + */ +test("a re-check that answers records the connection verified, with the action it was checked with", async () => { + const sent: { + slug: string; + userId: string; + version: string; + args: unknown; + }[] = []; + useAnsweringClient({ + execute: async (call, args) => { + reached.push(call.slug); + sent.push({ + slug: call.slug, + userId: call.userId, + version: call.version, + args, + }); + return answered; + }, + }); + await addProbedApp(); + await holdProbedApp(); + // Taken before the call, so the comparison below is against a moment that cannot postdate the + // write. Both this and the column are written in this process, so no clock but one is involved. + const before = new Date(); + + const answer = await store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }); + + expect(answer.verified).toBe(true); + expect(answer.probe).toBe(probeAction); + expect(new Date(answer.verifiedAt ?? "").getTime()).toBeGreaterThanOrEqual( + before.getTime(), + ); + + // The same call the connect path makes, in the same shape: the person's own account, the version + // the listing recorded, and no arguments at all. + expect(sent).toEqual([ + { slug: probeAction, userId: askerId, version: probeVersion, args: {} }, + ]); + + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt?.toISOString()).toBe(answer.verifiedAt); + + // NOTHING WAS CONNECTED AND NOTHING WAS WITHDRAWN. The only ask in this run is the one the app's + // own enablement made; a re-check that reached `connectWithFields` would be making a second + // account for somebody who has one, and one that reached either revoke would be ending the + // account it was asked to check. + expect(asksMade()).toEqual([`ensureAuthConfig:${probedToolkit}/fields`]); + expect(vendorHolds).toEqual([madeAccountId]); + + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].targetId).toBe(probedToolkit); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: true, + }); +}); + +/** + * A PROBE THAT RAN AND FAILED IS A FAILURE, AND NOT AN ANSWER SAYING "NOT VERIFIED". + * + * CRITERION. When the vendor rejects the key, the call raises with Composio's own sentence in it, + * the row is left standing and written unverified, the account at the vendor is untouched, and the + * trail carries the action that was tried. + * + * REASON. `verified: false` is the same flag an app that publishes nothing safe to call produces, + * so a re-check that RETURNED it would hand the row two states it cannot tell apart — and the one + * it would get wrong is the person who has just fixed their key and pressed the button. The row + * would drop the Re-check button in exactly the state somebody needs it, while telling them nothing + * was ever checked. A raise carries the vendor's sentence, which is the whole of what they can act + * on. + * + * AND THE ACCOUNT STAYS, which is the line between this and a connect. `connectBrokeredWithFields` + * withdraws the account it just made, because it made it and the key is bad — the undo is of its own + * act. Here the account predates the press by days and the person did not ask to disconnect + * anything; their key is wrong, and taking their account away to tell them so would destroy the + * thing they are trying to repair. + */ +test("a re-check whose probe fails raises rather than answering unverified", async () => { + useAnsweringClient({ + execute: async ({ slug }) => { + reached.push(slug); + // The vendor reporting a failure in a 200, which is how Composio says a credential is wrong. + return { + data: {}, + error: "Invalid API key provided.", + successful: false, + }; + }, + }); + await addProbedApp(); + await holdProbedApp(); + + await expect( + store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).rejects.toThrow(/Invalid API key provided\./); + + // The key was spent on the action the chooser picked, and on nothing else. + expect(reached).toEqual([probeAction]); + // The account is still the vendor's to see, and nothing here asked it to be otherwise. + expect(vendorHolds).toEqual([madeAccountId]); + expect(asksMade()).toEqual([`ensureAuthConfig:${probedToolkit}/fields`]); + + // The row SURVIVES the failure — it is their key that is wrong, not their account — and it stops + // claiming a verification, with no date left standing on a claim nobody is making. + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row).toMatchObject({ toolkit: probedToolkit, verified: false }); + expect(row.verifiedAt).toBeNull(); + + // AND THE TRAIL SAYS WHICH ACTION WAS TRIED, which is what separates this row from an app that + // had nothing to try: both say `verified: false`, and only the name says the vendor was asked. + const checked = recordedOfType("mcp.connection_verified"); + expect(checked).toHaveLength(1); + expect(checked[0].payload).toMatchObject({ + actor: askerId, + action: probeAction, + verified: false, + }); +}); + +/** + * AN APP WITH NOTHING TO PROBE COMES BACK SAYING SO, AND THE ROW IS LEFT EXACTLY AS IT WAS. + * + * CRITERION. Where the app publishes no action a probe may use, no call is made, the answer carries + * `probe: null`, the row's `verified` and `verified_at` are the values they already held, and + * nothing reaches the trail. + * + * REASON. Null from the chooser is an ordinary answer — most key-based apps publish some + * argument-less read and PostHog publishes none — and `probe: null` is what tells the row that + * nothing was tried, which `verified: false` alone cannot. + * + * THE UNTOUCHED ROW IS THE HALF THAT WOULD BE EASY TO GET WRONG. Writing `false` here because the + * check produced no evidence would take the date off a connection that was verified at a consent + * screen — a press of a button erasing a fact nothing else in this deployment records, and telling + * the person their working connection is now unchecked. A check that could try nothing has learned + * nothing, and the honest write is no write at all. + */ +test("an app with nothing to probe leaves the verification exactly as it was", async () => { + useAnsweringClient(); + await addProbedApp({ withProbe: false }); + // Verified a fortnight ago, at a consent screen or by a probe this app has since stopped + // publishing. Either way it is a fact, and this press must not be what takes it off the row. + const earned = new Date("2026-08-30T09:00:00.000Z"); + await holdProbedApp(earned); + + expect( + await store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).toEqual({ + verified: true, + verifiedAt: earned.toISOString(), + probe: null, + }); + + // Nothing was called, which is what "nothing to try" means at the vendor. + expect(reached).toEqual([]); + const [row] = await database + .select() + .from(composioConnections) + .where( + and( + eq(composioConnections.toolkit, probedToolkit), + eq(composioConnections.userId, askerId), + ), + ); + expect(row.verified).toBe(true); + expect(row.verifiedAt?.toISOString()).toBe(earned.toISOString()); + // And nothing is on the trail: `mcp.connection_verified` records an account exercised with a real + // call, and no call was made. A row filed for a press that changed nothing would make the one + // event that means "a key was tried" also mean "somebody looked at a page". + expect(recordedOfType("mcp.connection_verified")).toEqual([]); +}); + +/** + * A RE-CHECK WITH NO CONNECTION TO CHECK REFUSES, AND MAKES NEITHER A ROW NOR A CALL. + * + * CRITERION. Where this person holds no account for the app, the call raises, no row is written, + * and nothing is spent at the vendor. + * + * REASON. The single writer this path records through is an UPSERT, so a re-check that probed + * first and wrote the answer would INSERT a connection for somebody who has none — a row that is + * the whole of the gate every later brokered call passes through, created by a button that claims + * to check one. And the probe itself would be spent on an account the vendor does not have, coming + * back as "no connected account found": a sentence about this deployment's own state, shown to + * somebody as though their key had been rejected. + */ +test("a re-check with no connection refuses rather than making one", async () => { + useAnsweringClient(); + await addProbedApp(); + + await expect( + store.recheckBrokeredConnection({ + toolkit: probedToolkit, + userId: askerId, + }), + ).rejects.toThrow(); + + expect(await connectedToolkitsFor(askerId)).toEqual([]); + expect(reached).toEqual([]); + expect(recordedOfType("mcp.connection_verified")).toEqual([]); +}); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 3dc9f7c9e..75433b59f 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1306,6 +1306,8 @@ function brokeredApp( }> = []; const queried: Array<{ toolkit: string; userId: string }> = []; const confirmed: Array<{ toolkit: string; userId: string }> = []; + /** Every re-check that reached the store, so who it was made about is an assertion. */ + const rechecked: Array<{ toolkit: string; userId: string }> = []; const disconnected: Array<{ toolkit: string; userId: string; @@ -1398,6 +1400,26 @@ function brokeredApp( if (deployment.storeThrows) throw deployment.storeThrows; return { connected: connection !== null }; }, + recheckBrokeredConnection: async (input: { + toolkit: string; + userId: string; + }) => { + rechecked.push(input); + // Thrown from where the store raises it in the product: a probe that ran and was refused is a + // {@link PluginRefusedError}, and a broker that would not answer at all is the vendor's own + // object one layer down. Both leave this method the same way — by throwing. + if (deployment.storeThrows) throw deployment.storeThrows; + /* + * All three fields, because all three are what the row reads. `verifiedAt` is the date the + * sentence is drawn from and `probe` is what says a call was really made, which `verified` + * alone cannot. + */ + return { + verified: true, + verifiedAt: "2026-09-13T10:00:00.000Z", + probe: "LINEAR_GET_ME", + }; + }, connectBrokeredWithFields: async (input: { toolkit: string; userId: string; @@ -1475,6 +1497,7 @@ function brokeredApp( authorized, queried, confirmed, + rechecked, disconnected, asked, submitted, @@ -1529,6 +1552,23 @@ function brokeredApp( body: JSON.stringify(options.body ?? {}), }, ), + /* + * The button beside them, and the one that is never pressed by a page. + * + * Given the same open inputs as the two below — the row, a body and a query — because it is the + * same identity question: a re-check spends a call on somebody's own account at the vendor, so a + * caller who could name a person would be spending a stranger's rate limit and rewriting the + * verification on their row. + */ + recheck: (options: Caller = {}) => + app.request( + `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection/recheck${options.query ?? ""}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body ?? {}), + }, + ), disconnect: (options: Caller = {}) => app.request( `http://openbot.test/api/plugins/servers/${options.serverId ?? "composio-linear"}/connection${options.query ?? ""}`, @@ -2120,3 +2160,114 @@ describe("confirming and ending a brokered connection", () => { expect(disconnectRefusal).not.toContain("socket hang up"); }); }); + +/** + * Re-checking a brokered connection, which is a button and never a page load. + * + * CRITERION. The route acts on the connection of the person whose session made the request whatever + * a body or a query says; it refuses a row that is not brokered and a deployment with no broker in + * the same words the other two do; and a probe that RAN AND FAILED reaches the browser as a failure + * carrying the vendor's sentence rather than as a 200 saying the connection is not verified. + * + * REASON. The store decides what a re-check writes and what it refuses; what is left here is the + * routing, and two things about it would do damage. A user id taken from a caller would spend a + * stranger's rate limit at the vendor and rewrite the verification on their row from one POST. And + * an answer of `{ verified: false }` for a probe that ran and was refused would be + * indistinguishable, to the row drawing it, from an app that publishes nothing to check against — + * so the Re-check button would quietly disappear for the one person who most needs it, the one who + * has just fixed their key. + * + * IT IS NOT CALLED ON MOUNT, unlike confirm, and nothing here calls it twice. Composio never + * re-checks a key by itself, so this is the only thing that can; but the call is spent against the + * person's own quota at the vendor, and verifying on every render would burn it to redraw one word. + */ +describe("re-checking a brokered connection", () => { + test("the re-check is made about the session's own person, whatever the caller says", async () => { + const { rechecked, recheck } = brokeredApp({ + connectedAt: "2026-02-02T00:00:00.000Z", + }); + + const response = await recheck({ + body: { userId: SOMEBODY_ELSE.id }, + query: `?userId=${SOMEBODY_ELSE.id}`, + }); + + expect(response.status).toBe(200); + // The store's answer, passed through: what was checked, when, and with which action. + expect(await response.json()).toEqual({ + verified: true, + verifiedAt: "2026-09-13T10:00:00.000Z", + probe: "LINEAR_GET_ME", + }); + expect(rechecked).toEqual([{ toolkit: "linear", userId: ADMIN.id }]); + }); + + test("a probe that ran and failed is a failure, not an answer saying not verified", async () => { + /* + * THE MUST-NOT CASE OF THIS ROUTE. The store raises for a key the vendor rejected, and the + * sentence it raises with carries Composio's own words — so the route has to pass it through as + * a refusal. Catching it and answering `{ verified: false }` instead would lose the sentence and + * hand the row a flag it cannot read: the same `false` an app with nothing to probe produces. + */ + const { recheck } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { + storeThrows: new PluginRefusedError( + "Linear would not answer with the key it is holding: Invalid API key provided. Your connection is recorded here as unchecked until a key that works is entered.", + null, + ), + }, + ); + + const response = await recheck(); + const body = await response.text(); + + expect(response.status).toBe(400); + expect(JSON.parse(body).error).toContain("Invalid API key provided."); + // And it is a refusal rather than an answer: nothing in it for a row to read as a verification. + expect(JSON.parse(body).verified).toBeUndefined(); + }); + + test("an app that is not brokered is refused for what is actually wrong", async () => { + // `notion` is a row this deployment really has; what is wrong is that its connection does not + // live at Composio, so there is nothing here to re-check. Same sentence as the other two. + const { rechecked, recheck } = brokeredApp(); + + const response = await recheck({ serverId: "notion" }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toBe( + "That app is not reached through a broker.", + ); + expect(rechecked).toEqual([]); + }); + + test("a deployment with no broker is told which setting to set", async () => { + const { rechecked, recheck } = brokeredApp(null, null); + + const response = await recheck(); + + expect(response.status).toBe(503); + expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + expect(rechecked).toEqual([]); + }); + + test("a broker that throws reaches the browser as Composio's own sentence", async () => { + // The other half of the failure mapping: a vendor object is not a refusal this deployment + // authored, so it comes back as the vendor's sentence at 502 — and nothing else that travelled + // with it, which is a request id nobody outside Composio can act on. + const { recheck } = brokeredApp( + { connectedAt: "2026-02-02T00:00:00.000Z" }, + AUTHORIZATION_URL, + { storeThrows: WRONG_KEY }, + ); + + const response = await recheck(); + const body = await response.text(); + + expect(response.status).toBe(502); + expect(JSON.parse(body).error).toBe("Invalid API key provided."); + expect(body).not.toContain("req_a_trace_id_nobody_should_read"); + }); +}); From 9b1878b6175ec8f3232b0306d671b5b76c40ee8d Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 17:11:02 +0200 Subject: [PATCH 203/215] Claim no vendor revocation for a key, whose value the disconnect leaves live at the app Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- server/src/plugins/store.ts | 54 +++++++++++- server/tests/composio-connections.test.ts | 103 +++++++++++++++++++++- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index eeb10300f..1e5469e88 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4771,6 +4771,25 @@ export function createPluginStore(options: PluginStoreOptions) { * that the provider has been asked, because the withdrawal itself runs as a background job with * no supported way to poll it. * + * EXCEPT WHERE THE APP IS ONE SOMEBODY TYPED A KEY INTO, AND THERE IT IS FALSE BY CONSTRUCTION + * RATHER THAN BY WHAT THE VENDOR FOUND. `revoke_on_delete` asks the PROVIDER to end a grant, + * which is a real request for a consent account — Google or Slack acts on it — and a + * meaningless one for an API key. There is no grant behind a key to withdraw: the value is + * still valid at the app and still works for anyone holding it, so the account ends at Composio + * and nothing was asked of anybody else. Saying otherwise would be the one row in this trail + * nobody could rely on, which is the failure the paragraph above describes arriving a second + * time by a different road — a withdrawal recorded for something that was never granted. So the + * scheme recorded on the app's row decides this field for a field connection, and the broker's + * own answer decides it for every other. + * + * THE SCHEME IS THE ONE ON THE APP'S ROW, for {@link connectBrokeredWithFields}' reason: it is + * what this deployment's authorization config was created AS and what the account was attached + * to, and a fresh read of the catalogue is a second answer — a key connection described as + * consent because the vendor has since started publishing managed OAuth for the app. The + * person's half of this fact is already written on the disconnect row they are shown: their key + * still works at the app, and rotating it there is what ends it. This is the trail's half of + * the same sentence. + * * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer * and never the account itself (see {@link brokeredConnection}), so its absence is not * evidence that the grant is gone: the confirm above deletes it on any `false` from the @@ -4797,6 +4816,12 @@ export function createPluginStore(options: PluginStoreOptions) { * account was ended; the event is filed for that as well. Only where both are absent is there * no act to record, and the two cases stay legible in the trail because the field still says * which of them happened. + * + * SO THE FILING IS DECIDED ON THE BROKER'S OWN ANSWER AND NOT ON THE FIELD, because for a field + * connection the two part company on purpose. A key account the vendor found and ended with no + * row here is an act — somebody's live connection stopped existing — and gating the event on a + * value that is false by construction would leave exactly that act unrecorded. The field + * answers what was asked of the provider; `ended` answers whether anything was there. */ async disconnectBrokered(input: { toolkit: string; @@ -4814,11 +4839,36 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise<{ vendorRevocationRequested: boolean }> { if (!broker) throw new BrokerUnconfiguredError(); - const vendorRevocationRequested = await broker.revoke({ + /* + * Keyed on the url, which is where a brokered row records which app it is; `mcp_servers.id` + * is a display name and nothing holds the two equal. It is the lookup + * {@link connectBrokeredWithFields} makes, for the same stake: a row called `gmail` at + * `composio://slack` would have this disconnect reading Gmail's scheme to describe what + * happened to a Slack account. + * + * AN APP WITH NO ROW HERE IS NOT A FIELD APP. A person can hold an account at Composio for + * an app this deployment has since removed — the row is a cache and the removal takes no + * grant with it — and the revoke below is the one operation that can still end it. Nothing + * names the scheme it was connected under any more, so the honest reading is the broker's + * own answer, which is what an absent row falls through to. + */ + const [app] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.url, `composio://${input.toolkit}`)) + .limit(1); + const fieldScheme = isFieldScheme(app?.authScheme ?? null); + + // Whether there was an account to end at all, which is what decides if anybody was + // disconnected. Named apart from the field below because for a key the two differ: something + // ended, and nothing was asked of the provider. + const ended = await broker.revoke({ userId: input.userId, toolkit: input.toolkit, }); + const vendorRevocationRequested = fieldScheme ? false : ended; + // `returning` because whether a row was here is half of what decides if anybody was // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. const [deleted] = await database @@ -4831,7 +4881,7 @@ export function createPluginStore(options: PluginStoreOptions) { ) .returning({ toolkit: composioConnections.toolkit }); - if (deleted || vendorRevocationRequested) { + if (deleted || ended) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 771c12db5..17333006a 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -34,9 +34,11 @@ import { TEST_POOL } from "./support/database"; * when somebody is offboarded, `removeServer` when the app itself is taken away, and * `disconnectBrokered` when a person ends their own account. This file is about the two an * administrator performs on somebody else's behalf, and about the trail those two leave; - * `disconnectBrokered` has its own coverage in `plugin-store.integration.test.ts`, which is why the - * two here are described throughout as the two ACTS AN ADMINISTRATOR PERFORMS and never as all the - * ways a connection can end. + * `disconnectBrokered` has its coverage in `plugin-store.integration.test.ts`, which is why the two + * here are described throughout as the two ACTS AN ADMINISTRATOR PERFORMS and never as all the ways + * a connection can end. It is asked about here in one place only — the test at the foot of this + * file, where what the trail may claim is decided by the app's recorded scheme, and so needs an app + * that arrived the way Add makes one arrive and a key that arrived the way a person types one. * * WHY THIS FILE OWNS ITS IDS OUTRIGHT, AND SO NEEDS NO REFUSE-TO-RUN GUARD. * `plugin-store.integration.test.ts` inserts at `gmail`, `notion` and `bot_helper` and refuses to @@ -2298,3 +2300,98 @@ test("a re-check with no connection refuses rather than making one", async () => expect(reached).toEqual([]); expect(recordedOfType("mcp.connection_verified")).toEqual([]); }); + +/** + * DISCONNECTING A KEY CLAIMS NO REVOCATION, BECAUSE A KEY HAS NO GRANT BEHIND IT TO WITHDRAW. + * + * CRITERION. Ending a connection somebody typed a key into answers `vendorRevocationRequested: + * false` and files a trail row saying false — while the vendor is still asked, still finds the + * account, and still ends it; and while the SAME app under a consent scheme, disconnected the same + * way against the same vendor answer, says true. + * + * REASON. `revoke_on_delete` asks the PROVIDER to end a grant. For a consent connection that is a + * real request Google or Slack acts on, and the field says a withdrawal was asked for. For a key + * there is no grant: the value is still valid at the app and still works for anyone holding it, so + * the account ends at Composio and nothing was asked of anybody else. `ComposioBroker.revoke`'s own + * doc argues that "requested" is as far as any implementation can honestly go and that the whole + * worth of the boolean is letting a reader tell an account this deployment acted on from one that + * outlives it somewhere else — a key reporting true would be the one row in that trail nobody could + * rely on, a withdrawal recorded for something nobody ever granted. The person is already told the + * other half of this fact in their own words on the disconnect row: their key still works at the + * app, and rotating it there is what ends it. + * + * WHY BOTH SCHEMES IN ONE TEST. The vendor here answers `true` to every revoke, so the false above + * is the implementation's doing and not the stub's — and the second half is what separates a field + * this deployment computes from a constant of either polarity. One app rather than two, re-enabled + * onto the other flow with nobody connected, so the ONLY thing that differs between the two acts is + * the scheme recorded on the row. + */ +test("disconnecting a key claims no revocation", async () => { + useAnsweringClient(); + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "fields", authScheme: "API_KEY" }, + }); + await store.connectBrokeredWithFields({ + toolkit: enabledToolkit, + userId: askerId, + values: { generic_api_key: typedKey }, + }); + + expect( + await store.disconnectBrokered({ + toolkit: enabledToolkit, + userId: askerId, + by: askerId, + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: false }); + + /* + * AND THE ACCOUNT DID END AT COMPOSIO, which is the half of the sentence the false must not be + * allowed to swallow. The broker was asked, it answered that it found an account — {@link + * vendorFinds} is true for everybody here — and it is holding nothing afterwards. Without these + * the assertion above would be satisfied just as well by a disconnect that skipped the revoke + * and left somebody's key attached at the vendor with no row here pointing at it. + */ + expect(asksMade()).toEqual([ + `ensureAuthConfig:${enabledToolkit}/fields`, + `connectWithFields:${enabledToolkit}/${askerId}`, + `revoke:${enabledToolkit}/${askerId}`, + ]); + expect(vendorHolds).toEqual([]); + expect(await connectedToolkitsFor(askerId)).toEqual([]); + + // THE SAME APP AND THE SAME ACT, with the scheme moved underneath it. Re-enabling may rewrite the + // column because the disconnect above left nobody connected to be stranded by it. + await store.addBrokeredApp({ + slug: enabledToolkit, + title: "Enablable App", + by: admin, + connection: { kind: "consent" }, + }); + await database + .insert(composioConnections) + .values({ toolkit: enabledToolkit, userId: askerId }); + + expect( + await store.disconnectBrokered({ + toolkit: enabledToolkit, + userId: askerId, + by: askerId, + reason: "self", + }), + ).toEqual({ vendorRevocationRequested: true }); + + // Read in the order the two acts happened, which is what makes the pair an assertion about the + // scheme rather than two separate assertions about a boolean. + expect( + recordedOfType("mcp.account_disconnected").map( + (event) => + (event.payload as { vendorRevocationRequested: boolean }) + .vendorRevocationRequested, + ), + ).toEqual([false, true]); +}); From 537ddf720d08da47fb9014a4e982f57b7974a5e3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 13 Sep 2026 17:14:30 +0200 Subject: [PATCH 204/215] Say which of the three a key's missing check is, and offer the re-check where it is bad Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P2mm9gARmD13tnasEztb4x --- .../plugins/brokered-account-row.tsx | 103 +++++++++-- app/src/lib/plugins/mutations.ts | 18 +- app/tests/brokered-account-row.test.tsx | 174 +++++++++++++++++- 3 files changed, 266 insertions(+), 29 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 17b02b648..d6defeac9 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -90,8 +90,9 @@ export type BrokeredAccount = { * * AND `false` IS A PLACEHOLDER AS MUCH AS A VERDICT. The store writes it unconditionally on every * key connection, whether or not the app publishes anything to check a key against, so nothing - * here may explain the `false` either. Three states share this one word — never tried, tried and - * failed, nothing to try — and they stay unsaid until the server sends the reason. + * here may explain the `false` off this field. Three states share this one word — nothing to try, + * tried and passed, tried and refused — and {@link BrokeredAccount.probe} is what tells them + * apart. Read the two together or not at all. */ verified: boolean; /** @@ -102,6 +103,29 @@ export type BrokeredAccount = { * about a key that may have been revoked at the vendor an hour ago. */ verifiedAt: string | null; + /** + * The action that check was spent on, as the last answer named it. + * + * THE THREE STATES BEHIND `verified` ARE THIS FIELD'S DOING, and the sentences below are written + * off it rather than off the flag: + * + * `null` — the answer said this app publishes nothing safe to spend a key on. Nothing was + * tried and nothing can be, which is a fact about the app and not about the key. + * a name, verified — the action ran in this person's account and the vendor took the key. + * a name, NOT verified — it ran, the vendor refused the key, and the account it had just made + * could not be withdrawn. The row exists, the key is bad, and something of theirs + * is standing at Composio. This is the worst state the feature has. + * `undefined` — NOT A FOURTH VERDICT BUT THE ABSENCE OF ONE. No answer has been given here, + * which is every first render: the connections read carries the flag and the date + * and has no column for this, so a page load knows whether a check passed and + * never what it was spent on, nor whether there was anything to spend one on. + * Nothing may read it as either of the two answers the server actually sends. + * + * Carried out of the answers rather than out of the recorded row, because only the answers have + * it: a re-check and a key handed over both come back naming the action, and neither writes the + * name anywhere a later read could find it. + */ + probe: string | null | undefined; /** * Whether this deployment has a Composio key at all. * @@ -333,6 +357,16 @@ export function useBrokeredAccount(input: { }, }); + /* + * The freshest thing either check has said about this key, or nothing at all. + * + * Two answers name a probe — a re-check, and a key just handed over — and the newer of the two + * wins for the reason `verified` below gives: an answer beats the record, and these two cannot + * both be new. A submission clears the re-check's answer as it lands, and opening the form clears + * the submission's, so whichever is present is the one that was actually last said. + */ + const answered = recheck.data ?? submission.data; + return { /* * What the vendor last answered, and only our own record until it has answered anything. The @@ -352,6 +386,14 @@ export function useBrokeredAccount(input: { */ verified: recheck.data ? recheck.data.verified : verified, verifiedAt: recheck.data ? recheck.data.verifiedAt : verifiedAt, + /* + * The name off whichever answer is newest, and UNDEFINED WHERE THERE IS NONE — not null, which + * is a thing the server says and this deployment would be inventing. See + * {@link BrokeredAccount.probe}: a row that reported "nothing to check with" on every page load + * would tell somebody their app publishes no probe on the strength of never having asked, and + * would take away the button they reach for having just fixed their key. + */ + probe: answered ? answered.probe : undefined, recheck: () => { report(null); recheck.mutate(serverId); @@ -452,16 +494,34 @@ function accountSentence(input: { return `Connected with a key you provided, last checked ${formatDate(account.verifiedAt)}.`; } /* - * NO REASON GIVEN, BECAUSE THIS ROW HAS NOT BEEN TOLD ONE. The sentence here used to explain - * the missing check as a fact about the app — that it publishes nothing to try a key on — - * and the store writes `verified: false` on every key connection it makes, publisher or not. - * So the row was asserting that Perplexity publishes nothing checkable while Perplexity - * publishes a perfectly good probe. What is true of all three states behind that one word is - * only that the key was taken and nothing has tried it, so that is the whole of what is said. + * THE KEY WAS CHECKED AND THE VENDOR REFUSED IT, and the account it was checked in could not + * be withdrawn — the one state where the sentence below was not vague but FALSE. Three facts + * are this person's to act on and all three go in: their key is bad, an account of theirs is + * live at Composio, and this deployment tried to take it back and could not. Reachable only + * from the answer to a key just handed over; a re-check the vendor refuses is raised instead, + * and the banner and the dialog carry Composio's own sentence for it. * - * THE FOLLOW-UP IS THE VERIFICATION PROBE, not a bigger sentence here: three states need the - * server to say WHICH — never tried, tried and failed, nothing to try — and the reason - * arrives with the probe that lands next. See {@link BrokeredAccount.verified}. + * BOTH WAYS OUT ARE NAMED, because neither is obvious from a row that says "Connected": the + * account ends with the button beside this line, and a key corrected at the vendor is worth a + * second check rather than a second connection. + */ + if (account.probe && !account.verified) { + return `Your key was checked against ${title} and rejected. The account it was checked in still stands at Composio — this deployment could not withdraw it — so disconnect it here, or fix the key at ${title} and press Re-check.`; + } + /* + * NOTHING TO CHECK IT WITH, WHICH IS A FACT ABOUT THE APP. The answer named no probe at all: + * this app publishes no action safe to spend somebody's key on, so the check was not skipped + * and cannot be made. Said plainly because the alternative reading — that this deployment + * doubts the key — is the one a person supplies for themselves when a row goes quiet. + */ + if (account.probe === null) { + return `Connected with a key you provided. It was accepted without being checked against ${title}, which publishes nothing safe to try a key on — that is about the app, not about your key.`; + } + /* + * AND NO ANSWER HERE HAS SAID WHICH, which is every page load: the connections read carries + * the flag and the date and not the probe, so all this row knows is that the key was taken and + * that nothing it has been told about has tried it. The two sentences above are the two things + * an answer can say; this is what stands until one does, and it must not borrow either. */ return `Connected with a key you provided. It was accepted without being checked against ${title}.`; } @@ -609,17 +669,20 @@ export function BrokeredAccountRow({ Connected {/* * OFFERED ONLY ON A KEY APP, BECAUSE THAT IS THE ONLY KIND A RE-CHECK IS AN ACT ON. - * `verified` on its own is not that question and never was — see - * {@link BrokeredAccount.verified}: a consent connection is written verified by the - * confirm, with no probe behind it, and migration 0030 backfilled every consent row - * that came before. The flag alone therefore drew Re-check on every connected Gmail - * a deployment already had, where pressing it could only fail. + * A consent connection is written verified by the confirm, with no probe behind it, + * and migration 0030 backfilled every consent row that came before — see + * {@link BrokeredAccount.verified} — so a gate that asked only about a check would + * draw Re-check on every connected Gmail a deployment already had, where pressing it + * could only fail. * - * THE FLAG STILL COUNTS BESIDE THE KIND. A key nothing has ever checked has no check - * to repeat, so there is nothing to press and the sentence above says where that - * leaves it — without claiming to know why, which this row is not told. + * AND THE SECOND HALF ASKS WHETHER THERE IS ANYTHING TO CHECK WITH, WHICH IS NOT + * "HAS A CHECK PASSED". Gating on `verified` withheld the button in the one state + * somebody reaches for it hardest: a key the vendor has just rejected, which they + * have gone and corrected and now want tried again. A null probe is the only state + * with nothing to press — the app publishes nothing to spend the key on, so the + * button could only ask for the same answer again. */} - {account.kind === "fields" && account.verified ? ( + {account.kind === "fields" && account.probe !== null ? (