diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5bf4fe5..7eaee412e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,26 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +### Bot turns hold their conversation more carefully + +Routines, responsibilities, background research, wake-ups, group turns and Bot-to-Bot handoffs keep a +lock on the conversation while they run and renew it as they go. + +- **A failed renewal no longer stops the turn.** Before, any failed renewal stopped a routine or + responsibility turn, including a network blip. A stopped turn is retried from the start, so a Bot + that had already sent an email could send it again. Now a turn stops when Intelligence says the + conversation belongs to another run (409), or when renewals have failed for so long that the lock + (120 seconds) would lapse before the next one, whatever the failures were. It stops while the lock + is still its own. A shorter blip rides through. +- **A handoff that loses its lock now stops.** Before, a handoff ignored every failed renewal and + kept writing, even into a person's own channel after they started typing there. Now it stops, gives + the conversation back and is retried a minute later. +- **A paused Bot's handoff no longer locks the conversation.** A handoff now checks that the Bot is + not paused, and that "Use Bots" and the model allowlist allow it, before taking the lock. Before, + every retry took the lock briefly and could refuse the person's own message. +- **Routine turns ask for a 120-second lock.** This is the lifetime Intelligence already applies, so + nothing changes on today's platform. + ### A sign-in's verification code is asked for after the password The private sign-in form used to ask for a username, a password and "a verification code, if the diff --git a/Dockerfile b/Dockerfile index 2f2e0ddaf..0152296c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,8 @@ COPY tsconfig.base.json bunfig.toml ./ COPY app/package.json app/package.json COPY server/package.json server/package.json COPY worker/package.json worker/package.json +COPY shared/package.json shared/package.json +COPY toolkit/package.json toolkit/package.json RUN bun install --frozen-lockfile # The lockfile travels with the manifest, because `--frozen-lockfile` with no lockfile in the context @@ -68,10 +70,12 @@ 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/ \ - && cd /prod && mkdir -p app server worker \ + && cd /prod && mkdir -p app server worker shared toolkit \ && cp /src/app/package.json app/package.json \ && cp /src/server/package.json server/package.json \ && cp /src/worker/package.json worker/package.json \ + && cp /src/shared/package.json shared/package.json \ + && cp /src/toolkit/package.json toolkit/package.json \ && bun install --frozen-lockfile --production @@ -80,6 +84,7 @@ FROM deps AS app-build COPY app app COPY scripts scripts COPY shared shared +COPY toolkit toolkit # The server's source as well: the app's prebuild step reads the tenant package through # `server/src/tenant-package`, so the app cannot be built without it. COPY server server @@ -121,6 +126,7 @@ WORKDIR /app # present and broken. COPY --from=deps /prod/node_modules node_modules COPY --from=deps /prod/server/node_modules server/node_modules +COPY --from=deps /prod/toolkit/node_modules toolkit/node_modules COPY --from=deps /src/package.json package.json COPY --from=deps /src/bun.lock bun.lock # The browser's tree is a separate install root with its own lockfile rather than a workspace of the @@ -130,6 +136,7 @@ COPY --from=deps /src/agent-computer/node_modules agent-computer/node_modules COPY server server COPY shared shared +COPY toolkit toolkit COPY examples examples COPY agent-computer/src agent-computer/src COPY agent-computer/package.json agent-computer/package.json diff --git a/app/package.json b/app/package.json index bc415c753..f741d78e5 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "@copilotkit/a2ui-renderer": "1.77.0", "@copilotkit/react-core": "1.77.0", "@fontsource-variable/inter": "^5.3.0", + "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-form": "^1.33.5", diff --git a/app/serve.ts b/app/serve.ts index e9666d4f6..a1e006b2b 100644 --- a/app/serve.ts +++ b/app/serve.ts @@ -14,9 +14,9 @@ */ import { join, normalize, sep } from "node:path"; +import { DICTATION_HTTP_IDLE_SECONDS } from "@openbot/shared/dictation"; +import { listenPort } from "@openbot/shared/listen-port"; import { file, type ServerWebSocket } from "bun"; -import { listenPort } from "../shared/listen-port"; -import { DICTATION_HTTP_IDLE_SECONDS } from "../shared/dictation"; const DIST = join(import.meta.dir, "dist"); const appPort = listenPort(process.env.APP_PORT, 3010); diff --git a/app/src/lib/channels/attachments.ts b/app/src/lib/channels/attachments.ts index 687f5e1e8..68c20fbb6 100644 --- a/app/src/lib/channels/attachments.ts +++ b/app/src/lib/channels/attachments.ts @@ -21,4 +21,4 @@ export { mediaTypeOf, namesNoFormat, shouldClaimPaste, -} from "../../../../shared/attachments"; +} from "@openbot/shared/attachments"; diff --git a/app/src/lib/channels/responsibility-turn.ts b/app/src/lib/channels/responsibility-turn.ts index 9fd251d67..5f9dc2a68 100644 --- a/app/src/lib/channels/responsibility-turn.ts +++ b/app/src/lib/channels/responsibility-turn.ts @@ -4,4 +4,4 @@ * See `routine-firing.ts` beside this file: the server builds the message from `shared/`, and the * browser keeps importing through `@/`. */ -export { readResponsibilityTurn } from "../../../../shared/responsibility-turn"; +export { readResponsibilityTurn } from "@openbot/shared/responsibility-turn"; diff --git a/app/src/lib/channels/routine-firing.ts b/app/src/lib/channels/routine-firing.ts index 9d98e840b..a79a38cde 100644 --- a/app/src/lib/channels/routine-firing.ts +++ b/app/src/lib/channels/routine-firing.ts @@ -5,4 +5,4 @@ * once. This file exists so the browser code keeps importing through `@/`, and so the path to * `shared/` is written down once rather than in every renderer. */ -export { readFiring } from "../../../../shared/routine-firing"; +export { readFiring } from "@openbot/shared/routine-firing"; diff --git a/app/src/lib/computers/control.ts b/app/src/lib/computers/control.ts index 18f4d4cd3..9b1a5325a 100644 --- a/app/src/lib/computers/control.ts +++ b/app/src/lib/computers/control.ts @@ -11,8 +11,8 @@ import { tryClient } from "@/lib/client"; * should say nothing, not tear down the screen the person is looking at. */ -export type { ComputerControlState as ControlState } from "../../../../shared/computer-control"; -import type { ComputerControlState as ControlState } from "../../../../shared/computer-control"; +export type { ComputerControlState as ControlState } from "@openbot/shared/computer-control"; +import type { ComputerControlState as ControlState } from "@openbot/shared/computer-control"; async function callControl( computerId: string, diff --git a/app/src/lib/computers/handoff.ts b/app/src/lib/computers/handoff.ts index 2de57684f..86c44d2f5 100644 --- a/app/src/lib/computers/handoff.ts +++ b/app/src/lib/computers/handoff.ts @@ -1,7 +1,7 @@ import type { BrowserChallenge, HandoffRequest, -} from "../../../../shared/computer-control"; +} from "@openbot/shared/computer-control"; import { callComputer, type ToolOutcome } from "./call"; import { cancelControl, readControl } from "./control"; diff --git a/app/src/lib/copilot/gallery-tools.tsx b/app/src/lib/copilot/gallery-tools.tsx index 82ac1801c..7adfbc77b 100644 --- a/app/src/lib/copilot/gallery-tools.tsx +++ b/app/src/lib/copilot/gallery-tools.tsx @@ -15,10 +15,7 @@ import { type GalleryComponent, galleryManifest, } from "@/lib/copilot/gallery-registry"; -import { - NOT_SHOWN, - NOT_SHOWN_TAIL, -} from "../../../../shared/component-markers"; +import { NOT_SHOWN, NOT_SHOWN_TAIL } from "@openbot/shared/component-markers"; /** * Register compiled gallery components once per name, scoped to the active Bot with `available`. diff --git a/app/src/lib/copilot/markers.ts b/app/src/lib/copilot/markers.ts index 696d0ee7a..1f41baddf 100644 --- a/app/src/lib/copilot/markers.ts +++ b/app/src/lib/copilot/markers.ts @@ -5,4 +5,4 @@ * file exists so the browser code keeps importing through `@/`, and so the path to `shared/` is * written down once rather than in every renderer. */ -export { HANDED_OVER, PUT_TO } from "../../../../shared/handoff-markers"; +export { HANDED_OVER, PUT_TO } from "@openbot/shared/handoff-markers"; diff --git a/app/src/lib/dictation/recording.ts b/app/src/lib/dictation/recording.ts index 4566565ae..54140e829 100644 --- a/app/src/lib/dictation/recording.ts +++ b/app/src/lib/dictation/recording.ts @@ -3,7 +3,7 @@ import { dictationExtension, dictationMediaType, MAX_DICTATION_BYTES, -} from "../../../../shared/dictation"; +} from "@openbot/shared/dictation"; export interface Recording { /** Shared with the visualizer; the recorder owns this stream's lifetime. */ diff --git a/app/src/lib/dictation/session.ts b/app/src/lib/dictation/session.ts index 589bb2ba2..78f1bb138 100644 --- a/app/src/lib/dictation/session.ts +++ b/app/src/lib/dictation/session.ts @@ -1,4 +1,4 @@ -import { MAX_DICTATION_SECONDS } from "../../../../shared/dictation"; +import { MAX_DICTATION_SECONDS } from "@openbot/shared/dictation"; import type { Recording } from "./recording"; export type DictationState = { diff --git a/app/src/lib/learning/queries.ts b/app/src/lib/learning/queries.ts index 1e79f30ca..ba9fc27d4 100644 --- a/app/src/lib/learning/queries.ts +++ b/app/src/lib/learning/queries.ts @@ -5,7 +5,7 @@ import type { LearningDeliveryStatus, LearningOverview, LearningSettings, -} from "../../../../shared/learning"; +} from "@openbot/shared/learning"; export const learningKeys = { all: ["learning"] as const, diff --git a/app/src/lib/settings/message-list.ts b/app/src/lib/settings/message-list.ts index 1516bdbb5..f8f155c72 100644 --- a/app/src/lib/settings/message-list.ts +++ b/app/src/lib/settings/message-list.ts @@ -6,10 +6,10 @@ import { } from "@tanstack/react-query"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { client } from "@/lib/client"; -import type { UserPreferences } from "../../../../shared/user-preferences"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; import { settingsKeys } from "./queries"; -export type { MessageListEmphasis } from "../../../../shared/user-preferences"; +export type { MessageListEmphasis } from "@openbot/shared/user-preferences"; export function userPreferencesQueryOptions(userId: string | undefined) { return queryOptions({ diff --git a/app/src/lib/voice/archive.ts b/app/src/lib/voice/archive.ts index 7194ebaf1..6739ca46b 100644 --- a/app/src/lib/voice/archive.ts +++ b/app/src/lib/voice/archive.ts @@ -7,7 +7,7 @@ import type { SaveVoiceSessionInput, VoiceSessionPage, VoiceSessionRecord, -} from "../../../../shared/voice-session"; +} from "@openbot/shared/voice-session"; import { closingOf } from "./cut"; import { forgetVoiceCall, diff --git a/app/src/lib/voice/outbox.ts b/app/src/lib/voice/outbox.ts index 38035afdd..bd54b2d15 100644 --- a/app/src/lib/voice/outbox.ts +++ b/app/src/lib/voice/outbox.ts @@ -1,4 +1,4 @@ -import type { SaveVoiceSessionInput } from "../../../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { authKeys, type AuthenticatedUser } from "@/lib/auth/queries"; import { queryClient } from "@/query-client"; diff --git a/app/src/lib/voice/session.ts b/app/src/lib/voice/session.ts index 05abab625..eceb17bba 100644 --- a/app/src/lib/voice/session.ts +++ b/app/src/lib/voice/session.ts @@ -1,4 +1,4 @@ -import type { SaveVoiceSessionInput } from "../../../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { closingOf, openingOf } from "./cut"; import { VOICE_CONTEXT_ITEM_ID, VoiceTranscript } from "./transcript"; import type { ConnectVoice, VoiceConnection, VoiceEvent } from "./types"; diff --git a/app/src/lib/voice/transcript.ts b/app/src/lib/voice/transcript.ts index fae2da661..3b444b194 100644 --- a/app/src/lib/voice/transcript.ts +++ b/app/src/lib/voice/transcript.ts @@ -1,4 +1,4 @@ -import type { VoiceTranscriptEntry } from "../../../../shared/voice-session"; +import type { VoiceTranscriptEntry } from "@openbot/shared/voice-session"; import type { VoiceEvent } from "./types"; export const VOICE_CONTEXT_ITEM_ID = "openbot-voice-context"; diff --git a/app/src/routes/_authed/admin/learning.tsx b/app/src/routes/_authed/admin/learning.tsx index 642551af4..19a82e05b 100644 --- a/app/src/routes/_authed/admin/learning.tsx +++ b/app/src/routes/_authed/admin/learning.tsx @@ -18,7 +18,7 @@ import type { LearningAutoAssignState, LearningSettings, LearningTarget, -} from "../../../../../shared/learning"; +} from "@openbot/shared/learning"; export const Route = createFileRoute("/_authed/admin/learning")({ component: LearningPage, diff --git a/app/tests/computer-handoff.test.ts b/app/tests/computer-handoff.test.ts index 61682b7a0..be0999347 100644 --- a/app/tests/computer-handoff.test.ts +++ b/app/tests/computer-handoff.test.ts @@ -9,7 +9,7 @@ import { import type { ComputerControlState, HandoffStatus, -} from "../../shared/computer-control"; +} from "@openbot/shared/computer-control"; import { awaitHandoff, runHelpRequest, diff --git a/app/tests/learning-admin.test.tsx b/app/tests/learning-admin.test.tsx index 7ad9bb3e5..57442b95f 100644 --- a/app/tests/learning-admin.test.tsx +++ b/app/tests/learning-admin.test.tsx @@ -9,7 +9,7 @@ import { } from "@tanstack/react-router"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { LearningPage } from "@/routes/_authed/admin/learning"; -import type { LearningSettings } from "../../shared/learning"; +import type { LearningSettings } from "@openbot/shared/learning"; beforeAll(() => GlobalRegistrator.register()); afterAll(() => GlobalRegistrator.unregister()); diff --git a/app/tests/person-sent-message.test.ts b/app/tests/person-sent-message.test.ts index 366951367..ba03fd672 100644 --- a/app/tests/person-sent-message.test.ts +++ b/app/tests/person-sent-message.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { frameFiring } from "../../shared/routine-firing"; +import { frameFiring } from "@openbot/shared/routine-firing"; import { isPersonSentMessage } from "../src/components/channels/chat-transcript"; /** diff --git a/app/tests/self-host-banner.test.tsx b/app/tests/self-host-banner.test.tsx index 53a32e340..325d5c0d5 100644 --- a/app/tests/self-host-banner.test.tsx +++ b/app/tests/self-host-banner.test.tsx @@ -16,7 +16,7 @@ import { import { authKeys } from "@/lib/auth/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; -import type { UserPreferences } from "../../shared/user-preferences"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; diff --git a/app/tests/transcript-attachments.test.tsx b/app/tests/transcript-attachments.test.tsx index e5313ecd6..40910e248 100644 --- a/app/tests/transcript-attachments.test.tsx +++ b/app/tests/transcript-attachments.test.tsx @@ -17,7 +17,7 @@ import { } from "@/components/channels/chat-transcript"; import type { QueuedMessage } from "@/components/channels/composer"; import { attachmentUrl } from "@/lib/channels/attachments"; -import { frameFiring } from "../../shared/routine-firing"; +import { frameFiring } from "@openbot/shared/routine-firing"; import { settleReactWork } from "./settle-react-work"; /** diff --git a/app/tests/user-preferences.test.tsx b/app/tests/user-preferences.test.tsx index 257794545..ea7ec16b5 100644 --- a/app/tests/user-preferences.test.tsx +++ b/app/tests/user-preferences.test.tsx @@ -15,7 +15,7 @@ import { saveUserPreferencesMutationOptions, userPreferencesQueryOptions, } from "@/lib/settings/message-list"; -import type { UserPreferences } from "../../shared/user-preferences"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; diff --git a/app/tests/voice-archive.test.ts b/app/tests/voice-archive.test.ts index 96415969b..8af6e1edb 100644 --- a/app/tests/voice-archive.test.ts +++ b/app/tests/voice-archive.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, mock, spyOn, test } from "bun:test"; -import type { VoiceSessionRecord } from "../../shared/voice-session"; +import type { VoiceSessionRecord } from "@openbot/shared/voice-session"; import { loadVoiceArchive, saveVoiceSession, diff --git a/app/tests/voice-outbox.test.ts b/app/tests/voice-outbox.test.ts index 482ad24d8..d10a5af8e 100644 --- a/app/tests/voice-outbox.test.ts +++ b/app/tests/voice-outbox.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import type { SaveVoiceSessionInput } from "../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { authKeys } from "../src/lib/auth/queries"; import { forgetVoiceCall, diff --git a/app/tests/voice-transcript.test.ts b/app/tests/voice-transcript.test.ts index 523bf0007..c5126171b 100644 --- a/app/tests/voice-transcript.test.ts +++ b/app/tests/voice-transcript.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { SaveVoiceSessionInput } from "../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { VoiceTranscript, VOICE_CONTEXT_ITEM_ID, diff --git a/app/tsconfig.json b/app/tsconfig.json index 8a446e9bb..3a2364a96 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -8,11 +8,5 @@ "jsx": "react-jsx", "lib": ["ES2024", "DOM", "DOM.Iterable"] }, - "include": [ - "src", - "tests", - "vite.config.ts", - "../shared/handoff-markers.ts", - "../shared/listen-port.ts" - ] + "include": ["src", "tests", "vite.config.ts"] } diff --git a/app/vite-listen-port.test.ts b/app/vite-listen-port.test.ts index 5e54d63b8..191c6d066 100644 --- a/app/vite-listen-port.test.ts +++ b/app/vite-listen-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { listenPort } from "../shared/listen-port"; +import { listenPort } from "@openbot/shared/listen-port"; /** * The app Vite config used `Number.parseInt(process.env.APP_PORT ?? "3010", 10)` (and the same diff --git a/app/vite.config.ts b/app/vite.config.ts index 97f49d91b..5358b2702 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; -import { listenPort } from "../shared/listen-port"; +import { listenPort } from "@openbot/shared/listen-port"; /* * Announce the server's port to the two runtimes that serve the app through Vite, and to no other. diff --git a/biome.json b/biome.json index 4bdd1512b..686c6088e 100644 --- a/biome.json +++ b/biome.json @@ -35,6 +35,73 @@ } } }, + { + "includes": ["toolkit/src/**", "!toolkit/src/copilotkit/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["../**"], + "message": "The toolkit runs turns without the server. Take what it needs as a port in toolkit/ports.ts instead of importing server code." + }, + { + "group": [ + "./copilotkit/**", + "@openbot/toolkit/copilotkit", + "@openbot/toolkit/copilotkit/**" + ], + "message": "The toolkit core does not depend on CopilotKit. Code that needs @copilotkit/runtime lives in toolkit/src/copilotkit and imports the core, not the other way round." + }, + { + "group": [ + "@copilotkit/**", + "drizzle-orm", + "drizzle-orm/**", + "hono", + "hono/**" + ], + "message": "The toolkit core depends on @ag-ui, rxjs, zod and @openbot/shared only. CopilotKit-specific code goes in toolkit/src/copilotkit." + } + ] + } + } + } + } + } + }, + { + "includes": ["toolkit/src/copilotkit/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["../../**"], + "message": "The CopilotKit adapters run without the server. Take what they need as a port or a constructor option instead of importing server code." + }, + { + "group": [ + "drizzle-orm", + "drizzle-orm/**", + "hono", + "hono/**" + ], + "message": "The CopilotKit adapters do not reach the database or the HTTP framework." + } + ] + } + } + } + } + } + }, { "includes": ["app/src/components/ui/**"], "linter": { diff --git a/bun.lock b/bun.lock index b9ede3ac0..c80a18591 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@copilotkit/a2ui-renderer": "1.77.0", "@copilotkit/react-core": "1.77.0", "@fontsource-variable/inter": "^5.3.0", + "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", "@tanstack/react-form": "^1.33.5", @@ -80,6 +81,8 @@ "@mastra/client-js": "^1.43.0", "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", + "@openbot/shared": "workspace:*", + "@openbot/toolkit": "workspace:*", "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/exporter-logs-otlp-http": "0.221.0", "@opentelemetry/resources": "2.10.0", @@ -103,9 +106,31 @@ "drizzle-kit": "^0.31.10", }, }, + "shared": { + "name": "@openbot/shared", + "devDependencies": { + "typescript": "^5.9.3", + }, + }, + "toolkit": { + "name": "@openbot/toolkit", + "dependencies": { + "@ag-ui/client": "1.0.1", + "@copilotkit/runtime": "1.77.0", + "@openbot/shared": "workspace:*", + "rxjs": "7.8.1", + "zod": "^4.4.3", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, "worker": { "name": "worker", "version": "0.0.0", + "dependencies": { + "@openbot/shared": "workspace:*", + }, }, }, "overrides": { @@ -154,9 +179,9 @@ "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.48", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Zl5+5VHId7g344LVSjZvPuFuRVej+u+MB+0ibFVGaXG1qUykyjKu2ptHinK5RjDdviCm+Bs+t3ZLegB39e8VyA=="], - "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], "@ai-sdk/provider-utils-v6": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], @@ -508,6 +533,10 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@openbot/shared": ["@openbot/shared@workspace:shared"], + + "@openbot/toolkit": ["@openbot/toolkit@workspace:toolkit"], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], @@ -964,7 +993,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], @@ -2370,18 +2399,6 @@ "@ag-ui/proto/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.95", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V0nIwhyv8f9rD+p6glm0uq30seid8y2CrvNvHCAbnuPm5bPpqVChEB63kixrIDNnogkgI+ZjSTtIbIL2q3QPvQ=="], "@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zYCrUuiCxLk65iQ3C5AU9qLo0lsyCbYNAS983rDTg/lMVmdsWz8aqrL6K746y7/lRyv+gM1y1HoFCxVsKOSlSw=="], @@ -2390,24 +2407,20 @@ "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], - "@ai-sdk/mcp/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - - "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], - "@ai-sdk/provider-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "@ai-sdk/provider-utils-v7/@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], + "@ai-sdk/ui-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + "@ai-sdk/ui-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], @@ -2500,16 +2513,10 @@ "@whatwg-node/node-fetch/@fastify/busboy": ["@fastify/busboy@3.2.2", "", {}, "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg=="], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "chat/@workflow/serde": ["@workflow/serde@4.1.0-beta.2", "", {}, "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww=="], "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], @@ -2650,6 +2657,8 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -2730,20 +2739,10 @@ "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@ai-sdk/google-vertex/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@ai-sdk/google/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "@ai-sdk/mcp/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@ai-sdk/openai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "@copilotkit/channels-core/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/proto": "0.0.59" } }, "sha512-wQCzBsStyZMm8nzhTdTx1G0B1C8yv5rbzZLnz1ka/l5LcJEsK3KTounU8CR9l+7QtcpOUUDJKd0xAbyI6gNcSw=="], "@copilotkit/channels-core/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1" } }, "sha512-X+uvDaegLEHw5kJu8tv2eSqHH8ouat+JCfFokGV1uuMquY8qCEQSlGsM7xzexwX9fujuxgHOWumg5kJDqa0+RA=="], @@ -2850,8 +2849,6 @@ "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "ai/@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index cf1dd3d8c..e08cc85d6 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -175,8 +175,11 @@ pub fn needs_fetch(root: &Path, wanted: &str) -> bool { pub const REQUIRED: [&str; 4] = ["docker-compose.yml", "server", "app", "worker"]; /// The rest of what a deployment needs, which is not what it is checked for. -pub const ALSO_COPIED: [&str; 7] = [ +pub const ALSO_COPIED: [&str; 8] = [ "shared", + // A root workspace the server imports as `@openbot/toolkit`. Without it, `bun install + // --frozen-lockfile` stops with `Workspace not found "toolkit"`. + "toolkit", "examples", "package.json", "bun.lock", diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 65f3b431d..3877d8b90 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -8181,6 +8181,8 @@ fn main() { std::fs::create_dir_all(root.join("server")).unwrap(); std::fs::create_dir_all(root.join("app")).unwrap(); std::fs::create_dir_all(root.join("worker")).unwrap(); + std::fs::create_dir_all(root.join("shared")).unwrap(); + std::fs::create_dir_all(root.join("toolkit")).unwrap(); std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); std::fs::write( root.join("app/package.json"), @@ -8228,7 +8230,7 @@ fn main() { ) .unwrap(); deployment::record(root, DEPLOYMENT_VERSION).unwrap(); - for package in ["", "server", "worker"] { + for package in ["", "server", "worker", "shared", "toolkit"] { std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); } std::fs::write(root.join("bun.lock"), "synthetic-lock").unwrap(); diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 0fb5d492b..f29c50b6e 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -11,7 +11,7 @@ pub const REQUIRED: &str = "Finish installing OpenBot's local software before si pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; -const PACKAGES: [&str; 4] = ["", "server", "app", "worker"]; +const PACKAGES: [&str; 6] = ["", "server", "app", "worker", "shared", "toolkit"]; #[derive(Serialize, Deserialize)] struct Dependencies { diff --git a/package.json b/package.json index 644d46466..c4324173a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "workspaces": [ "app", "server", - "worker" + "worker", + "shared", + "toolkit" ], "scripts": { "build": "bun run generate:app-config && bun run --filter '*' build", diff --git a/server/Dockerfile b/server/Dockerfile index d66feafc6..0dda2d096 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -19,6 +19,8 @@ COPY bunfig.toml bunfig.toml COPY app/package.json app/package.json COPY server/package.json server/package.json COPY worker/package.json worker/package.json +COPY shared/package.json shared/package.json +COPY toolkit/package.json toolkit/package.json RUN bun install --frozen-lockfile @@ -29,6 +31,7 @@ FROM deps AS app-build COPY app app COPY scripts scripts COPY shared shared +COPY toolkit toolkit # The server's source too: `generate-app-config` reads the tenant package through # `server/src/tenant-package`, so the app cannot be built without it. COPY server server @@ -49,9 +52,11 @@ COPY --from=deps /app/node_modules node_modules COPY --from=deps /app/package.json package.json COPY --from=deps /app/bun.lock bun.lock COPY --from=deps /app/server/node_modules server/node_modules +COPY --from=deps /app/toolkit/node_modules toolkit/node_modules COPY server server COPY shared shared +COPY toolkit toolkit COPY examples examples # The built app, served by the process below. `APP_DIST_DIR` is what turns that on, so an image diff --git a/server/package.json b/server/package.json index a8b7080ef..df7114e93 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,8 @@ "@mastra/client-js": "^1.43.0", "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", + "@openbot/shared": "workspace:*", + "@openbot/toolkit": "workspace:*", "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/exporter-logs-otlp-http": "0.221.0", "@opentelemetry/resources": "2.10.0", diff --git a/server/scripts/cull-idle-computers.ts b/server/scripts/cull-idle-computers.ts index c5609481d..be0772105 100644 --- a/server/scripts/cull-idle-computers.ts +++ b/server/scripts/cull-idle-computers.ts @@ -19,7 +19,7 @@ import { suspendClaimedComputers, } from "../src/work/culler"; import { createWorkQueue } from "../src/work/queue"; -import { workOwner } from "../../shared/work-owner"; +import { workOwner } from "@openbot/shared/work-owner"; const config = loadConfig(process.env); if (!config.computer) { diff --git a/server/scripts/fire-routines.ts b/server/scripts/fire-routines.ts index ee2545ed8..554e1dd9d 100644 --- a/server/scripts/fire-routines.ts +++ b/server/scripts/fire-routines.ts @@ -21,7 +21,7 @@ import { ROUTINE_FIRE_KIND, } from "../src/routines/sweep"; import { createWorkQueue } from "../src/work/queue"; -import { workOwner } from "../../shared/work-owner"; +import { workOwner } from "@openbot/shared/work-owner"; const config = loadConfig(process.env); diff --git a/server/src/agents/coordination-wiring.ts b/server/src/agents/coordination-wiring.ts new file mode 100644 index 000000000..fb6bd1b3a --- /dev/null +++ b/server/src/agents/coordination-wiring.ts @@ -0,0 +1,296 @@ +import { createHash } from "node:crypto"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import { eq } from "drizzle-orm"; +import { PERSON_QUESTION_WAITING } from "../approvals/question-resume"; +import type { ApprovalService } from "../approvals/service"; +import { currentApprovalContext } from "../approvals/types"; +import { + type AuditInitiator, + type AuditStore, + PERSON_INITIATOR, +} from "../audit"; +import { groupSourceForRun } from "../channels/group"; +import type { ChannelStore } from "../channels/routes"; +import type { DeploymentConfig } from "../config"; +import type { Database } from "../db/client"; +import { channelMemberships } from "../db/schema"; +import type { DeliveryScope } from "../delivery/types"; +import type { PeopleStore } from "../people/store"; +import type { PluginStore } from "../plugins/store"; +import { REFUSAL_MARKER } from "../plugins/tools"; +import { createPrivateShareCheck } from "../proactive/private-share"; +import { createWorkQueue } from "../work/queue"; +import { type RunAssertion, readRunAssertion } from "./callback-token"; +import type { HandoffDesk } from "./handoff"; +import { + authoriseCoordinationRun, + createCoordinationTools, + readCoordinationHandoffClaim, +} from "./handoff-tool"; +import type { AgentProfileStore } from "./profile-store"; +import type { AgentActor } from "./profile-types"; + +/** + * Bots coordinating with each other and with people: handing work on, asking a person a question, + * and the per-run tools a Bot is given for both. + * + * Moved out of index.ts verbatim. `notify` is taken as a function because index.ts assigns delivery + * after this is created: a question reads it when it is routed, never when this is built. + */ +export function wireCoordination(deps: { + handoffDesk: HandoffDesk; + config: DeploymentConfig; + approvalService: ApprovalService; + agentProfileStore: AgentProfileStore; + actorFor: (ownerUserId: string) => Promise; + channelSourceForRun: ( + run: RunAssertion, + ) => Promise<{ channelId: string; botId: string } | null>; + database: Database; + pluginStore: PluginStore; + bootAuditStore: AuditStore; + peopleStore: PeopleStore; + sourceForCoordinationRun: ( + run: RunAssertion, + ) => Promise<{ channelId: string; botId: string } | null>; + channelStore: ChannelStore; + notify: ( + scope: DeliveryScope, + input: { + id: string; + text: string; + kind: "question"; + requestId: string; + }, + ) => Promise; +}) { + const { + handoffDesk, + config, + approvalService, + agentProfileStore, + actorFor, + channelSourceForRun, + database, + pluginStore, + bootAuditStore, + peopleStore, + sourceForCoordinationRun, + channelStore, + } = deps; + const personQuestions = createWorkQueue(database); + const coordination = createCoordinationTools({ + desk: handoffDesk, + caps: config.handoff, + approvalGate: approvalService.gate, + privateShare: { + check: createPrivateShareCheck({ approvals: approvalService.store }), + // Resolved the way the desk resolves it: an id wins, then a single visible name. Null for the + // owner's own Bot or a deployment Bot, since nobody else reads that work. + audienceFor: async (from, target) => { + const roster = await agentProfileStore.list( + await actorFor(from.actorId), + ); + const wanted = target.trim().toLowerCase(); + const found = + roster.find((candidate) => candidate.id.toLowerCase() === wanted) ?? + roster.find( + (candidate) => + !candidate.hidden && + candidate.deletedAt === null && + candidate.name.toLowerCase() === wanted, + ); + if (!found?.ownerUserId || found.ownerUserId === from.actorId) + return null; + return { + kind: "handoff", + id: found.ownerUserId, + recipientUserIds: [found.ownerUserId], + label: found.name, + }; + }, + // A question from a conversation other people are in reaches them too, so it is held like a + // handoff to another person. Only the owner reading it is the ordinary, unheld case. + questionAudience: async (from) => { + const channel = await channelSourceForRun(from); + const group = channel ? null : await groupSourceForRun(database, from); + const source = channel ?? group; + if (!source) return null; + const members = ( + await database + .select({ userId: channelMemberships.userId }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, source.channelId)) + ).map((row) => row.userId); + if (members.every((userId) => userId === from.actorId)) return null; + return { + audience: { + kind: group ? "group" : "channel", + id: source.channelId, + recipientUserIds: members, + }, + // A channel thread is the conversation itself; a group Bot's own thread also holds context + // only its owner has seen. + origin: channel + ? { kind: "shared_conversation", sharedWithUserIds: members } + : { kind: "private_conversation" }, + }; + }, + }, + botsReachableFrom: (botId) => pluginStore.botsReachableFrom(botId), + auditStore: bootAuditStore, + authoriseRun: (run) => + authoriseCoordinationRun(run, { + canUseBot: async (scope) => { + const person = await peopleStore.find(scope.actorId); + if (!person || person.revoked) return false; + return ( + (await agentProfileStore.get( + await actorFor(scope.actorId), + scope.botId, + )) !== null + ); + }, + sourceThread: sourceForCoordinationRun, + delegationFor: (claim) => readCoordinationHandoffClaim(database, claim), + }), + route: async (question) => { + const source = await sourceForCoordinationRun(question); + if (!source) + return { + refusal: "That question could not reach its source conversation.", + }; + const key = createHash("sha256") + .update( + JSON.stringify([ + question.actorId, + question.botId, + question.runId, + question.question, + ]), + ) + .digest("hex"); + const queued = await personQuestions.offer({ + kind: "person.question", + key, + payload: { + ...question, + sourceBotId: source.botId, + channelId: source.channelId, + mode: "completed_question", + toolName: "ask_person", + args: { + question: question.question, + ...(question.why ? { why: question.why } : {}), + }, + }, + }); + if (queued === "refused") + return { refusal: "The question could not be saved for its person." }; + await channelStore.recordActivity( + await actorFor(question.actorId), + source.channelId, + { + text: `A question needs your answer: ${question.question}`, + agentId: source.botId, + at: new Date(), + }, + { id: `question:${key}` }, + ); + if (question.threadId) { + // A person reads this in Slack or on their phone, where "general-assistant" is an id, not a name. + const botName = + ( + await agentProfileStore + .get(await actorFor(question.actorId), question.botId) + .catch(() => null) + )?.name ?? question.botId; + await deps.notify( + { + ownerUserId: question.actorId, + channelId: source.channelId, + agentId: source.botId, + threadId: question.threadId, + }, + { + id: key, + text: `${botName} asks: ${question.question}`, + kind: "question", + requestId: key, + }, + ); + } + return { + reached: + "the person in the source conversation; the question is saved awaiting their reply", + }; + }, + }); + const coordinationForActor = + ( + actorId: string, + initiator: AuditInitiator = PERSON_INITIATOR, + baseDepth = 0, + ) => + async (botId: string, input: import("@ag-ui/client").RunAgentInput) => { + const assertion = readRunAssertion( + input.forwardedProps?.openbotRun, + config.keyEncryptionKey, + ); + if ( + assertion && + (assertion.botId !== botId || assertion.actorId !== actorId) + ) + throw new Error("The run assertion does not match this Bot and owner."); + const run: RunAssertion = assertion?.handoff + ? assertion + : { + botId, + actorId, + runId: input.runId, + threadId: input.threadId, + depth: assertion?.depth ?? baseDepth, + initiator: assertion?.initiator ?? initiator, + }; + const tools = await coordination.toolsForRun(run); + return tools.map((tool) => ({ + ...tool, + execute: async (args: unknown) => { + if (!args || typeof args !== "object" || Array.isArray(args)) + return `${REFUSAL_MARKER} Tool arguments must be an object.`; + const result = await coordination.call({ + name: tool.name, + args: args as Record, + run, + }); + if (!result) + throw new Error("A coordination tool was not dispatched."); + /* + * A responsibility's question pauses its run, the way an approval does. + * + * Left running, the run ended "succeeded" the moment the question was saved, and the answer + * came back to a run that was already over: the Bot read it, and then the store refused its + * progress because it was not from the responsibility's current run, so the goal stayed + * open for ever. Suspended, the run waits, and the answer resumes that same run as this + * tool's result, with the right to record progress and finish. + */ + const snapshot = currentApprovalContext(); + if ( + tool.name === "ask_person" && + run.initiator?.kind === "responsibility" && + snapshot && + !result.text.startsWith(REFUSAL_MARKER) + ) + throw new HeadlessToolSuspension( + "This responsibility is waiting for the person's answer.", + { + kind: PERSON_QUESTION_WAITING, + continuation: snapshot, + }, + ); + return result.text; + }, + })); + }; + return { coordination, coordinationForActor }; +} diff --git a/server/src/agents/escalation.ts b/server/src/agents/escalation.ts index 1149e2c8c..be25f4fe5 100644 --- a/server/src/agents/escalation.ts +++ b/server/src/agents/escalation.ts @@ -19,13 +19,13 @@ */ import { z } from "zod"; -import { PUT_TO } from "../../../shared/handoff-markers"; +import { PUT_TO } from "@openbot/shared/handoff-markers"; import { type AuditInitiator, type AuditStore, recordAuditEvent, } from "../audit"; -import type { GrantedTool } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import type { RunAssertion } from "./callback-token"; /** What the model is offered. One name, so a transcript can find every escalation by searching. */ diff --git a/server/src/agents/handoff-delivery.ts b/server/src/agents/handoff-delivery.ts index e672d745a..c6909522f 100644 --- a/server/src/agents/handoff-delivery.ts +++ b/server/src/agents/handoff-delivery.ts @@ -12,11 +12,12 @@ * the work over, the second says it answered, and no row anywhere agrees. */ import type { AbstractAgent, BaseEvent } from "@ag-ui/client"; +import { THREAD_LOCK_TTL_SECONDS } from "@openbot/toolkit/copilotkit/thread-lock"; +import { keepLease } from "@openbot/toolkit/lease"; +import type { ThreadLock, TurnAdmission } from "@openbot/toolkit/ports"; import type { Observable } from "rxjs"; -import { headlessTurnRefusal } from "../admin/controls"; import type { AuditInitiator } from "../audit"; import type { HandoffDelivery } from "./handoff-runner"; -import { guardBotTurn } from "./lifecycle"; import { textOf } from "./message-text"; /** Whatever runs an agent against a thread and records what it did. */ @@ -44,38 +45,6 @@ export type ThreadRunner = { stop?: (input: { threadId: string; runId: string }) => Promise; }; -/** - * The conversation's run lock. - * - * ONE RUN AT A TIME PER CONVERSATION, taken before anything is streamed. The platform hands the lock - * out through an ordinary authenticated call and hands back the token that proves it: a run that - * skips this and starts streaming is refused, because it is claiming to be a run nobody was told - * about. That is what every delivery did before this existed, and the refusal read like a platform - * limitation rather than a missing step. - * - * Taken with `NX`, so a conversation somebody else is already running in refuses rather than queues. - * That is the right answer and the hop simply waits its turn: it is released back to the queue and - * tried again, which is a wait rather than a failure. - */ -export type ThreadLock = { - /** - * The run id the platform issued, or null when somebody else is running in this conversation. - * - * ITS OWN ID, NOT THE ONE ASKED FOR. That id is what the gateway checks every streamed event - * against, so a run that used the local one would be claiming to be a run nobody was told about. - */ - acquire: (input: { - threadId: string; - runId: string; - userId: string; - agentId: string; - }) => Promise<{ runId: string } | null>; - /** Keep it while the addressed Bot works. The lock expires on its own otherwise. */ - renew: (input: { threadId: string; runId: string }) => Promise; - /** Give it back, so the next run does not wait out the whole expiry. */ - release: (input: { threadId: string; runId: string }) => Promise; -}; - export function createHandoffDelivery(options: { /** * The addressed Bot, built for the person whose conversation this is. @@ -164,8 +133,21 @@ export function createHandoffDelivery(options: { * conversation it was asked in locked against them for as long as the process lives. */ deadlineMs?: number; + /** How often the lock is renewed while the Bot works. Overridden by tests; see `LOCK_RENEW_EVERY_MS`. */ + renewEveryMs?: number; + /** + * How long the lock lives without a renewal. Renewals failing for longer than this mean it has + * lapsed, and the hop stops. Overridden by tests; the platform applies its own 120 seconds. + */ + lockLifetimeMs?: number; + /** + * The same admission every headless turn meets: the pause guard and the enterprise controls. See + * `serverTurnAdmission` in turn-admission.ts. + */ + admission: TurnAdmission; }): HandoffDelivery { const { + admission, agentFor, history, runner, @@ -175,10 +157,25 @@ export function createHandoffDelivery(options: { setBusy, newRunId, deadlineMs = DEFAULT_DELIVERY_DEADLINE_MS, + renewEveryMs = LOCK_RENEW_EVERY_MS, + lockLifetimeMs = THREAD_LOCK_TTL_SECONDS * 1000, } = options; return { async deliver({ work, message, shown, assertion }) { + /* + * Admitted before anything else, as every headless turn is: refused if the person paused the + * Bot while this hop waited, or if "Use Bots" or the model allowlist refuse it, before the Bot + * is built, the history read or the conversation locked. A refused hop used to take the lock + * first, so a paused Bot's relay held the person's own channel for a moment on every retry. + * The signal it returns aborts if they pause the Bot while it works, on this replica at once + * and from any other through the watcher. The runner's check before claiming is not enough on + * its own, because a hop runs for minutes. + */ + const paused = await admission({ + ownerUserId: work.actorId, + agentId: work.toBotId, + }); const agent = await agentFor({ actorId: work.actorId, botId: work.toBotId, @@ -344,35 +341,21 @@ export function createHandoffDelivery(options: { * Renewed while the addressed Bot works, because the lock expires on its own. A run is minutes * and the platform's window is short; a lock that lapses mid-answer lets a second run into the * conversation, which is the thing it exists to prevent. + * + * A renewal refused as a conflict means somebody else now holds this conversation — on a + * relay, most likely the person, having typed in their own channel. The run is stopped as a + * pause would stop it and the hop fails into a retry, rather than writing this Bot's answer + * into their run. A renewal that merely failed is skipped; see `keepLease`. */ - const heartbeat = setInterval(() => { - void lock.renew({ threadId: where.threadId, runId }).catch(() => {}); - }, LOCK_RENEW_EVERY_MS); + const lockLost = new AbortController(); + const heartbeat = keepLease({ + renew: () => lock.renew({ threadId: where.threadId, runId }), + everyMs: renewEveryMs, + lifetimeMs: lockLifetimeMs, + onLost: (error) => lockLost.abort(error), + }); try { - /* - * The same pause guard every other turn takes: refused if the person paused the Bot while - * this hop waited, and stopped if they pause it while it works, on this replica at once and - * from any other through the watcher. The runner's check before claiming is not enough on - * its own, because a hop runs for minutes. - */ - const paused = await guardBotTurn({ - ownerUserId: work.actorId, - agentId: work.toBotId, - }); - // "Use Bots" and the model allowlist, as every other headless turn meets them in the turn - // runner. A hop builds its agent and runs it here, so without this one a chain that began on - // a remote Bot reached a built-in Bot on a model off the allowlist, or ran for a person with - // Use Bots off. - const refused = await headlessTurnRefusal({ - ownerUserId: work.actorId, - agentId: work.toBotId, - }); - if (refused) { - const error = new Error(refused); - error.name = "CapabilityRefusedError"; - throw error; - } await settled( runner.run({ threadId: where.threadId, @@ -422,7 +405,7 @@ export function createHandoffDelivery(options: { }, }), deadlineMs, - paused, + AbortSignal.any([paused, lockLost.signal]), () => { void runner .stop?.({ threadId: where.threadId, runId }) @@ -452,7 +435,7 @@ export function createHandoffDelivery(options: { }); } } finally { - clearInterval(heartbeat); + heartbeat.stop(); /* * Given back whatever happened. Left held, the conversation is unusable by anybody until the * lock expires: the person cannot ask a follow-up and the next hop is refused, which turns @@ -549,7 +532,10 @@ const DEFAULT_DELIVERY_DEADLINE_MS = 5 * 60_000; function settled( events: Observable, deadlineMs: number, - /** Aborts when the person pauses the Bot; the run is stopped and the hop fails with the reason. */ + /** + * Aborts when the person pauses the Bot or the conversation's lock is lost; the run is stopped and + * the hop fails with the reason. + */ paused: AbortSignal, stop: () => void, /** Written when the deadline passes, so it can say how far the run had got by then. */ diff --git a/server/src/agents/handoff-tool.ts b/server/src/agents/handoff-tool.ts index 53c1301a3..997ac9cdf 100644 --- a/server/src/agents/handoff-tool.ts +++ b/server/src/agents/handoff-tool.ts @@ -14,7 +14,7 @@ import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; -import { HANDED_OVER } from "../../../shared/handoff-markers"; +import { HANDED_OVER } from "@openbot/shared/handoff-markers"; import { type ApprovalGate, ApprovalRefusedError, @@ -23,7 +23,8 @@ import { import { type AuditStore, PERSON_INITIATOR, recordAuditEvent } from "../audit"; import type { Database } from "../db/client"; import { workItems } from "../db/schema"; -import { type GrantedTool, REFUSAL_MARKER } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { REFUSAL_MARKER } from "../plugins/tools"; import type { CheckPrivateShare, PrivateShareInput, diff --git a/server/src/agents/handoff-wiring.ts b/server/src/agents/handoff-wiring.ts new file mode 100644 index 000000000..bce0c3e25 --- /dev/null +++ b/server/src/agents/handoff-wiring.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { IntelligenceAgentRunner } from "@copilotkit/runtime/v2"; +import { workOwner } from "@openbot/shared/work-owner"; +import { eq } from "drizzle-orm"; +import { type AuditStore, handoffInitiator } from "../audit"; +import type { GroupConversations } from "../channels/group"; +import type { ChannelStore } from "../channels/routes"; +import type { ThreadIdentity } from "../channels/thread-identity"; +import type { DeploymentConfig } from "../config"; +import type { mountCopilotRuntime } from "../copilot"; +import type { Database } from "../db/client"; +import { intelligenceChannelMappings } from "../db/schema"; +import { repeatAfterEach } from "../work/loop"; +import { + createWorkQueue, + startWorkOfferedListener, + type WorkOfferedListener, +} from "../work/queue"; +import { HANDOFF_KIND } from "./handoff"; +import { createHandoffDelivery } from "./handoff-delivery"; +import { createHandoffRunner } from "./handoff-runner"; +import { signHandoffDeliveryRun } from "./handoff-signing"; +import type { AgentActor } from "./profile-types"; +import { serverTurnAdmission } from "./turn-admission"; + +/** + * Handing work between Bots, started on this replica: the runner that claims hops, the delivery + * that runs the addressed Bot, and the loop and queue wake-up that drive them. + * + * Moved out of index.ts verbatim. Returns the queue's wake-up listener, which index.ts holds so its + * shutdown can give the connection back. Called only where handing work between Bots is on. + */ +export async function startHandoffDelivery(deps: { + config: DeploymentConfig; + database: Database; + bootAuditStore: AuditStore; + actorFor: (ownerUserId: string) => Promise; + copilotRuntime: ReturnType; + threadIdentity: ThreadIdentity; + groupConversations: GroupConversations; + channelStore: ChannelStore; +}): Promise { + const { + config, + database, + bootAuditStore, + actorFor, + copilotRuntime, + threadIdentity, + groupConversations, + channelStore, + } = deps; + const runner = createHandoffRunner({ + queue: createWorkQueue(database), + owner: workOwner("handoff"), + auditStore: bootAuditStore, + /* + * The signed statement of the run the addressed Bot is about to start, carrying how deep the + * chain has gone. Minted here, where the key lives, and one deeper than the run that asked. + */ + sign: (work, claim) => + signHandoffDeliveryRun(work, config.keyEncryptionKey, undefined, claim), + delivery: createHandoffDelivery({ + admission: serverTurnAdmission, + /* + * Built as the person, WITH THEIR ROLE. The desk resolved it to decide the hop was allowed; a + * delivery that then rebuilt them as an ordinary user could not find the Bot the desk had just + * agreed to, and the person was told it never answered. + */ + agentFor: async ({ actorId, botId, fromBotId, initiator }) => { + const actor = await actorFor(actorId).catch(() => null); + if (!actor) { + throw new Error( + "who this is for could not be confirmed, so the Bot was not run", + ); + } + return copilotRuntime.agentFor({ + actor, + botId, + initiator: handoffInitiator(fromBotId, initiator), + }); + }, + history: copilotRuntime.history, + lock: copilotRuntime.threadLock, + /* + * A scratch thread of the addressed Bot's own, one per hop. + * + * An Intelligence thread has exactly one agent, so a second Bot cannot answer inside the first + * Bot's conversation however it asks. Its turn runs here instead, unmapped to any channel, and + * what it said comes back to the conversation that asked through the relay — in the asking + * Bot's voice, which is the only voice that thread admits. Minted with the deployment's own + * identity, like every thread this deployment starts. + */ + mintThreadId: () => threadIdentity.mint(), + /* + * The roster, told that a relayed answer landed. The delivery knows only the thread it ran + * in; this resolves which channel shows that thread — a scratch thread maps to nothing and + * announces nowhere, which is the point of a scratch thread. + */ + announce: async (input) => { + const [mapped] = await database + .select({ channelId: intelligenceChannelMappings.channelId }) + .from(intelligenceChannelMappings) + .where(eq(intelligenceChannelMappings.threadId, input.threadId)) + .limit(1); + // A Bot's group thread maps to no channel; its answer goes to the shared transcript. + if (!mapped) { + await groupConversations.announceHandoff(input); + return; + } + const actor = await actorFor(input.actorId).catch(() => null); + if (!actor) return; + await channelStore.recordActivity(actor, mapped.channelId, { + text: input.text, + agentId: input.agentId, + at: new Date(), + }); + }, + // The asking conversation shown as working while a hop runs in it. Keyed by thread, resolved + // to its channel by the store; a scratch thread maps to none and signals nowhere. + setBusy: (input) => channelStore.signalBusy(input.threadId, input.busy), + newRunId: () => randomUUID(), + // The same address and the same token the runtime uses. Assembling either from configuration + // produced a runner every join was refused for, because the thread's active run is a lock the + // platform issues rather than something an API key can claim. + runner: new IntelligenceAgentRunner( + copilotRuntime.runnerConnection(), + ) as never, + }), + }); + + const sweep = async () => { + try { + const report = await runner.sweep(); + if (report.delivered.length > 0 || report.skipped.length > 0) { + console.info(JSON.stringify({ type: "bot-handoff", ...report })); + } + } catch (error) { + // A sweep that failed must not take the loop with it: the next one may find the database back. + console.warn( + "[handoff] a sweep could not run:", + error instanceof Error ? error.message : error, + ); + } + }; + + /* + * ONE SWEEP AT A TIME ON THIS REPLICA, from both callers below. A sweep poked while one is + * running is remembered rather than started, and runs once the current one ends — a wake-up + * that arrived mid-sweep may be for a hop the running sweep's claim already missed. + */ + let sweeping = false; + let sweepAgain = false; + const kick = async () => { + if (sweeping) { + sweepAgain = true; + return; + } + sweeping = true; + try { + do { + sweepAgain = false; + await sweep(); + } while (sweepAgain); + } finally { + sweeping = false; + } + }; + + /* + * Woken by the queue itself, from any replica: a person is waiting through every hop, and the + * poll below would spend up to two seconds per leg doing nothing. The poll stays as the + * backstop — a notification is a latency optimisation, and one lost in transit costs one + * interval, never the work. See repeatAfterEach for why an interval must not be used: an + * interval would start another sweep every two seconds while a five-minute delivery runs, each + * claiming a different batch, and this replica's concurrent agent runs would grow with the + * backlog rather than stopping at the limit it was asked for. + */ + const listener = await startWorkOfferedListener( + config.databaseUrl, + (kind) => { + if (kind === HANDOFF_KIND) void kick(); + }, + ); + repeatAfterEach(kick, 2_000); + return listener; +} diff --git a/server/src/agents/plan-model.ts b/server/src/agents/plan-model.ts index ed92322bf..60fe96b79 100644 --- a/server/src/agents/plan-model.ts +++ b/server/src/agents/plan-model.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { HttpAgent, type Message, type RunAgentInput } from "@ag-ui/client"; import { EventSchemas } from "@ag-ui/core/schemas"; import type { BuiltInAgentClassicConfig } from "@copilotkit/runtime/v2"; +import { PLAN_RUN_COMPLETED } from "@openbot/toolkit/copilotkit/agents"; import type { Subscription } from "rxjs"; import { clearDesktopConnectionFailure, @@ -24,7 +25,7 @@ export type PlanModelConfig = { endpoint: URL; token: string; }; -export const PLAN_RUN_COMPLETED = Symbol("plan-run-completed"); +export { PLAN_RUN_COMPLETED }; /** A plan is an explicit desktop credential choice, not an API-key fallback. */ export function planModelForEnvironment( diff --git a/server/src/agents/turn-admission.ts b/server/src/agents/turn-admission.ts new file mode 100644 index 000000000..b0ee794e4 --- /dev/null +++ b/server/src/agents/turn-admission.ts @@ -0,0 +1,28 @@ +import { headlessTurnRefusal } from "../admin/controls"; +import type { TurnAdmission } from "@openbot/toolkit/ports"; +import { guardBotTurn } from "./lifecycle"; + +/** + * The server's admission for a headless turn: the pause guard, then the enterprise controls. + * + * In that order, as the turn runner has always taken them. Both read state this process installs at + * startup (`configureBotLifecycle`, the enterprise controls), which is why they live here rather than + * in the turn code. + */ +export const serverTurnAdmission: TurnAdmission = async ({ + ownerUserId, + agentId, + signal, +}) => { + // Paused Bots do not start; a pause while running aborts this signal. See agents/lifecycle.ts. + const admitted = await guardBotTurn({ ownerUserId, agentId, signal }); + // "Use Bots" and the model allowlist, which a browser run meets at the HTTP gate. No headless turn + // crosses that gate, so every one of them meets the same check here instead. + const refused = await headlessTurnRefusal({ ownerUserId, agentId }); + if (refused) { + const error = new Error(refused); + error.name = "CapabilityRefusedError"; + throw error; + } + return admitted; +}; diff --git a/server/src/agents/wake-up.ts b/server/src/agents/wake-up.ts index febad7db8..f953685da 100644 --- a/server/src/agents/wake-up.ts +++ b/server/src/agents/wake-up.ts @@ -20,11 +20,11 @@ import { z } from "zod"; import type { AuditInitiator, AuditStore } from "../audit"; import { recordAuditEvent } from "../audit"; import type { AgentChannel } from "../channels/routes"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; import type { Database } from "../db/client"; import { workItems } from "../db/schema/work"; -import type { GrantedTool } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { REFUSAL_MARKER } from "../plugins/tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import type { WorkQueue } from "../work/queue"; import { BotPausedError } from "./lifecycle"; import type { AgentActor } from "./profile-types"; diff --git a/server/src/app.ts b/server/src/app.ts index b214f490e..dcf69de23 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,7 +2,7 @@ import type { Hono as HonoApp, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { serveStatic } from "hono/bun"; -import { MAX_IMAGE_BYTES } from "../../shared/attachments"; +import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import { enterpriseControls } from "./admin/controls"; import { createEnterpriseGate } from "./admin/gate"; import { createEnterpriseAdminRoutes } from "./admin/routes"; diff --git a/server/src/approvals/execute.ts b/server/src/approvals/execute.ts index e07574d90..2d777cbcc 100644 --- a/server/src/approvals/execute.ts +++ b/server/src/approvals/execute.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import type { RunAssertion } from "../agents/callback-token"; import type { AuditInitiator } from "../audit"; -import type { HeadlessTool } from "../computer/headless-tools"; import { PluginRefusedError } from "../plugins/store"; -import type { GrantedTool } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; import { isApprovalRecheck } from "./service"; import { type ApprovalAction, diff --git a/server/src/approvals/person-response-sweep.ts b/server/src/approvals/person-response-sweep.ts new file mode 100644 index 000000000..14684d113 --- /dev/null +++ b/server/src/approvals/person-response-sweep.ts @@ -0,0 +1,148 @@ +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { z } from "zod"; +import type { RunAssertion } from "../agents/callback-token"; +import type { AgentActor } from "../agents/profile-types"; +import type { ChannelStore } from "../channels/routes"; +import type { DeliveryScope } from "../delivery/types"; +import type { ResponsibilityStore } from "../responsibilities/types"; +import type { WorkQueue } from "../work/queue"; +import { resumePersonQuestion } from "./question-resume"; +import { + type PersonQuestion, + personQuestionSchema, + questionConversationBot, +} from "./questions"; +import { ApprovalRefusedError } from "./types"; + +/** + * One pass of the person-response loop: claim a person's answer to a Bot's question, check the + * question still belongs where it was asked, resume the turn that asked it, and post the reply + * back to the channel the question came from. + * + * Moved out of index.ts verbatim. `notify` is taken as a function because index.ts assigns + * delivery after this loop is created: the loop reads it when it runs, never when it is built. + */ +export function createPersonResponseTick(deps: { + approvalQueue: WorkQueue; + personResponseOwner: string; + authoriseQuestion: (question: PersonQuestion) => Promise; + approvalContinuationRunner: TurnRunner; + responsibilityStore: ResponsibilityStore; + responsibilityAnswerTurn: NonNullable< + Parameters[0]["responsibilityTurn"] + >; + sourceForCoordinationRun: ( + run: RunAssertion, + ) => Promise<{ channelId: string } | null | undefined>; + channelStore: ChannelStore; + actorFor: (ownerUserId: string) => Promise; + notify: ( + scope: DeliveryScope, + input: { id: string; text: string; kind: "reply" }, + ) => Promise; +}): () => Promise { + const { + approvalQueue, + personResponseOwner, + authoriseQuestion, + approvalContinuationRunner, + responsibilityStore, + responsibilityAnswerTurn, + sourceForCoordinationRun, + channelStore, + actorFor, + } = deps; + return async () => { + try { + const [item] = await approvalQueue.claim({ + kind: "person.response", + owner: personResponseOwner, + leaseMs: 10 * 60_000, + limit: 1, + maxAttempts: 1, + }); + if (!item) return; + try { + const payload = z + .object({ + ownerUserId: z.string().min(1), + // The saved question, initiator included, so the answer resumes the turn that asked. + question: personQuestionSchema, + response: z.string().min(1).max(6000), + }) + .parse(item.payload); + if (payload.ownerUserId !== payload.question.actorId) + throw new ApprovalRefusedError( + "The saved question owner does not match.", + ); + await authoriseQuestion(payload.question); + const outcome = await resumePersonQuestion({ + ownerUserId: payload.ownerUserId, + question: payload.question, + response: payload.response, + messageId: `person-response:${item.key}`, + runTurn: approvalContinuationRunner, + responsibilities: responsibilityStore, + responsibilityTurn: responsibilityAnswerTurn, + }); + const source = await sourceForCoordinationRun({ + ...payload.question, + botId: questionConversationBot(payload.question), + depth: 0, + }); + if (source && outcome.replyText) + await channelStore.recordActivity( + await actorFor(payload.ownerUserId), + source.channelId, + { + text: outcome.replyText, + agentId: questionConversationBot(payload.question), + at: new Date(), + }, + { id: `person-response:${item.key}` }, + ); + if (source && outcome.replyText) + await deps.notify( + { + ownerUserId: payload.ownerUserId, + channelId: source.channelId, + agentId: questionConversationBot(payload.question), + threadId: payload.question.threadId, + }, + { + id: `person-response:${item.key}`, + text: outcome.replyText, + kind: "reply", + }, + ); + if ( + !(await approvalQueue.finish({ + kind: item.kind, + key: item.key, + owner: personResponseOwner, + })) + ) + throw new Error( + "The question response lease was lost before completion.", + ); + } catch (error) { + await approvalQueue.release({ + kind: item.kind, + key: item.key, + owner: personResponseOwner, + delayMs: 0, + reason: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } catch (error) { + console.error( + JSON.stringify({ + type: "person-response-error", + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }), + ); + } + }; +} diff --git a/server/src/approvals/question-resume.ts b/server/src/approvals/question-resume.ts index a01696f99..96be38e52 100644 --- a/server/src/approvals/question-resume.ts +++ b/server/src/approvals/question-resume.ts @@ -3,7 +3,7 @@ import type { Responsibility, ResponsibilityStore, } from "../responsibilities/types"; -import type { TurnRunner } from "../routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; import { type PersonQuestion, questionConversationBot, diff --git a/server/src/approvals/resume-sweep.ts b/server/src/approvals/resume-sweep.ts new file mode 100644 index 000000000..2760724ba --- /dev/null +++ b/server/src/approvals/resume-sweep.ts @@ -0,0 +1,240 @@ +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { headlessTurnRefusal } from "../admin/controls"; +import type { AgentActor } from "../agents/profile-types"; +import { PERSON_INITIATOR } from "../audit"; +import type { GroupConversations } from "../channels/group"; +import type { ChannelStore } from "../channels/routes"; +import type { Database } from "../db/client"; +import { + intelligenceChannelMappings, + responsibilityRuns, + routineRuns, +} from "../db/schema"; +import type { DeliveryScope } from "../delivery/types"; +import type { ResponsibilityStore } from "../responsibilities/types"; +import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; +import type { ApprovalResumeDependencies, ApprovalService } from "./service"; + +/** + * One pass of the approval resume loop: claim a decided approval, carry it out, and pick the waiting + * turn back up — a responsibility run, a group conversation or a routine run — then tell the channel. + * + * Moved out of index.ts verbatim. `notify` is taken as a function because index.ts assigns delivery + * after this loop is created: the loop reads it when it runs, never when it is built. + */ +export function createApprovalResumeTick(deps: { + approvalQueue: WorkQueue; + approvalOwner: string; + approvalService: ApprovalService; + executeApprovedAction: ApprovalResumeDependencies["execute"]; + database: Database; + responsibilityStore: ResponsibilityStore; + groupConversations: Pick; + approvalContinuationRunner: TurnRunner; + channelStore: ChannelStore; + actorFor: (ownerUserId: string) => Promise; + notify: ( + scope: DeliveryScope, + input: { id: string; text: string; kind: "reply" }, + ) => Promise; +}): () => Promise { + const { + approvalQueue, + approvalOwner, + approvalService, + executeApprovedAction, + database, + responsibilityStore, + groupConversations, + approvalContinuationRunner, + channelStore, + actorFor, + } = deps; + return async () => { + try { + const [item] = await approvalQueue.claim({ + kind: "approval.resume", + owner: approvalOwner, + leaseMs: 15 * 60_000, + limit: 1, + }); + if (!item) return; + // Set once the item is understood, so its last failed try can still tell the Bot. + let abandon: ((reason: string) => Promise) | undefined; + try { + const input = z + .object({ + ownerUserId: z.string().min(1), + approvalId: z.string().min(1), + }) + .parse(item.payload); + const resumeDependencies: ApprovalResumeDependencies = { + // Asked before the action runs: with Use Bots off, or the Bot's model off the allowlist, + // nothing is carried out and the refusal is final rather than retried. + refusal: (action) => + headlessTurnRefusal({ + ownerUserId: action.actorId, + agentId: action.botId, + }), + validate: (action) => + approvalService.validateReentry(action, () => + executeApprovedAction(action), + ), + execute: executeApprovedAction, + continue: async ({ + continuation: snapshot, + result, + messageId, + approvalId, + }) => { + const [waitingRun] = await database + .select({ id: responsibilityRuns.id }) + .from(responsibilityRuns) + .where( + and( + eq(responsibilityRuns.status, "waiting"), + sql`${responsibilityRuns.waiting}->>'approvalId' = ${approvalId}`, + ), + ) + .limit(1); + if (waitingRun) { + await responsibilityStore.resumeWaiting( + input.ownerUserId, + waitingRun.id, + result, + ); + return; + } + const request = await approvalService.store.get( + input.ownerUserId, + approvalId, + ); + // A group turn's approval also writes the resumed reply into the shared transcript. + const outcome = await groupConversations.continueWaiting( + approvalId, + () => + approvalContinuationRunner({ + ownerUserId: input.ownerUserId, + routineId: "approval", + agentId: request.action.botId, + threadId: snapshot.threadId, + instruction: "", + initiator: snapshot.initiator ?? PERSON_INITIATOR, + continuation: { snapshot, result, messageId }, + }), + ); + const [source] = await database + .select({ channelId: intelligenceChannelMappings.channelId }) + .from(intelligenceChannelMappings) + .where( + and( + eq(intelligenceChannelMappings.userId, input.ownerUserId), + eq(intelligenceChannelMappings.threadId, snapshot.threadId), + ), + ) + .limit(1); + if (source && outcome.replyText) + await channelStore.recordActivity( + await actorFor(input.ownerUserId), + source.channelId, + { + text: outcome.replyText, + agentId: request.action.botId, + at: new Date(), + }, + { id: messageId }, + ); + // A decision made in Slack, SMS or the phone gets its result back there too. + if (source && outcome.replyText) + await deps.notify( + { + ownerUserId: input.ownerUserId, + channelId: source.channelId, + agentId: request.action.botId, + threadId: snapshot.threadId, + }, + { id: messageId, text: outcome.replyText, kind: "reply" }, + ); + await database + .update(routineRuns) + .set({ + status: "succeeded", + finishedAt: new Date(), + error: null, + waiting: null, + }) + .where( + and( + eq(routineRuns.status, "waiting"), + sql`${routineRuns.waiting}->>'approvalId' = ${approvalId}`, + ), + ); + }, + }; + abandon = (reason) => + approvalService.abandon( + input.ownerUserId, + input.approvalId, + reason, + resumeDependencies, + ); + await approvalService.resume( + input.ownerUserId, + input.approvalId, + resumeDependencies, + ); + if ( + !(await approvalQueue.finish({ + kind: item.kind, + key: item.key, + owner: approvalOwner, + })) + ) + throw new Error( + "The approval work lease was lost before completion.", + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + // The queue will not hand this item out again, so the conversation hears now or never. + if (abandon && item.attempts >= DEFAULT_MAX_ATTEMPTS) { + try { + await abandon(reason); + await approvalQueue.finish({ + kind: item.kind, + key: item.key, + owner: approvalOwner, + }); + return; + } catch (failure) { + console.error( + JSON.stringify({ + type: "approval-abandon-error", + error: + failure instanceof Error ? failure.message : String(failure), + timestamp: new Date().toISOString(), + }), + ); + } + } + await approvalQueue.release({ + kind: item.kind, + key: item.key, + owner: approvalOwner, + delayMs: 10_000, + reason, + }); + throw error; + } + } catch (error) { + console.error( + JSON.stringify({ + type: "approval-resume-error", + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }), + ); + } + }; +} diff --git a/server/src/approvals/routes.ts b/server/src/approvals/routes.ts index 6af698cdf..780f191c1 100644 --- a/server/src/approvals/routes.ts +++ b/server/src/approvals/routes.ts @@ -33,7 +33,7 @@ const ruleChange = z "Change at least one field.", ); -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; export function createApprovalRoutes( service: ApprovalService, diff --git a/server/src/approvals/service.ts b/server/src/approvals/service.ts index e66c34fa7..1b7a618c5 100644 --- a/server/src/approvals/service.ts +++ b/server/src/approvals/service.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { PERSON_INITIATOR } from "../audit"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { decideAction, effectiveHostCommandPolicy, diff --git a/server/src/approvals/types.ts b/server/src/approvals/types.ts index 9393db0dc..048c17e47 100644 --- a/server/src/approvals/types.ts +++ b/server/src/approvals/types.ts @@ -1,8 +1,11 @@ -import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import type { Message, RunAgentInput } from "@ag-ui/client"; import { RunAgentInputSchema } from "@ag-ui/core/schemas"; import type { AuditInitiator } from "../audit"; +import { + type ApprovalContinuation, + currentApprovalContext, + withApprovalContext, +} from "@openbot/toolkit/continuation"; /** `handled` answers a hand-off: the person did it themselves, so it is never executed for them. */ export type ApprovalDecision = @@ -47,24 +50,11 @@ export type ApprovalPolicyOutcome = { failedClosed?: boolean; }; }; -export type ApprovalContinuation = { - runId: string; - threadId: string; - toolCallId: string; - toolName: string; - args: unknown; - messages: Message[]; - state: unknown; - context: RunAgentInput["context"]; - forwardedProps: unknown; - initiator?: AuditInitiator; +export { + type ApprovalContinuation, + currentApprovalContext, + withApprovalContext, }; -const actionContext = new AsyncLocalStorage(); -export const withApprovalContext = ( - context: ApprovalContinuation, - run: () => Promise, -): Promise => actionContext.run(context, run); -export const currentApprovalContext = () => actionContext.getStore(); export type ApprovalCandidate = { actorId: string; diff --git a/server/src/approvals/wiring.ts b/server/src/approvals/wiring.ts new file mode 100644 index 000000000..14aa31d4c --- /dev/null +++ b/server/src/approvals/wiring.ts @@ -0,0 +1,172 @@ +import { isDeepStrictEqual } from "node:util"; +import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; +import type { IntelligenceLike } from "@openbot/toolkit/run-turn"; +import { type RunAssertion, readRunAssertion } from "../agents/callback-token"; +import type { AgentProfileStore } from "../agents/profile-store"; +import type { AgentActor } from "../agents/profile-types"; +import { type AuditInitiator, PERSON_INITIATOR } from "../audit"; +import type { DeploymentConfig } from "../config"; +import type { Database } from "../db/client"; +import type { DeliveryScope } from "../delivery/types"; +import type { ReviewModel } from "./policy"; +import { createApprovalQuestions, type PersonQuestion } from "./questions"; +import { createApprovalService } from "./service"; +import { ApprovalRefusedError, type ApprovalStore } from "./types"; + +/** + * The deployment's approval service: the stored approvals, told the person's channel when one + * opens; the question store; and a computer tool resumed only when its call is really saved in the + * conversation it claims to come from. + * + * Moved out of index.ts verbatim. `notify` is taken as a function because delivery is assigned after + * this is created, and a notice reads it when an approval opens, never when this is built. + */ +export function createServerApprovalService(deps: { + storedApprovals: ApprovalStore; + config: Pick; + sourceForCoordinationRun: ( + run: RunAssertion, + ) => Promise<{ channelId: string; botId: string } | null>; + agentProfileStore: AgentProfileStore; + actorFor: (ownerUserId: string) => Promise; + database: Database; + authoriseQuestion: (question: PersonQuestion) => Promise; + routineIntelligence: Pick; + approvalComputerTools: ( + ownerUserId: string, + agentId: string, + initiator: AuditInitiator, + ) => Promise; + review: ReviewModel; + notify: ( + scope: DeliveryScope, + input: { id: string; kind: "approval"; requestId: string; text: string }, + ) => Promise; +}) { + const { + storedApprovals, + config, + sourceForCoordinationRun, + agentProfileStore, + actorFor, + database, + authoriseQuestion, + routineIntelligence, + approvalComputerTools, + } = deps; + return createApprovalService( + { + ...storedApprovals, + open: async (action) => { + const request = await storedApprovals.open(action); + if (request.status === "pending") { + const forwarded = action.continuation?.forwardedProps; + const signed = + forwarded && + typeof forwarded === "object" && + "openbotRun" in forwarded + ? readRunAssertion(forwarded.openbotRun, config.keyEncryptionKey) + : null; + const run = + signed && + signed.actorId === action.actorId && + signed.botId === action.botId + ? signed + : { + actorId: action.actorId, + botId: action.botId, + runId: action.runId, + threadId: action.threadId, + depth: 0, + }; + const source = await sourceForCoordinationRun(run); + const botName = source + ? (( + await agentProfileStore + .get(await actorFor(action.actorId), action.botId) + .catch(() => null) + )?.name ?? action.botId) + : action.botId; + if (source && run.threadId) + await deps.notify( + { + ownerUserId: action.actorId, + channelId: source.channelId, + agentId: source.botId, + threadId: run.threadId, + }, + { + id: request.id, + kind: "approval", + requestId: request.id, + text: `${botName} asks for approval to use ${action.toolRef}. Open the Bot's page in OpenBot to review this action.`, + }, + ); + } + return request; + }, + }, + createApprovalQuestions(database, authoriseQuestion), + async (ownerUserId, botId, snapshot) => { + const source = await sourceForCoordinationRun({ + actorId: ownerUserId, + botId, + threadId: snapshot.threadId, + runId: snapshot.runId, + depth: 0, + initiator: PERSON_INITIATOR, + }); + if (!source) + throw new ApprovalRefusedError( + "That conversation is no longer available to this Bot and person.", + ); + // Intelligence saves a streaming run's messages a moment behind the stream, and the browser runs + // the tool as soon as the call arrives. A call that is not saved yet is waited for briefly; one + // that never appears is refused below. + let history = await routineIntelligence.getThreadMessages({ + threadId: snapshot.threadId, + userId: ownerUserId, + }); + const findCall = () => + history.messages + .flatMap((message) => message.toolCalls ?? []) + .find((call) => call.id === snapshot.toolCallId); + for (let attempt = 0; !findCall() && attempt < 10; attempt++) { + await Bun.sleep(500); + history = await routineIntelligence.getThreadMessages({ + threadId: snapshot.threadId, + userId: ownerUserId, + }); + } + const canonicalCall = findCall(); + if ( + !canonicalCall || + canonicalCall.name !== snapshot.toolName || + !isDeepStrictEqual(JSON.parse(canonicalCall.args), snapshot.args) || + history.messages.some( + (message) => + message.role === "tool" && + message.toolCallId === snapshot.toolCallId, + ) + ) + throw new ApprovalRefusedError( + "That unanswered tool call is not saved in this conversation.", + ); + const tools = await approvalComputerTools( + ownerUserId, + botId, + PERSON_INITIATOR, + ); + const tool = tools.find( + (tool) => tool.definition.name === snapshot.toolName, + ); + if (!tool) + throw new ApprovalRefusedError("That computer tool is not available."); + return tool.execute(snapshot.args, { + toolCallId: snapshot.toolCallId, + signal: new AbortController().signal, + }); + }, + { review: deps.review }, + ); +} diff --git a/server/src/audit.ts b/server/src/audit.ts index 8c2ffab31..356e0f0a8 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -13,6 +13,7 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import type { Database } from "./db/client"; import { auditEvents } from "./db/schema"; import { parsePageLimit } from "./paging"; +import type { AuditInitiator } from "@openbot/toolkit/initiator"; const sensitiveKeys = new Set([ "access_token", @@ -556,14 +557,7 @@ export const auditEventTypes = [ export type AuditEventType = (typeof auditEventTypes)[number]; -/** What caused a row, where `actorUserId` is only whose authority it borrowed. */ -export type AuditInitiator = - | { kind: "person" } - | { kind: "deployment" } - | { kind: "routine"; id: string } - | { kind: "responsibility"; id: string } - | { kind: "memory"; id: string } - | { kind: "handoff"; id: string; origin?: AuditInitiator }; +export type { AuditInitiator }; export const PERSON_INITIATOR: AuditInitiator = { kind: "person" }; @@ -705,6 +699,20 @@ export async function recordAuditEvent( }); } +/** + * Where code that should not hold the store writes to the trail. + * + * A sink rather than the store, so that code never touches the database. Written through + * `recordAuditEvent`, which redacts the payload the same way every other write does. + */ +export type AuditSink = { + record: (event: AuditEventInput) => Promise; +}; + +export function auditSink(store: AuditStore): AuditSink { + return { record: (event) => recordAuditEvent(store, event) }; +} + function initiatorColumns(initiator: AuditInitiator | undefined) { if (!initiator) return { initiatorKind: "person" as const, initiatorId: null }; diff --git a/server/src/channels/attachment-mime.ts b/server/src/channels/attachment-mime.ts index 7435e5f6c..2d7cea144 100644 --- a/server/src/channels/attachment-mime.ts +++ b/server/src/channels/attachment-mime.ts @@ -3,7 +3,7 @@ import { ACCEPTED_TEXT_MIME, mediaTypeOf, namesNoFormat, -} from "../../../shared/attachments"; +} from "@openbot/shared/attachments"; /** * The text claims this function will hand back under their own name. diff --git a/server/src/channels/attachment-parts.ts b/server/src/channels/attachment-parts.ts index 15f72ad32..6882c1ef3 100644 --- a/server/src/channels/attachment-parts.ts +++ b/server/src/channels/attachment-parts.ts @@ -2,7 +2,7 @@ import { attachmentUrl, classifyAttachment, MAX_EXTRACTED_CHARACTERS, -} from "../../../shared/attachments"; +} from "@openbot/shared/attachments"; /** * What `load` hands back for an attachment id: the bytes and the metadata @@ -520,7 +520,7 @@ async function resolvePart( * For an older message it must not. History is replayed in full on every * turn, so a row that vanished once would fail this channel's every future * turn, for ever, with no recovery but starting another channel — the same - * shape as the dangling tool call in `agents/history-sanitize.ts`, found in + * shape as the dangling tool call in `toolkit/history-sanitize.ts`, found in * production twice: a permanent failure grown out of transient damage, and * nothing the person did wrong. That file's answer is this one's. History is * CONTEXT for a turn, not a transaction to resume; the attachment is already diff --git a/server/src/channels/attachments.ts b/server/src/channels/attachments.ts index 19c5e4797..98ecc28e6 100644 --- a/server/src/channels/attachments.ts +++ b/server/src/channels/attachments.ts @@ -25,7 +25,7 @@ import { MAX_FILE_BYTES, MAX_IMAGE_BYTES, namesNoFormat, -} from "../../../shared/attachments"; +} from "@openbot/shared/attachments"; import type { StoredAttachment } from "./attachment-parts"; import { sniffMimeType } from "./attachment-mime"; diff --git a/server/src/channels/group.ts b/server/src/channels/group.ts index 6712b9859..23f594a35 100644 --- a/server/src/channels/group.ts +++ b/server/src/channels/group.ts @@ -3,7 +3,7 @@ * * An Intelligence thread admits exactly one agent, so a channel with two Bots cannot be one thread * with two speakers. Each Bot gets a thread of its own in the group (`group_bot_threads`), and every - * turn runs through the ordinary headless AG-UI path (`routines/run-turn.ts`) into that thread, so + * turn runs through the ordinary headless AG-UI path (`toolkit/run-turn.ts`) into that thread, so * learning, governance, approvals and analytics apply to a group turn exactly as they do to a routine. * What the people in the channel read is `group_messages`: the shared transcript, one row per person * message and one per Bot reply, each attributed to its speaker. @@ -33,7 +33,6 @@ import { recordAuditEvent, } from "../audit"; import type { AppVariables } from "../auth/guards"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; import type { Database } from "../db/client"; import { channelMemberships, @@ -44,7 +43,8 @@ import { import { groupBotThreads, groupMessages } from "../db/schema/group"; import { workItems } from "../db/schema/work"; import type { CheckPrivateShare } from "../proactive/private-share"; -import type { TurnRunner } from "../routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import type { WorkQueue } from "../work/queue"; export const GROUP_TURN_KIND = "group.turn"; diff --git a/server/src/channels/summary.ts b/server/src/channels/summary.ts index c1a464240..69af215d1 100644 --- a/server/src/channels/summary.ts +++ b/server/src/channels/summary.ts @@ -8,7 +8,7 @@ * event, so a missed sweep costs two seconds where a missed event would cost the name entirely. */ import { and, asc, eq, isNull, notExists, sql } from "drizzle-orm"; -import { readFiring } from "../../../shared/routine-firing"; +import { readFiring } from "@openbot/shared/routine-firing"; import type { Database } from "../db/client"; import { channelMemberships, diff --git a/server/src/components/headless.ts b/server/src/components/headless.ts index cc001e14c..9ef4f70ae 100644 --- a/server/src/components/headless.ts +++ b/server/src/components/headless.ts @@ -1,11 +1,8 @@ +import { NOT_SHOWN, NOT_SHOWN_TAIL } from "@openbot/shared/component-markers"; import { z } from "zod"; -import { NOT_SHOWN, NOT_SHOWN_TAIL } from "../../../shared/component-markers"; -import { - type AuditInitiator, - type AuditStore, - recordAuditEvent, -} from "../audit"; -import type { HeadlessTool } from "../computer/headless-tools"; +import type { AuditInitiator, AuditSink } from "../audit"; +import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; +import type { ComponentCatalog } from "@openbot/toolkit/ports"; import type { ComponentStore } from "./store"; /** @@ -330,6 +327,28 @@ export function notShown(title: string, reason: string): string { return `${NOT_SHOWN}${title}. ${reason}${NOT_SHOWN_TAIL}`; } +/** + * The governed gallery as a headless turn sees it: every component's name, and the tools for one + * Bot, decided by the component store and refused into the audit trail. + */ +export function headlessComponentCatalog(options: { + store: ComponentStore; + audit?: AuditSink; +}): ComponentCatalog { + const { store, audit } = options; + return { + names: new Set(HEADLESS_COMPONENTS.map(({ name }) => name)), + toolsForTurn: ({ agentId, ownerUserId, initiator }) => + createHeadlessComponentTools({ + store, + botId: agentId, + ownerUserId, + initiator, + ...(audit ? { audit } : {}), + }), + }; +} + /** * The component tools for one headless turn, bound to the Bot and its owner outside the arguments. * @@ -343,7 +362,7 @@ export async function createHeadlessComponentTools(options: { botId: string; ownerUserId: string; initiator: AuditInitiator; - auditStore?: AuditStore; + audit?: AuditSink; specs?: readonly HeadlessComponentSpec[]; }): Promise { const { @@ -351,7 +370,7 @@ export async function createHeadlessComponentTools(options: { botId, ownerUserId, initiator, - auditStore, + audit, specs = HEADLESS_COMPONENTS, } = options; const held = new Map( @@ -366,8 +385,8 @@ export async function createHeadlessComponentTools(options: { reason: string, extra: Record = {}, ) => { - if (auditStore) - await recordAuditEvent(auditStore, { + if (audit) + await audit.record({ eventType: extra.function ? "component.function_refused" : "component.refused", diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index 74171d434..9ddf7a46f 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -1,4 +1,4 @@ -import type { HandoffRequest } from "../../../shared/computer-control"; +import type { HandoffRequest } from "@openbot/shared/computer-control"; import type { NavigateResult } from "./schema"; import { checkNavigationTarget } from "./target"; diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 4cb9812b3..141ac3503 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -24,6 +24,7 @@ import { type AuditStore, recordAuditEvent, } from "../audit"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { withUntrustedNotice } from "../untrusted-content"; import { ComputerStoppedError, @@ -31,7 +32,6 @@ import { createComputerTransport, StaleSnapshotError, } from "./client"; -import { HeadlessToolSuspension } from "./headless-tools"; import { checkComputerAddress } from "./target"; export { diff --git a/server/src/computer/headless-tools.ts b/server/src/computer/headless-tools.ts index 4194d0a82..0036785d8 100644 --- a/server/src/computer/headless-tools.ts +++ b/server/src/computer/headless-tools.ts @@ -1,25 +1,10 @@ -import { - AbstractAgent, - type BaseEvent, - EventType, - type Message, - Middleware, - type RunAgentInput, - type RunFinishedEvent, - type Tool, - type ToolCall, -} from "@ag-ui/client"; -import { Observable, type Subscription } from "rxjs"; import { z } from "zod"; +import { currentApprovalContext } from "../approvals/types"; +import { type AuditStore, recordAuditEvent } from "../audit"; import { - currentApprovalContext, - withApprovalContext, -} from "../approvals/types"; -import { - type AuditInitiator, - type AuditStore, - recordAuditEvent, -} from "../audit"; + type HeadlessTool, + HeadlessToolSuspension, +} from "@openbot/toolkit/headless-tools"; import { type ActionActor, type ComputerGateway, @@ -27,329 +12,6 @@ import { } from "./gateway"; import type { ControlState } from "./schema"; -/** Client tools supplied by OpenBot when the caller has no browser. */ -export type HeadlessTool = { - definition: Tool; - /** - * Executed when called but never offered to the model. For a tool the Bot does not hold, so a - * call it makes anyway gets a governed refusal rather than going unanswered. - */ - hidden?: boolean; - execute( - args: unknown, - context: { toolCallId: string; signal: AbortSignal }, - ): Promise; -}; - -export type HeadlessWaiting = { - kind: string; - requestId?: string; - [key: string]: unknown; -}; - -/** A durable request remains open; continuing the Bot would invent a human answer. */ -export class HeadlessToolSuspension extends Error { - constructor( - message: string, - readonly waiting: HeadlessWaiting, - ) { - super(message); - this.name = "HeadlessToolSuspension"; - } -} - -const MAX_TOOL_SEGMENTS = 30; - -/** - * Executes AG-UI client tools inside the Intelligence run. Backend results are - * forwarded unchanged; only newly emitted unanswered calls can be executed. - */ -export class HeadlessToolsMiddleware extends Middleware { - constructor( - private readonly tools: HeadlessTool[], - private readonly initiator?: AuditInitiator, - ) { - super(); - const names = tools.map((tool) => tool.definition.name); - if (new Set(names).size !== names.length) - throw new Error("Headless tool names must be unique."); - } - - run( - originalInput: RunAgentInput, - next: AbstractAgent, - ): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - const tools = new Map( - this.tools.map((tool) => [tool.definition.name, tool]), - ); - const executed = new Set(); - let subscription: Subscription | undefined; - let started = false; - const work = async () => { - let request: RunAgentInput = { - ...originalInput, - tools: [ - ...originalInput.tools.filter((tool) => !tools.has(tool.name)), - ...this.tools - .filter((tool) => !tool.hidden) - .map((tool) => tool.definition), - ], - }; - for (let segment = 0; segment <= MAX_TOOL_SEGMENTS; segment += 1) { - if (subscriber.closed) return; - const historical = new Set( - request.messages.flatMap((message) => - message.role === "assistant" - ? (message.toolCalls ?? []).map((call) => call.id) - : [], - ), - ); - const emitted = new Set(); - let messages = request.messages; - let state = request.state; - let terminal: RunFinishedEvent | undefined; - let failed = false; - await new Promise((resolve, reject) => { - subscription = this.runNextWithState( - request, - new InvocationTransport(next, request), - ).subscribe({ - next: ({ - event, - messages: updatedMessages, - state: updatedState, - }) => { - messages = updatedMessages; - state = updatedState; - if (event.type === EventType.TOOL_CALL_START) - emitted.add(String(event.toolCallId)); - if (event.type === EventType.MESSAGES_SNAPSHOT) { - for (const message of messages) { - if (message.role !== "assistant") continue; - for (const call of message.toolCalls ?? []) { - if (!historical.has(call.id)) emitted.add(call.id); - } - } - } - if (event.type === EventType.RUN_FINISHED) { - terminal = event as RunFinishedEvent; - return; - } - if (event.type === EventType.RUN_STARTED) { - if (started) return; - started = true; - } - if (event.type === EventType.RUN_ERROR) failed = true; - subscriber.next(event); - }, - error: reject, - complete: resolve, - }); - }); - if (subscriber.closed || failed) return; - if (!terminal) - throw new Error( - "The headless agent ended without a terminal event.", - ); - const answered = new Set( - messages.flatMap((message) => - message.role === "tool" ? [message.toolCallId] : [], - ), - ); - const pending: ToolCall[] = messages.flatMap((message) => - message.role === "assistant" - ? (message.toolCalls ?? []).filter( - (call) => - emitted.has(call.id) && - !historical.has(call.id) && - !answered.has(call.id) && - !executed.has(call.id), - ) - : [], - ); - const owned = pending.filter( - (call) => - tools.has(call.function.name) || - call.function.name.startsWith("computer_"), - ); - if (owned.length === 0) { - subscriber.next(terminal); - return; - } - if (segment === MAX_TOOL_SEGMENTS) - throw new Error( - "The headless tool continuation limit was reached.", - ); - const results = new Map(); - for (const call of owned) { - if (subscriber.closed) return; - executed.add(call.id); - let content: string; - let error: string | undefined; - try { - const tool = tools.get(call.function.name); - if (!tool) - throw new Error( - `The tool ${call.function.name} is unavailable for this headless turn.`, - ); - const args: unknown = JSON.parse(call.function.arguments || "{}"); - const continuation = { - runId: request.runId, - threadId: request.threadId, - toolCallId: call.id, - toolName: call.function.name, - args, - messages, - state, - context: request.context, - forwardedProps: request.forwardedProps, - initiator: this.initiator, - }; - const result = await withApprovalContext(continuation, () => - tool.execute(args, { - toolCallId: call.id, - signal: controller.signal, - }), - ); - content = - typeof result === "string" ? result : JSON.stringify(result); - if (content === undefined) - throw new Error( - `The tool ${call.function.name} returned no result.`, - ); - } catch (failure) { - if (failure instanceof HeadlessToolSuspension) { - failure.waiting.continuation ??= { - runId: request.runId, - threadId: request.threadId, - toolCallId: call.id, - toolName: call.function.name, - args: JSON.parse(call.function.arguments || "{}"), - messages, - state, - context: request.context, - forwardedProps: request.forwardedProps, - initiator: this.initiator, - }; - subscriber.next({ - type: EventType.CUSTOM, - name: "openbot.headless.waiting", - value: { - ...failure.waiting, - message: failure.message, - toolCallId: call.id, - }, - }); - subscriber.next({ - ...terminal, - outcome: { - type: "interrupt", - interrupts: [ - { - id: call.id, - toolCallId: call.id, - reason: "human_input", - message: failure.message, - metadata: { openbotWaiting: failure.waiting }, - }, - ], - }, - }); - return; - } - error = - failure instanceof Error ? failure.message : String(failure); - content = JSON.stringify({ ok: false, error }); - } - if (subscriber.closed) return; - const message: Message = { - id: crypto.randomUUID(), - role: "tool", - toolCallId: call.id, - content, - ...(error ? { error } : {}), - }; - messages = [...messages, message]; - results.set(call.id, content); - // AG-UI 1.0's TOOL_CALL_RESULT has no `error` field and validation strips one; the - // refusal travels in `content`, and on the tool message, which keeps `error`. - subscriber.next({ - type: EventType.TOOL_CALL_RESULT, - messageId: message.id, - toolCallId: call.id, - role: "tool", - content, - }); - } - const interrupts = - terminal.outcome?.type === "interrupt" - ? terminal.outcome.interrupts - : []; - const unresolved = interrupts.filter( - (interrupt) => !results.has(interrupt.toolCallId ?? interrupt.id), - ); - if (owned.length !== pending.length || unresolved.length > 0) { - subscriber.next( - unresolved.length > 0 - ? { - ...terminal, - outcome: { type: "interrupt", interrupts: unresolved }, - } - : terminal, - ); - return; - } - request = { - ...request, - messages, - state, - resume: - interrupts.length > 0 - ? interrupts.map((interrupt) => ({ - interruptId: interrupt.id, - status: "resolved" as const, - payload: results.get(interrupt.toolCallId ?? interrupt.id), - })) - : undefined, - }; - } - }; - // Observable teardown owns cancellation; both asynchronous exits are observed. - work().then( - () => subscriber.complete(), - (error: unknown) => { - if (subscriber.closed) return; - subscriber.next({ - type: EventType.RUN_ERROR, - code: "OPENBOT_HEADLESS_TOOL_ERROR", - message: error instanceof Error ? error.message : String(error), - }); - subscriber.complete(); - }, - ); - return () => { - controller.abort(); - subscription?.unsubscribe(); - }; - }); - } -} - -/** AG-UI accumulation reads transport history, so seed each continuation explicitly. */ -class InvocationTransport extends AbstractAgent { - constructor( - private readonly transport: AbstractAgent, - input: RunAgentInput, - ) { - super({ initialMessages: input.messages, initialState: input.state }); - } - run(input: RunAgentInput) { - return this.transport.run(input); - } -} - /** What the headless sign-in tool needs from the passwords module. See passwords/service.ts. */ export type HeadlessSignInRequester = { request(input: { diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index af9b4b201..181de1666 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -1,6 +1,6 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; -import { downloadHeaders } from "../../../shared/file-download"; +import { downloadHeaders } from "@openbot/shared/file-download"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AuditReader } from "../audit"; import type { AppVariables } from "../auth/guards"; diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index 7c0272fd3..4836eaed9 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -1,7 +1,7 @@ import type { BrowserChallenge, ComputerControlState, -} from "../../../shared/computer-control"; +} from "@openbot/shared/computer-control"; /** * The computer-use contract. * diff --git a/server/src/config.ts b/server/src/config.ts index df9f2b036..337100055 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -6,7 +6,7 @@ import { isLearningContainerId, type LearningTarget, -} from "../../shared/learning"; +} from "@openbot/shared/learning"; import { singleUserEnabled } from "./auth/dev-actor"; import { normalizeDomain } from "./auth/email-domain"; import { organizationAuthority } from "./auth/organization"; diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 3ccd5a8f9..5c5bca798 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1,32 +1,39 @@ -import type { BaseEvent, Message, RunAgentInput } from "@ag-ui/client"; -import { AbstractAgent, HttpAgent } from "@ag-ui/client"; +import type { Message, RunAgentInput } from "@ag-ui/client"; +import { type AbstractAgent, HttpAgent } from "@ag-ui/client"; import type { BuiltInAgentConfiguration } from "@copilotkit/runtime/v2"; import { - BuiltInAgent, - CopilotKitIntelligence, + type CopilotKitIntelligence, CopilotRuntime, } from "@copilotkit/runtime/v2"; import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; -import type { Observable } from "rxjs"; -import { defer, finalize, from, fromEvent, switchMap, takeUntil } from "rxjs"; +import { PROVENANCE_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { defaultModelFor } from "@openbot/shared/model-providers"; +import { + BuiltInAgentWithSaneHistory, + CloningRemoteAgent, + IntelligenceKnowingANewThread, + RunBuiltAgent, + UnavailableAgent, +} from "@openbot/toolkit/copilotkit/agents"; +import { createIntelligenceThreadLock } from "@openbot/toolkit/copilotkit/thread-lock"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { parametersFor } from "@openbot/toolkit/granted-tool"; +import { HeadlessToolsMiddleware } from "@openbot/toolkit/headless-tools"; +import { sanitizeSeededHistory } from "@openbot/toolkit/history-sanitize"; +import { executeNativeApprovalTool } from "@openbot/toolkit/native-approval"; +import { historyOrEmpty } from "@openbot/toolkit/thread-history"; +import { defer, from, switchMap } from "rxjs"; import { z } from "zod"; -import { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt"; import { type ActorAgentResolver, CoworkerUnavailableError, } from "./agents/agent-resolver"; -import { sanitizeSeededHistory } from "./agents/history-sanitize"; import { - PLAN_RUN_COMPLETED, PlanModel, type PlanModelConfig, planModelForEnvironment, } from "./agents/plan-model"; import type { AgentActor } from "./agents/profile-types"; -import { - executeNativeApprovalTool, - nativeApprovalRun, -} from "./approvals/native-context"; import type { AuditInitiator } from "./audit"; import { attachmentIdsIn, @@ -35,7 +42,6 @@ import { type StoredAttachment, } from "./channels/attachment-parts"; import type { AgentFetch, StallGuard } from "./channels/stall-guard"; -import { HeadlessToolsMiddleware } from "./computer/headless-tools"; import type { DeploymentConfig } from "./config"; import { observeModelConnection } from "./desktop-connection-failure"; import { desktopTelemetryProperties } from "./desktop-telemetry"; @@ -59,8 +65,7 @@ import { SELECTION_FLOOR, selectTools, } from "./plugins/selection"; -import type { GrantedTool } from "./plugins/tools"; -import { grantedToolGuidance, parametersFor } from "./plugins/tools"; +import { grantedToolGuidance } from "./plugins/tools"; import { UNTRUSTED_GUIDANCE } from "./untrusted-content"; /** @@ -136,8 +141,6 @@ type AgentRunInput = Parameters[0]; type AgentMessage = AgentRunInput["messages"][number]; type AgentContext = NonNullable[number]; export type StandingRoleMessage = Extract; -type AgentHeaders = Record; -type HeaderBearingAgent = AbstractAgent & { headers?: AgentHeaders }; /** The durable part of a coworker: who it is and what its standing job is. */ export type AgentStandingProfile = { @@ -203,6 +206,18 @@ export function normalizeModelBaseUrls( } } +/** + * The OpenAI model the built-in Bot falls back to when someone switches it to OpenAI and names none. + * + * DELIBERATELY NOT `defaultModelFor("openai")`, which is `gpt-5.5`. That default is for the remote + * Bots: `agent-bot` and several framework Bots speak `/v1/chat/completions`, where the 5.6 models + * refuse function tools (`agent-bot/src/index.ts`, and `docker-compose.yml` on `AGENT_BOT_MODEL`). + * The built-in Bot speaks the Responses API, so it can run the newer model, and matches the tenant + * package's own default (`examples/fintech/model.yaml`). The Anthropic fallback has no such split and + * is read from the shared spec. + */ +const BUILT_IN_OPENAI_FALLBACK_MODEL = "gpt-5.6-terra"; + export function runtimeModelForEnvironment( packageModel: RuntimeModel, environment: Record = process.env, @@ -221,8 +236,8 @@ export function runtimeModelForEnvironment( provider === packageModel.provider ? packageModel.defaultModel : provider === "anthropic" - ? "claude-sonnet-4-5" - : "gpt-5.6-terra"; + ? defaultModelFor("anthropic") + : BUILT_IN_OPENAI_FALLBACK_MODEL; const selectedModelApplies = provider === "anthropic" || ((!selectedProvider || selectedProvider === "openai") && @@ -692,7 +707,7 @@ export type MarkAttachmentsSent = ( * * OLDER MESSAGES DEGRADE INSTEAD. This maps over the WHOLE history, and history is replayed on every * turn, so one vanished row failing here would fail this channel's every future turn for ever — the - * same geometry as the dangling tool call in `agents/history-sanitize.ts`, which opens by recording + * same geometry as the dangling tool call in `toolkit/history-sanitize.ts`, which opens by recording * that exact failure found in production twice: a permanent failure grown out of transient damage, * and nothing the person did wrong. It is reachable the same way, too: a send whose run is stopped * before the load never stamps `attachedAt`, and the sweeper deletes the row a day later. So an @@ -982,8 +997,20 @@ async function buildAgent( : undefined, learnedSkills, ), - loadAttachment, - markAttachmentsSent, + { + observe: observeModelConnection, + ...(loadAttachment + ? { + inline: (messages: AgentRunInput["messages"], threadId: string) => + inlineAttachments( + messages, + loadAttachment, + threadId, + markAttachmentsSent, + ), + } + : {}), + }, ); const whole = withTools(granted); @@ -1532,299 +1559,6 @@ function mastraOpenBotContext({ ]; } -class CloningRemoteAgent extends AbstractAgent { - headers?: AgentHeaders; - private readonly remote: HeaderBearingAgent; - - constructor( - remote: AbstractAgent, - private readonly attachOpenBotMiddleware: (target: AbstractAgent) => void, - ) { - super({ - agentId: remote.agentId, - description: remote.description, - threadId: remote.threadId, - initialMessages: remote.messages, - initialState: remote.state, - debug: remote.debug, - }); - this.remote = remote as HeaderBearingAgent; - if (this.remote.headers) { - this.headers = { ...this.remote.headers }; - } - this.attachOpenBotMiddleware(this); - } - - run(input: RunAgentInput): Observable { - if (this.headers) { - this.remote.headers = { ...this.headers }; - } - return this.remote.run(input); - } - - async getCapabilities() { - return this.remote.getCapabilities?.() ?? {}; - } - - abortRun(): void { - this.remote.abortRun(); - super.abortRun(); - } - - clone() { - const clonedRemote = this.remote.clone() as AbstractAgent; - const clone = new CloningRemoteAgent( - clonedRemote, - this.attachOpenBotMiddleware, - ); - if (this.headers) { - clone.headers = { ...this.headers }; - } - return clone; - } -} - -/** - * A built-in Bot that will not hand the model provider a conversation it is going to refuse. - * - * FOUND LIVE, ON CHAT. One person's next three messages each failed with - * `AI_MissingToolResultsError: Tool result is missing for tool call chatcmpl-tool-8dd56dc7497c5ea9`, - * thrown out of the AI SDK's `convertToLanguageModelPrompt`. A frontend tool handler had been torn - * down while its call was open, so the agent's live messages in the browser carried an assistant - * message whose tool call never got a result. The durable store did not have it, nothing was going - * to answer it, and every retry sent it straight back up as `input.messages`. The conversation was - * finished until the person worked out for themselves to start another one. - * - * The guard has to be on this side of `run`. `BuiltInAgent.run` converts `input.messages` itself, - * with no seam in between, so wrapping the agent is the only place left to stand. The reasoning for - * why a dangling call is DROPPED rather than repaired, and why ids are never changed, is in - * `agents/history-sanitize.ts`, where the routines path found the same failure first. - * - * A RESUMED CALL IS NOT A DANGLE. `run` appends a tool result for each `input.resume` entry by - * `interruptId` AFTER converting the messages, so a call that a resume is about to answer must - * survive this pass or the appended result lands on nothing. - */ -class BuiltInAgentWithSaneHistory extends BuiltInAgent { - /** - * The configuration, held a second time because the base class keeps its own copy private and - * {@link clone} has to build another one of THIS class rather than of the base. - */ - private readonly configuration: BuiltInAgentConfiguration; - /** - * How this Bot's attached files are inlined, or absent to leave them alone. - * - * Held for the same reason {@link configuration} is: {@link clone} builds another one of THIS - * class and everything the run depends on has to survive that. - */ - private readonly loadAttachment: LoadAttachment | undefined; - /** - * How this Bot records that the files on the message it is answering were sent, or absent to - * record nothing. Held for the reason {@link loadAttachment} is: {@link clone} builds another one - * of THIS class, and a seam lost in a clone is a seam that never runs. - */ - private readonly markAttachmentsSent: MarkAttachmentsSent | undefined; - - constructor( - configuration: BuiltInAgentConfiguration, - loadAttachment?: LoadAttachment, - markAttachmentsSent?: MarkAttachmentsSent, - ) { - super(configuration); - this.configuration = configuration; - this.loadAttachment = loadAttachment; - this.markAttachmentsSent = markAttachmentsSent; - } - - run(input: RunAgentInput): Observable { - const answeredByResume = new Set( - (input.resume ?? []).map((entry) => entry.interruptId), - ); - const history = sanitizeSeededHistory(input.messages, answeredByResume); - const load = this.loadAttachment; - /* - * ALONGSIDE THE GUARD ABOVE, NOT INSTEAD OF IT. One drops a conversation the model provider is - * going to refuse; this replaces the stored reference a person's attachment arrives as with the - * bytes themselves, because `BuiltInAgent.run` converts `input.messages` with no seam in - * between and a `/api/attachments/` URL is not something a model provider will go and fetch. - * - * Nothing to load means nothing to inline, and the run goes up exactly as it did before any of - * this existed. - */ - if (!load) - return nativeApprovalRun({ ...input, messages: history }, () => - observeModelConnection(super.run({ ...input, messages: history })), - ); - /* - * Deferred, because `run` has to answer with a stream straight away and reading the bytes is a - * database round trip. `defer` puts that read on the subscription, which is where the run - * actually begins, so nothing is fetched until somebody is listening. - */ - return defer(() => - from( - inlineAttachments( - history, - load, - // As above: the run's own conversation, not the actor's channels at large. - input.threadId, - this.markAttachmentsSent, - ), - ).pipe( - switchMap((messages) => - nativeApprovalRun({ ...input, messages }, () => - observeModelConnection(super.run({ ...input, messages })), - ), - ), - ), - ); - } - - /** - * Carried by hand, for the same reason {@link RunBuiltAgent.clone} is. - * - * The runtime clones an agent before every run, and the base class's clone hard-codes - * `new BuiltInAgent(this.config)`: inherited unchanged, the very first message anybody sends - * would go through an agent that does none of the above. The middleware list is copied because - * the base clone copies it, and it is reached through a cast because `AbstractAgent` declares it - * private. Nothing registers middleware on a built-in Bot today, and this is here so that the day - * something does, it is not lost in a clone. - */ - clone(): BuiltInAgentWithSaneHistory { - const cloned = new BuiltInAgentWithSaneHistory( - this.configuration, - this.loadAttachment, - this.markAttachmentsSent, - ); - type WithMiddlewares = { middlewares: unknown[] }; - (cloned as unknown as WithMiddlewares).middlewares = [ - ...(this as unknown as WithMiddlewares).middlewares, - ]; - return cloned; - } -} - -/** - * An agent whose tools are decided when the run starts, because that is the first moment anybody - * knows what the run is about, and who is asking on whose behalf. - * - * WHY A WRAPPER AND NOT A NARROWER `loadTools`. Tools are resolved per request, and a request is - * earlier than a run: at that point there is a Bot and a person and no message, so there is nothing - * to select against. `run(input)` is the first place the message exists. Both underlying agents take - * their tools at construction — a built-in one in its configuration, a remote one in the middleware - * that sends them — so the only way to hand either a set chosen from the message is to build it - * after the message arrives. That is all this does: it defers `build` to the first subscription and - * then gets out of the way. - * - * The deferral is per subscription, so a retried run reselects rather than reusing a decision made - * for a message that is no longer the last one. - */ -class RunBuiltAgent extends AbstractAgent { - /** - * Stop must reach the pending build as well as the eventual model. Keep both in one per-run - * record so a late build or teardown cannot replace the next run's cancellation target. - */ - private active?: { controller: AbortController; inner?: AbstractAgent }; - /** The same Bot with nothing narrowed, kept to answer questions that are not about one run. */ - private whole: AbstractAgent; - private build: ( - input: RunAgentInput, - signal: AbortSignal, - ) => Promise; - - constructor( - identity: { agentId: string; description: string }, - whole: AbstractAgent, - build: ( - input: RunAgentInput, - signal: AbortSignal, - ) => Promise, - ) { - super(identity); - this.whole = whole; - this.build = build; - } - - run(input: RunAgentInput): Observable { - return defer(() => { - const active: NonNullable = { - controller: new AbortController(), - }; - this.active = active; - const { signal } = active.controller; - return defer(() => this.build(input, signal)).pipe( - switchMap((agent) => { - signal.throwIfAborted(); - active.inner = agent; - return agent.run(input); - }), - // Settle Stop even if a collaborator ignores the signal or rejects after cancellation. - takeUntil(fromEvent(signal, "abort")), - finalize(() => { - if (this.active === active) this.active = undefined; - active.controller.abort(PLAN_RUN_COMPLETED); - }), - ); - }); - } - - /** - * What the Bot can do, answered from the un-narrowed agent. - * - * Capabilities are asked for outside a run, where there is no message and so nothing to select - * against. They are also a fact about the Bot rather than about one turn: a deployment that - * narrowed this run to three tools has not stopped supporting whatever the underlying agent - * supports. - */ - getCapabilities() { - return this.whole.getCapabilities?.() ?? Promise.resolve({}); - } - - /** - * Carried by hand, because `AbstractAgent.clone` does not know this class exists. - * - * It builds a bare object on the prototype and copies a fixed list of base fields onto it, so - * every field declared here arrives `undefined`. The runtime clones an agent before every run - * (`agents[agentId].clone()`), which means the omission is not a corner case: without this, the - * first message anybody sends fails on a `build` that is not a function. - */ - clone(): RunBuiltAgent { - const cloned = super.clone() as RunBuiltAgent; - cloned.whole = this.whole; - cloned.build = this.build; - // A clone owns its cancellation state, including while its build is pending. - cloned.active = undefined; - return cloned; - } - - abortRun(): void { - const active = this.active; - active?.controller.abort(); - active?.inner?.abortRun(); - super.abortRun(); - } -} - -class UnavailableAgent extends AbstractAgent { - private reason: string; - - constructor(agent: RegisteredUnavailableAgent) { - super({ agentId: agent.id, description: agent.name }); - this.reason = agent.reason; - } - - clone(): UnavailableAgent { - const cloned = super.clone() as UnavailableAgent; - // The runtime clones before running; the base clone only carries base-class fields. - cloned.reason = this.reason; - return cloned; - } - - // Refused here rather than at the endpoint: a deleted coworker has no endpoint worth contacting, - // and the person is owed the reason rather than a transport error. - run(): never { - throw new Error(this.reason); - } -} - export async function resolveRuntimeAgents( loadAgents: () => Promise, model: RuntimeModel, @@ -1958,83 +1692,6 @@ export function createRequestAgents( * reachable on the next request. Resolving once at boot would mean every new Bot needed a restart, * which is not a property you can explain to somebody who just created one. */ -/** - * Whether this failure means "the platform has never heard of that thread". - * - * A thread id is minted before the thread exists — the platform creates it on the first run — so - * reading history on a brand-new conversation is the normal opening move, and the platform answers - * `THREAD_NOT_FOUND` with a 404. The runtime's own handler catches everything and returns a bare 500, - * so every new chat produced one, with a stack trace behind it. - * - * Matched on the shape rather than with `instanceof`. The class is `PlatformRequestError` and it - * carries `.status` for exactly this — its own documentation gives `error.status === 404` as the - * example — but it is not re-exported from `@copilotkit/runtime/v2`, and the package's `exports` map - * offers no subpath that reaches it, so there is no type to test against. The name is set by the - * constructor and the status is a number on the instance; both are checked, so an unrelated error - * carrying a `status` of 404 does not qualify. - * - * 404 ONLY, and nothing wider. A 500 from the platform means an outage or a bad key, and answering - * that with an empty history would tell the browser the conversation is gone and invite somebody to - * start it over. That is the failure this must not introduce while removing the noisy one. - */ -export function isMissingThread(error: unknown): boolean { - return ( - error instanceof Error && - error.name === "PlatformRequestError" && - (error as { status?: unknown }).status === 404 - ); -} - -/** - * Read a thread's history, treating a thread the platform does not know about as having none. - * - * Takes the read as a function rather than being folded into the class below, so the decision can be - * exercised against a function that really throws. The previous attempt at this fix - * (#71) was tested by re-implementing its middleware inside the test file, which passes with the real - * code deleted; this is the actual code path in both places. - */ -export async function historyOrEmpty( - read: () => Promise, - whenMissing: T, -): Promise { - try { - return await read(); - } catch (error) { - if (isMissingThread(error)) return whenMissing; - throw error; - } -} - -/** - * The platform client, with one answer corrected. - * - * A subclass rather than a wrapper. The runtime is handed this object and calls many methods on it, - * and the base class keeps its state in `#private` fields. Extending keeps every other method on - * the instance that owns those fields. The authentication observer separately binds methods to - * that original instance, so it preserves this override and its private-field receiver. - * - * `getThreadMessages` is the only override. `handleGetThreadMessages` in the runtime calls it and - * returns `Response.json` of whatever comes back, so an empty history here is the `{ messages: [] }` - * the browser expects and a 200 instead of a 500. - */ -class IntelligenceKnowingANewThread extends CopilotKitIntelligence { - override getThreadMessages( - params: Parameters[0], - ) { - return historyOrEmpty(() => super.getThreadMessages(params), { - messages: [], - }); - } -} - -/** - * How long a conversation's lock is held before it lapses on its own. - * - * Matches the platform's own default rather than picking a number: this is renewed while a Bot works, - * so what it really sets is how long a conversation stays stuck after a process dies mid-run. - */ -const THREAD_LOCK_TTL_SECONDS = 120; - export function mountCopilotRuntime( config: DeploymentConfig, resolver: ActorAgentResolver, @@ -2175,83 +1832,11 @@ export function mountCopilotRuntime( url: intelligenceClient.ɵgetRunnerWsUrl(), authToken: intelligenceClient.ɵgetRunnerAuthToken(), }), - /** - * The conversation's run lock, as the platform issues it. - * - * ONE RUN AT A TIME PER CONVERSATION. Taken before anything is streamed, because the gateway - * checks every event against the run the lock names: a run that skips this is claiming to be one - * nobody was told about, and every event is refused. That refusal reads like a platform - * limitation and is a missing step. - * - * A conversation somebody else is already running in refuses rather than queues, which is right: - * the caller waits and tries again rather than two Bots writing over each other. - */ - threadLock: { - acquire: async (input: { - threadId: string; - runId: string; - userId: string; - agentId: string; - }) => { - try { - const learningContainerId = await learning?.containerForThread(input); - await intelligenceClient.getOrCreateThread({ - ...input, - ...(learningContainerId ? { learningContainerId } : {}), - }); - const held = await intelligenceClient.ɵacquireThreadLock({ - ...input, - ...(learningContainerId ? { learningContainerId } : {}), - }); - // A run started on this thread. Side effect only, never awaited: a channel showing it is - // working is worth nothing next to the lock the run depends on. - try { - onRunBusy?.({ threadId: input.threadId, busy: true }); - } catch {} - /* - * The run id only. The lock also hands back a join token, which is what a browser presents - * to watch the conversation; the runner's socket has its own credential and passing this - * one in place of it means a socket that is refused and a run that never starts. See the - * note on `runner.run` in handoff-delivery.ts. - */ - return { runId: held.runId }; - } catch (error) { - /* - * ONLY A CONFLICT MEANS "NOT NOW". Everything else is raised. - * - * A conversation somebody is already running in answers 409, and that is ordinary: the hop - * waits and is tried again. Anything else is not — a platform that cannot be reached, a - * token that stopped working, or one of the underscored APIs below being renamed by a - * routine version bump. Returned as `null` those all read as contention: every hop retries - * to exhaustion, every person is told their question was never answered, and the only - * evidence is a warning line that looks like a busy conversation. - * - * Raised, the runner writes the real reason onto `agent.handoff_failed`, and the sentence - * the person eventually gets names it. - */ - const status = - error instanceof Error && "status" in error - ? (error as { status?: unknown }).status - : undefined; - if (status === 409) return null; - throw error; - } - }, - renew: async (input: { threadId: string; runId: string }) => { - await intelligenceClient.ɵrenewThreadLock({ - ...input, - ttlSeconds: THREAD_LOCK_TTL_SECONDS, - }); - }, - release: async (input: { threadId: string; runId: string }) => { - // The run on this thread is over. Cleared here rather than trusting a browser: the run may - // have outlived the tab that started it, and this is where the platform is told it ended. - try { - onRunBusy?.({ threadId: input.threadId, busy: false }); - } catch {} - await intelligenceClient.ɵcleanupThreadLock(input); - }, - }, + threadLock: createIntelligenceThreadLock({ + client: intelligenceClient, + learning, + onRunBusy, + }), agentFor, /** * A thread's messages, as the platform holds them. diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 529394a24..de42b60a1 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -12,7 +12,7 @@ import { uniqueIndex, uuid, } from "drizzle-orm/pg-core"; -import type { UserPreferences } from "../../../../shared/user-preferences"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; // NOT drizzle's `jsonb`: that one serialises, and so does the driver, so every object landed as a // JSON string and nothing in this database could be queried by a JSON field. See ./json.ts. import { jsonb } from "./json"; diff --git a/server/src/db/schema/learning.ts b/server/src/db/schema/learning.ts index a2d30d1f9..25009d63b 100644 --- a/server/src/db/schema/learning.ts +++ b/server/src/db/schema/learning.ts @@ -1,5 +1,5 @@ import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; -import type { LearningSettings } from "../../../../shared/learning"; +import type { LearningSettings } from "@openbot/shared/learning"; import { jsonb } from "./json"; export const learningSettings = pgTable("learning_settings", { diff --git a/server/src/db/schema/responsibilities.ts b/server/src/db/schema/responsibilities.ts index 75d929372..91a8d03bb 100644 --- a/server/src/db/schema/responsibilities.ts +++ b/server/src/db/schema/responsibilities.ts @@ -8,7 +8,6 @@ import { uniqueIndex, uuid, } from "drizzle-orm/pg-core"; -import type { HeadlessWaiting } from "../../computer/headless-tools"; import type { TriggerConfig, TriggerKind, @@ -18,6 +17,7 @@ import type { ResponsibilitySource, ResponsibilitySubscription, } from "../../responsibilities/types"; +import type { HeadlessWaiting } from "@openbot/toolkit/headless-tools"; import { agents, credentials, users } from "./core"; import { jsonb } from "./json"; diff --git a/server/src/db/schema/voice.ts b/server/src/db/schema/voice.ts index 472ae6b95..2fb99449e 100644 --- a/server/src/db/schema/voice.ts +++ b/server/src/db/schema/voice.ts @@ -7,7 +7,7 @@ import { text, timestamp, } from "drizzle-orm/pg-core"; -import type { VoiceTranscriptEntry } from "../../../../shared/voice-session"; +import type { VoiceTranscriptEntry } from "@openbot/shared/voice-session"; import { channels, users } from "./core"; import { jsonb } from "./json"; diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index f00f55d85..74f6e246c 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -13,7 +13,7 @@ import { ingestSlackEvent, type SlackTriggerEvent, } from "../responsibilities/slack"; -import type { DrawnComponent } from "../routines/runner"; +import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { agUiStream, approvalFields, diff --git a/server/src/delivery/opentag.ts b/server/src/delivery/opentag.ts index 76deba6ec..05a62d5fd 100644 --- a/server/src/delivery/opentag.ts +++ b/server/src/delivery/opentag.ts @@ -19,6 +19,7 @@ */ import { timingSafeEqual } from "node:crypto"; import { z } from "zod"; +import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; import { type BindingTransport, type ChatTransport, @@ -116,7 +117,7 @@ export function createOpenTagTransport(config: OpenTagConfig) { try { response = await wire(new URL("/openbot/deliver", config.url), { method: "POST", - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(DELIVERY_REQUEST_TIMEOUT_MS), headers: { authorization: bearer, "content-type": "application/json", diff --git a/server/src/delivery/push.ts b/server/src/delivery/push.ts index 8c9746e4d..eb66ed459 100644 --- a/server/src/delivery/push.ts +++ b/server/src/delivery/push.ts @@ -1,3 +1,4 @@ +import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; import { DeliveryProviderError, type ProviderFetch, @@ -16,7 +17,7 @@ export function createExpoPushTransport( try { response = await wire(`https://exp.host/--/api/v2/push/${method}`, { method: "POST", - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(DELIVERY_REQUEST_TIMEOUT_MS), headers: { "content-type": "application/json", ...(config.accessToken diff --git a/server/src/delivery/router.ts b/server/src/delivery/router.ts index e7f0dc534..6786209ae 100644 --- a/server/src/delivery/router.ts +++ b/server/src/delivery/router.ts @@ -1,5 +1,5 @@ import { resolveUpdateRoute, type UpdateRoute } from "../agents/lifecycle"; -import type { DrawnComponent } from "../routines/runner"; +import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import type { WorkItem, WorkQueue } from "../work/queue"; import type { ExpoPushTransport } from "./push"; import type { DeliveryStore } from "./store"; diff --git a/server/src/delivery/twilio.ts b/server/src/delivery/twilio.ts index 9dbd02462..fac14f904 100644 --- a/server/src/delivery/twilio.ts +++ b/server/src/delivery/twilio.ts @@ -1,4 +1,5 @@ import { validateRequest } from "twilio"; +import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; import { DeliveryProviderError, type ProviderFetch, @@ -67,7 +68,7 @@ export function createTwilioTransport(config: TwilioConfig) { try { response = await wire(url, { method: "POST", - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(DELIVERY_REQUEST_TIMEOUT_MS), headers: { authorization: `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64")}`, "content-type": "application/x-www-form-urlencoded", diff --git a/server/src/dictation/routes.ts b/server/src/dictation/routes.ts index 6a35bb720..a593607b5 100644 --- a/server/src/dictation/routes.ts +++ b/server/src/dictation/routes.ts @@ -5,7 +5,7 @@ import { DICTATION_TIMEOUT_MS, dictationMediaType, MAX_DICTATION_BYTES, -} from "../../../shared/dictation"; +} from "@openbot/shared/dictation"; import type { AppVariables } from "../auth/guards"; import { TranscriptionError, type TranscriptionProvider } from "./provider"; diff --git a/server/src/host-access/tools.ts b/server/src/host-access/tools.ts index b2eccc016..386368a2d 100644 --- a/server/src/host-access/tools.ts +++ b/server/src/host-access/tools.ts @@ -1,12 +1,13 @@ import { z } from "zod"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; import { type AuditInitiator, type AuditStore, recordAuditEvent, } from "../audit"; -import { REFUSAL_MARKER, type GrantedTool } from "../plugins/tools"; -import { HostAccessRefusedError, type HostAccessBroker } from "./broker"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { REFUSAL_MARKER } from "../plugins/tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import { type HostAccessBroker, HostAccessRefusedError } from "./broker"; const empty = z.object({}); diff --git a/server/src/http-server.ts b/server/src/http-server.ts new file mode 100644 index 000000000..d60f52844 --- /dev/null +++ b/server/src/http-server.ts @@ -0,0 +1,273 @@ +import { DICTATION_HTTP_IDLE_SECONDS } from "@openbot/shared/dictation"; +import { serve } from "bun"; +import { computerAccessCheck } from "./agents/computer-access"; +import type { AgentProfileStore } from "./agents/profile-store"; +import type { OpenBotRole } from "./auth/roles"; +import { websocket as channelSocket } from "./channels/socket"; +import type { ComputerGateway } from "./computer/gateway"; +import { streamPathBotId } from "./computer/stream-path"; +import type { DeploymentConfig } from "./config"; +import type { DemonstrationRecorder } from "./demonstrations/recording"; +import type { DemonstrationStore } from "./demonstrations/store"; + +/** + * The server's front door: one Bun server that hands ordinary requests to the Hono app, proxies the + * computer's live stream, and carries channel activity over websockets. + * + * Moved out of index.ts verbatim, with what it reads passed in. + */ + +/** What each proxied socket carries: where to connect inward, and the socket once opened. */ +type StreamData = { + upstream: string; + ownerUserId: string; + botId: string; + recordingId?: string; + inward?: WebSocket; +}; + +/** + * Bun takes exactly one WebSocket handler for the server, and two features need one: the app proxies + * the computer stream, and it pushes channel activity through Hono's adapter. So this one + * dispatches on what the upgrade attached, a proxy socket carries `upstream`, a Hono socket does + * not, rather than either feature quietly taking the slot and breaking the other on connect. + */ +type ChannelSocket = Parameters[0]; +type SocketData = StreamData | ChannelSocket["data"]; + +export function startHttpServer(deps: { + port: number; + config: DeploymentConfig; + resolveRequestActor: ( + request: Request, + ) => Promise<{ id: string; name: string; role: OpenBotRole }>; + agentProfileStore: AgentProfileStore; + computerGateway: ComputerGateway | undefined; + demonstrationStore: DemonstrationStore; + demonstrationRecorder: DemonstrationRecorder; + app: { + fetch: ( + request: Request, + env: { server: unknown }, + ) => Response | Promise; + }; +}): void { + const { + port, + config, + resolveRequestActor, + agentProfileStore, + computerGateway, + demonstrationStore, + demonstrationRecorder, + app, + } = deps; + + /** + * The live screen, proxied. + * + * Proxied rather than connected directly. `agent-computer` authenticates its callers with a + * shared token, not with a person's session, and it must never be reachable from a browser. So the + * socket terminates here, behind the same session guard as every other route, and this process opens + * a second socket inward carrying the token. + * + * Not a Hono route because an upgrade is not a request/response: Bun hands it over before Hono sees a + * body, so it is handled in `fetch` ahead of the app. + */ + const toStreamUrl = (baseUrl: string, botId: string) => + // The Bot travels in the query, because a websocket upgrade carries no custom header for the + // computer to read and every call it serves is per Bot. The secret travels the same way and for the + // same reason, this socket is the one a person can type into, so it is the last thing that should + // be reachable without it. + `${baseUrl.replace(/^http/, "ws").replace(/\/$/, "")}/stream?bot=${encodeURIComponent(botId)}&token=${encodeURIComponent(config.computer?.token ?? "")}`; + + const isProxiedStream = (data: SocketData): data is StreamData => + typeof (data as StreamData).upstream === "string"; + + // Hono owns the socket's data once it has upgraded it; this hands its own back to it. + const asChannelSocket = (ws: { data: SocketData }) => + ws as unknown as ChannelSocket; + + serve({ + port, + async fetch(request, server) { + const url = new URL(request.url); + if (url.pathname === "/api/audio/transcriptions") { + server.timeout(request, DICTATION_HTTP_IDLE_SECONDS); + } + if (url.pathname === "/api/voice/calls") server.timeout(request, 30); + if (url.pathname === "/api/voice/sessions") server.timeout(request, 30); + const streamBotId = streamPathBotId(url.pathname); + if ( + streamBotId !== null && + request.headers.get("upgrade")?.toLowerCase() === "websocket" + ) { + if (!config.computer) { + return new Response("No computer is configured.", { status: 503 }); + } + // The session guard, applied by hand because middleware does not run on an upgrade. An + // unauthenticated socket here would be the whole point of the proxy defeated. + const actor = await resolveRequestActor(request).catch(() => null); + if (!actor) { + return new Response("Sign in first.", { status: 401 }); + } + // And which Bot, which the guard above does not answer. This socket carries that Bot's screen, + // so signing in is not enough: without this, anybody signed in watches anybody's Bot work. And + // seeing a Team Bot is not enough either: its screen is its owner's signed-in browser. + if ( + !(await computerAccessCheck(agentProfileStore)( + { id: actor.id, role: actor.role }, + streamBotId, + )) + ) { + return new Response("There is no such Bot.", { status: 404 }); + } + /* + * Through the gateway, not the provider. + * + * `gateway.locate` runs checkComputerAddress; `provider.locate` does not, and the URL built + * below carries COMPUTER_TOKEN in its query string. A provider that answered with a foreign + * host was handed the deployment's computer token, which is the case that check was written + * for. Every acting path already went through the gateway; this one did not. + */ + let upstream: string; + try { + const streamBase = computerGateway + ? await computerGateway.locate(streamBotId) + : undefined; + if (!streamBase) { + return new Response("No computer address is configured.", { + status: 503, + }); + } + upstream = toStreamUrl(streamBase, streamBotId); + } catch (error) { + // Said out loud rather than falling back to another Bot's computer, which is the failure this + // whole path exists to prevent. + return new Response( + error instanceof Error + ? error.message + : "That Bot's computer could not be reached.", + { status: 502 }, + ); + } + const recording = await demonstrationStore.active( + actor.id, + streamBotId, + ); + if (recording) + upstream += `&recording=${encodeURIComponent(recording.id)}`; + if ( + server.upgrade(request, { + data: { + upstream, + ownerUserId: actor.id, + botId: streamBotId, + ...(recording ? { recordingId: recording.id } : {}), + }, + }) + ) { + return undefined as unknown as Response; + } + return new Response("Expected a WebSocket upgrade.", { status: 400 }); + } + return app.fetch(request, { server }); + }, + websocket: { + open(ws) { + if (!isProxiedStream(ws.data)) { + channelSocket.open(asChannelSocket(ws)); + return; + } + const stream = ws.data; + const inward = new WebSocket(stream.upstream); + stream.inward = inward; + // Frames outward, input inward. Buffered by neither side: a frame the browser is too slow for + // should be dropped, not queued, because a stale frame is worse than a missing one. + inward.onmessage = (event) => { + if (stream.recordingId) { + let recorded: unknown; + try { + recorded = JSON.parse(String(event.data)); + } catch { + recorded = null; + } + if ( + recorded && + typeof recorded === "object" && + "type" in recorded && + recorded.type === "demonstration.action" + ) { + const data = stream; + const message = recorded as { + recordingId?: unknown; + action?: unknown; + }; + if (message.recordingId !== data.recordingId) { + ws.send( + JSON.stringify({ + type: "error", + error: + "The recording identity changed. Stop and restart recording.", + }), + ); + return; + } + demonstrationRecorder + .capture( + data.ownerUserId, + data.botId, + data.recordingId ?? "", + message.action, + ) + .catch((error) => { + console.error( + JSON.stringify({ + type: "demonstration-capture-error", + error: + error instanceof Error ? error.name : "UnknownError", + context: { recordingId: data.recordingId }, + timestamp: new Date().toISOString(), + }), + ); + try { + ws.send( + JSON.stringify({ + type: "error", + error: + "A browser step could not be saved. Stop and review the demonstration before continuing.", + }), + ); + } catch { + /* A closed viewer cannot receive the already logged failure. */ + } + }); + return; + } + } + try { + ws.send(String(event.data)); + } catch { + inward.close(); + } + }; + inward.onclose = () => ws.close(); + inward.onerror = () => ws.close(); + }, + message(ws, raw) { + if (!isProxiedStream(ws.data)) { + channelSocket.message(asChannelSocket(ws), raw); + return; + } + if (ws.data.inward?.readyState === 1) ws.data.inward.send(String(raw)); + }, + close(ws, code, reason) { + if (!isProxiedStream(ws.data)) { + channelSocket.close(asChannelSocket(ws), code, reason); + return; + } + ws.data.inward?.close(); + }, + }, + }); +} diff --git a/server/src/identity/github-oauth.ts b/server/src/identity/github-oauth.ts index 9057c00c0..5d0412dda 100644 --- a/server/src/identity/github-oauth.ts +++ b/server/src/identity/github-oauth.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { seal, unseal } from "../auth/signed-value"; +import { OAUTH_REQUEST_TIMEOUT_MS } from "../timeouts"; /** * Signing a person in with the deployment's GitHub App. @@ -13,7 +14,6 @@ export const GITHUB_CALLBACK_PATH = "/api/identity/github/callback"; const STATE_LABEL = "identity-github-connect"; const STATE_TTL_MS = 10 * 60_000; -const REQUEST_TIMEOUT_MS = 15_000; const AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; const TOKEN_URL = "https://github.com/login/oauth/access_token"; @@ -131,7 +131,7 @@ export async function exchangeGithubCode( }), // The request carries a client secret and a code; a redirect is a refusal, not a detour. redirect: "manual", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS), }); } catch { throw new GithubOAuthError("GitHub could not be reached"); @@ -184,7 +184,7 @@ export async function fetchGithubUser( "X-GitHub-Api-Version": "2022-11-28", }, redirect: "manual", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS), }); } catch { throw new GithubOAuthError("GitHub could not be reached"); diff --git a/server/src/index.ts b/server/src/index.ts index df02710e5..1a519b93c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,24 +1,19 @@ -import { createHash, randomUUID } from "node:crypto"; -import { isDeepStrictEqual } from "node:util"; import type { AbstractAgent } from "@ag-ui/client"; import { CopilotKitIntelligence, IntelligenceAgentRunner, } from "@copilotkit/runtime/v2"; -import { serve } from "bun"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { COMPUTER_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { workOwner } from "@openbot/shared/work-owner"; +import { createTurnRunner } from "@openbot/toolkit/run-turn"; +import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; -import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; -import { DICTATION_HTTP_IDLE_SECONDS } from "../../shared/dictation"; -import { buildResponsibilityTurn } from "../../shared/responsibility-turn"; -import { workOwner } from "../../shared/work-owner"; import { createActivityStore } from "./activity/activity"; import { auditRoutineStore, enterpriseControls, guardDeliveryStore, guardHostAccess, - headlessTurnRefusal, installEnterpriseControls, } from "./admin/controls"; import { @@ -31,17 +26,12 @@ import { readApprovedRunAssertion, readRunAssertion, } from "./agents/callback-token"; -import { canUseComputer, computerAccessCheck } from "./agents/computer-access"; +import { canUseComputer } from "./agents/computer-access"; +import { wireCoordination } from "./agents/coordination-wiring"; import { createAgentFetch } from "./agents/endpoint"; -import { createHandoffDesk, HANDOFF_KIND } from "./agents/handoff"; -import { createHandoffDelivery } from "./agents/handoff-delivery"; +import { createHandoffDesk } from "./agents/handoff"; import { createHandoffRunner } from "./agents/handoff-runner"; -import { signHandoffDeliveryRun } from "./agents/handoff-signing"; -import { - authoriseCoordinationRun, - createCoordinationTools, - readCoordinationHandoffClaim, -} from "./agents/handoff-tool"; +import { startHandoffDelivery } from "./agents/handoff-wiring"; import { configureBotLifecycle, createBotLifecycleStore, @@ -50,6 +40,7 @@ import { createBotReset } from "./agents/lifecycle-reset"; import { createAgentProfileStore } from "./agents/profile-store"; import type { AgentActor } from "./agents/profile-types"; import { createRuntimeAgentLoader } from "./agents/runtime-agents"; +import { serverTurnAdmission } from "./agents/turn-admission"; import { createWakeUpRunner, createWakeUpStore, @@ -57,33 +48,21 @@ import { } from "./agents/wake-up"; import { createApp } from "./app"; import { createApprovedActionExecutor } from "./approvals/execute"; +import { createPersonResponseTick } from "./approvals/person-response-sweep"; import { - PERSON_QUESTION_WAITING, - resumePersonQuestion, -} from "./approvals/question-resume"; -import { - createApprovalQuestions, type PersonQuestion, - personQuestionSchema, questionConversationBot, } from "./approvals/questions"; -import { - type ApprovalResumeDependencies, - createApprovalService, -} from "./approvals/service"; +import { createApprovalResumeTick } from "./approvals/resume-sweep"; import { createApprovalStore } from "./approvals/store"; -import { - ApprovalRefusedError, - currentApprovalContext, - parseApprovalContinuation, - parseApprovalResult, -} from "./approvals/types"; +import { ApprovalRefusedError } from "./approvals/types"; +import { createServerApprovalService } from "./approvals/wiring"; import { type AuditInitiator, + auditSink, createAuditReader, createAuditStore, DEPLOYMENT_INITIATOR, - handoffInitiator, PERSON_INITIATOR, recordAuditEvent, } from "./audit"; @@ -110,7 +89,6 @@ import { groupSourceForRun, } from "./channels/group"; import { type ChannelStore, createChannelStore } from "./channels/routes"; -import { websocket as channelSocket } from "./channels/socket"; import { createStallGuard } from "./channels/stall-guard"; import { forgetSettledSummaries, @@ -119,13 +97,11 @@ import { } from "./channels/summary"; import { createThreadIdentity } from "./channels/thread-identity"; import { createChannelTitler } from "./channels/titler"; +import { headlessComponentCatalog } from "./components/headless"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerGateway } from "./computer/gateway"; -import { - createHeadlessComputerTools, - HeadlessToolSuspension, -} from "./computer/headless-tools"; +import { createHeadlessComputerTools } from "./computer/headless-tools"; import { createPageFrameStore } from "./computer/page-frames"; import { startPolicyListener } from "./computer/policy-listener"; import { withPolicyPushOnWake } from "./computer/policy-network"; @@ -138,7 +114,6 @@ import { describeComputerIsolation, } from "./computer/provider"; import { createSnapshotStore } from "./computer/snapshot-store"; -import { streamPathBotId } from "./computer/stream-path"; import { loadConfig } from "./config"; import { type IdentifyActor, @@ -159,8 +134,6 @@ import { channelMemberships, channels, intelligenceChannelMappings, - responsibilityRuns, - routineRuns, } from "./db/schema"; import { createDeliveryRouter, DELIVERY_WORK_KINDS } from "./delivery/router"; import { @@ -177,6 +150,7 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; +import { startHttpServer } from "./http-server"; import { githubCallbackRoutes } from "./identity/github-callback"; import { retireIdentityLinks, retireOwnedAccounts } from "./identity/retire"; import { slackCodeRedeemer } from "./identity/slack-redeem"; @@ -185,7 +159,6 @@ import { createIntelligenceClient, observeIntelligenceAuthentication, } from "./intelligence-client"; -import { clearLearningRevisionFallback } from "./learning/runtime"; import { createLearningAutoAssign, probeLearningContainer, @@ -198,6 +171,7 @@ import { keyIntelligence, learningSetupInstructions, } from "./learning/ensure-container"; +import { clearLearningRevisionFallback } from "./learning/runtime"; import { createLearningSettingsStore } from "./learning/settings"; import { createMemoryDefaults } from "./memory/defaults"; import { createMemoryIngestion } from "./memory/ingestion"; @@ -247,10 +221,6 @@ import { fetchText as fetchSnsText, inboundEmailConfigFromEnv, } from "./responsibilities/email"; -import { - createResponsibilityEngine, - type ResponsibilityEngine, -} from "./responsibilities/engine"; import { createSlackTriggerIngest, slackAccessFrom, @@ -263,7 +233,7 @@ import { createTriggerIngressRoutes } from "./responsibilities/trigger-routes"; import { useTriggerTools } from "./responsibilities/trigger-tools"; import { createTriggerStore } from "./responsibilities/triggers"; import type { Responsibility } from "./responsibilities/types"; -import { createTurnRunner } from "./routines/run-turn"; +import { createServerResponsibilityEngine } from "./responsibilities/wiring"; import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; @@ -276,18 +246,12 @@ import { loadTenantPackage, synchronizeTenantPackage, } from "./tenant-package"; -import { markUntrusted } from "./untrusted-content"; import { createUserInstructionsStore } from "./user-instructions"; import { createUserPreferencesStore } from "./user-preferences"; import { createVoiceSessionStore } from "./voice/sessions"; import { createVoiceSummarizer } from "./voice/summary"; import { repeatAfterEach } from "./work/loop"; -import { - createWorkQueue, - DEFAULT_MAX_ATTEMPTS, - startWorkOfferedListener, - type WorkOfferedListener, -} from "./work/queue"; +import { createWorkQueue, type WorkOfferedListener } from "./work/queue"; /** * Who is asking, for a CopilotKit request. @@ -578,6 +542,11 @@ const policyListener = await startPolicyListener( * unavailable, and the row is a note for a reader rather than something the server depends on. */ const bootAuditStore = createAuditStore(database); +// The gallery every headless turn offers its Bot, refusals written to the same trail. +const componentCatalog = headlessComponentCatalog({ + store: componentStore, + audit: auditSink(bootAuditStore), +}); // One store: the gateway writes through it, a route reads it, and the sweep below takes the old ones out. const pageFrameStore = createPageFrameStore(database); let deliveryNotifications: ( @@ -591,6 +560,64 @@ let deliveryNotifications: ( ) => Promise = async () => { throw new Error("Delivery is not initialized."); }; + +/** + * Who a routine acts as, resolved the way {@link resolveRequestActor} resolves it. + * + * THE ROLE IS READ, NOT ASSUMED. Which coworkers exist is decided per person and an administrator + * sees Bots a user does not, so hardcoding `role: "user"` here would hide an administrator's own Bots + * from their own routine — the routine would fail with "that Bot is no longer registered" for a Bot + * sitting in front of them in chat. This asks the same repository the request path asks, so a routine + * sees exactly the coworkers its owner sees. + */ +const actorFor = async (ownerUserId: string): Promise => { + // One person, and they are an administrator. The id stays the routine owner's rather than being + // rewritten to DEV_ACTOR's: in this mode they are the same person, and if they ever were not, + // silently borrowing the dev actor's identity would be worse than finding nothing. + if (config.singleUser) return { id: ownerUserId, role: DEV_ACTOR.role }; + const roles = await roleRepository.rolesForUser(ownerUserId); + if (!roles.includes("admin") && !roles.includes("user")) { + throw new Error("A routine requires an authorized owner."); + } + return { + id: ownerUserId, + role: roles.includes("admin") ? "admin" : "user", + }; +}; +const channelSourceForRun = async (run: RunAssertion) => { + if (!run.threadId) return null; + const sources = await database + .select({ channelId: channels.id, botId: channelAgents.agentId }) + .from(intelligenceChannelMappings) + .innerJoin(channels, eq(channels.id, intelligenceChannelMappings.channelId)) + .innerJoin( + channelMemberships, + and( + eq(channelMemberships.channelId, channels.id), + eq(channelMemberships.userId, run.actorId), + ), + ) + .innerJoin(channelAgents, eq(channelAgents.channelId, channels.id)) + .where( + and( + eq(intelligenceChannelMappings.threadId, run.threadId), + eq(intelligenceChannelMappings.userId, run.actorId), + isNull(channels.deletedAt), + ), + ); + return ( + sources.find((source) => source.botId === run.botId) ?? + (run.handoff ? (sources[0] ?? null) : null) + ); +}; + +/* + * A run's source conversation: a channel's own thread, or a Bot's thread in a group conversation. + * The group lookup applies the same live-membership and own-Bot checks. See channels/group.ts. + */ +const sourceForCoordinationRun = async (run: RunAssertion) => + (await channelSourceForRun(run)) ?? groupSourceForRun(database, run); + const authoriseQuestion = async (question: PersonQuestion) => { const source = await sourceForCoordinationRun({ ...question, @@ -612,120 +639,85 @@ const authoriseQuestion = async (question: PersonQuestion) => { throw new ApprovalRefusedError("That Bot is no longer available to you."); }; const storedApprovals = createApprovalStore(database); -const approvalService = createApprovalService( - { - ...storedApprovals, - open: async (action) => { - const request = await storedApprovals.open(action); - if (request.status === "pending") { - const forwarded = action.continuation?.forwardedProps; - const signed = - forwarded && - typeof forwarded === "object" && - "openbotRun" in forwarded - ? readRunAssertion(forwarded.openbotRun, config.keyEncryptionKey) - : null; - const run = - signed && - signed.actorId === action.actorId && - signed.botId === action.botId - ? signed - : { - actorId: action.actorId, - botId: action.botId, - runId: action.runId, - threadId: action.threadId, - depth: 0, - }; - const source = await sourceForCoordinationRun(run); - const botName = source - ? (( - await agentProfileStore - .get(await actorFor(action.actorId), action.botId) - .catch(() => null) - )?.name ?? action.botId) - : action.botId; - if (source && run.threadId) - await deliveryNotifications( - { - ownerUserId: action.actorId, - channelId: source.channelId, - agentId: source.botId, - threadId: run.threadId, - }, - { - id: request.id, - kind: "approval", - requestId: request.id, - text: `${botName} asks for approval to use ${action.toolRef}. Open the Bot's page in OpenBot to review this action.`, - }, - ); - } - return request; - }, - }, - createApprovalQuestions(database, authoriseQuestion), - async (ownerUserId, botId, snapshot) => { - const source = await sourceForCoordinationRun({ - actorId: ownerUserId, - botId, - threadId: snapshot.threadId, - runId: snapshot.runId, - depth: 0, - initiator: PERSON_INITIATOR, - }); - if (!source) - throw new ApprovalRefusedError( - "That conversation is no longer available to this Bot and person.", - ); - // Intelligence saves a streaming run's messages a moment behind the stream, and the browser runs - // the tool as soon as the call arrives. A call that is not saved yet is waited for briefly; one - // that never appears is refused below. - let history = await routineIntelligence.getThreadMessages({ - threadId: snapshot.threadId, - userId: ownerUserId, - }); - const findCall = () => - history.messages - .flatMap((message) => message.toolCalls ?? []) - .find((call) => call.id === snapshot.toolCallId); - for (let attempt = 0; !findCall() && attempt < 10; attempt++) { - await Bun.sleep(500); - history = await routineIntelligence.getThreadMessages({ - threadId: snapshot.threadId, - userId: ownerUserId, - }); - } - const canonicalCall = findCall(); - if ( - !canonicalCall || - canonicalCall.name !== snapshot.toolName || - !isDeepStrictEqual(JSON.parse(canonicalCall.args), snapshot.args) || - history.messages.some( - (message) => - message.role === "tool" && message.toolCallId === snapshot.toolCallId, - ) - ) - throw new ApprovalRefusedError( - "That unanswered tool call is not saved in this conversation.", - ); - const tools = await approvalComputerTools( - ownerUserId, - botId, - PERSON_INITIATOR, - ); - const tool = tools.find( - (tool) => tool.definition.name === snapshot.toolName, - ); - if (!tool) - throw new ApprovalRefusedError("That computer tool is not available."); - return tool.execute(snapshot.args, { - toolCallId: snapshot.toolCallId, - signal: new AbortController().signal, - }); - }, - { review: (prompt, signal) => chooseSkills(prompt, signal) }, +// Before anything reads the model environment: blank base URLs removed, Anthropic given its `/v1`. +normalizeModelBaseUrls(); +const runtimeModel = runtimeModelForEnvironment(tenantPackage.model); + +/** + * Pass one of tool selection: which skills a message needs, on the deployment's own model. + * + * Built once rather than per request, because it holds nothing about a person: the key is resolved + * on every call, so a credential rotated a moment ago is used by the next run. + */ +const chooseSkills = createModelCompleter({ + model: runtimeModel, + resolveApiKey: () => + resolveModelApiKey({ + encryptionKey: config.keyEncryptionKey, + reader: credentialStore, + provider: runtimeModel.provider, + keyId: tenantPackage.model.credentialSecretRef, + environment: process.env, + }), +}); + +/* + * The pair a headless turn is driven through, built ONCE. + * + * Not the runtime's own pair: `mountCopilotRuntime` keeps its client and its runner inside + * `CopilotRuntime` and hands neither back, and reaching into that object would be a worse seam than + * building our own from the same three settings. Built from `config.runtime.intelligence`, which is + * required and not optional — `RuntimeCapabilities` has exactly one mode and every Intelligence field + * with it (`config.ts:10-22`), and `loadConfig` refuses to boot without them — so there is no + * not-in-Intelligence-mode branch to write here. If a second mode is ever added, THIS is the line that + * has to grow a guard, and the routine runner must then be left off `createApp` entirely. + * + * One runner for the process, reused across firings: it opens a socket per run and holds no idle + * connection, but its `threads` map is per instance, and a runner per turn would fragment the + * already-running check that keeps two turns off one thread. See `toolkit/run-turn.ts`. + */ +const routineIntelligence = observeIntelligenceAuthentication( + new CopilotKitIntelligence({ + apiUrl: config.runtime.intelligence.apiUrl, + wsUrl: config.runtime.intelligence.gatewayWsUrl, + apiKey: config.runtime.intelligence.apiKey, + }), ); +const approvalComputerTools = async ( + ownerUserId: string, + agentId: string, + initiator: AuditInitiator, +) => { + const actor = await actorFor(ownerUserId); + const profile = await agentProfileStore.get(actor, agentId); + if (!profile) + throw new ApprovalRefusedError("That Bot is no longer available to you."); + // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. + return computerGateway && canUseComputer(profile, actor) + ? createHeadlessComputerTools({ + gateway: computerGateway, + botId: agentId, + actor: { id: actor.id, userId: actor.id, initiator }, + auditStore: bootAuditStore, + ...(signInService ? { signIn: signInService } : {}), + }) + : []; +}; + +const approvalService = createServerApprovalService({ + storedApprovals, + config, + sourceForCoordinationRun, + agentProfileStore, + actorFor, + database, + authoriseQuestion, + routineIntelligence, + approvalComputerTools, + review: (prompt, signal) => chooseSkills(prompt, signal), + // Assigned further down, once delivery is wired. + notify: (scope, input) => deliveryNotifications(scope, input), +}); // Housekeeping on a schedule: audit rows when asked for, screenshots always, one timer. See audit-retention.ts. const retentionSweeps = startRetentionSweeps( config.databaseUrl, @@ -928,6 +920,8 @@ const responsibilityBindings = createResponsibilityBindingStore(database, { reader: credentialStore, encryptionKey: config.keyEncryptionKey, }); +const deliveryStore = createDeliveryStore(database); + /* * Event triggers. Every kind authenticates with its vendor's own scheme and then goes through the * responsibility engine's one ingest (event + run + queue in one transaction), so dispatch is the @@ -1111,9 +1105,6 @@ const stallGuard = createStallGuard({ auditStore: bootAuditStore, }); -normalizeModelBaseUrls(); -const runtimeModel = runtimeModelForEnvironment(tenantPackage.model); - const intentRouter = createIntentRouter({ complete: createModelCompleter({ model: runtimeModel, @@ -1143,24 +1134,6 @@ void reportModelAvailability({ }), }); -/** - * Pass one of tool selection: which skills a message needs, on the deployment's own model. - * - * Built once rather than per request, because it holds nothing about a person: the key is resolved - * on every call, so a credential rotated a moment ago is used by the next run. - */ -const chooseSkills = createModelCompleter({ - model: runtimeModel, - resolveApiKey: () => - resolveModelApiKey({ - encryptionKey: config.keyEncryptionKey, - reader: credentialStore, - provider: runtimeModel.provider, - keyId: tenantPackage.model.credentialSecretRef, - environment: process.env, - }), -}); - // Named collaborators are bound once in actorAgentResolver for chat, hops, and headless turns. /** The deployment's model key, resolved per call so a credential rotated a moment ago is used next. */ @@ -1210,6 +1183,34 @@ const memoryIngestion = createMemoryIngestion({ }); // Proactive research may only read; see proactive/restriction.ts. Read from the catalogue per call. const proactiveReadOnlyRefs = createReadOnlyClassifier(pluginStore); +/** Team Bots: published Bots, their audiences, and teammates' consent to their own accounts. */ +const teamBots = createTeamBots({ + database, + connectedServers: async (userId) => + new Set( + [ + ...(await pluginStore.connectionsFor(userId)), + ...(await pluginStore.brokeredConnectionsFor(userId)), + ].map((connection) => connection.serverId), + ), +}); +const deliveryScopeFor: DeliveryScopeResolver = async ( + ownerUserId, + channelId, + agentId, +) => { + const person = await peopleStore.find(ownerUserId); + if (!person || person.revoked) return null; + const actor = await actorFor(ownerUserId); + const [channel, bot] = await Promise.all([ + channelStore.get(actor, channelId), + agentProfileStore.get(actor, agentId), + ]); + return channel?.active && bot && channel.agentIds.includes(agentId) + ? { ownerUserId, channelId, agentId, threadId: channel.threadId } + : null; +}; + /* * Memory sources and background research, on by default for each person and Bot, and never switched * back on once the person turns one down. See memory/defaults.ts. @@ -1266,17 +1267,6 @@ const memorySweep = repeatAfterEach(async () => { }, 60_000); // Follow-ups a Bot schedules for itself, on the shared work queue. See agents/wake-up.ts. const wakeUpStore = createWakeUpStore(database, { auditStore: bootAuditStore }); -/** Team Bots: published Bots, their audiences, and teammates' consent to their own accounts. */ -const teamBots = createTeamBots({ - database, - connectedServers: async (userId) => - new Set( - [ - ...(await pluginStore.connectionsFor(userId)), - ...(await pluginStore.brokeredConnectionsFor(userId)), - ].map((connection) => connection.serverId), - ), -}); const loadToolsForActor = (actorId: string, initiator: AuditInitiator = PERSON_INITIATOR) => @@ -1501,283 +1491,31 @@ const agentFetch = createAgentFetch({ }); }, }); +const { coordination, coordinationForActor } = wireCoordination({ + handoffDesk, + config, + approvalService, + agentProfileStore, + actorFor, + channelSourceForRun, + database, + pluginStore, + bootAuditStore, + peopleStore, + sourceForCoordinationRun, + channelStore, + // Assigned further down, once delivery is wired. + notify: (scope, input) => deliveryNotifications(scope, input), +}); /** - * Who a routine acts as, resolved the way {@link resolveRequestActor} resolves it. + * One Bot, built for a routine's turn, as its owner. * - * THE ROLE IS READ, NOT ASSUMED. Which coworkers exist is decided per person and an administrator - * sees Bots a user does not, so hardcoding `role: "user"` here would hide an administrator's own Bots - * from their own routine — the routine would fail with "that Bot is no longer registered" for a Bot - * sitting in front of them in chat. This asks the same repository the request path asks, so a routine - * sees exactly the coworkers its owner sees. - */ -const actorFor = async (ownerUserId: string): Promise => { - // One person, and they are an administrator. The id stays the routine owner's rather than being - // rewritten to DEV_ACTOR's: in this mode they are the same person, and if they ever were not, - // silently borrowing the dev actor's identity would be worse than finding nothing. - if (config.singleUser) return { id: ownerUserId, role: DEV_ACTOR.role }; - const roles = await roleRepository.rolesForUser(ownerUserId); - if (!roles.includes("admin") && !roles.includes("user")) { - throw new Error("A routine requires an authorized owner."); - } - return { - id: ownerUserId, - role: roles.includes("admin") ? "admin" : "user", - }; -}; - -/* - * A run's source conversation: a channel's own thread, or a Bot's thread in a group conversation. - * The group lookup applies the same live-membership and own-Bot checks. See channels/group.ts. - */ -const sourceForCoordinationRun = async (run: RunAssertion) => - (await channelSourceForRun(run)) ?? groupSourceForRun(database, run); -const channelSourceForRun = async (run: RunAssertion) => { - if (!run.threadId) return null; - const sources = await database - .select({ channelId: channels.id, botId: channelAgents.agentId }) - .from(intelligenceChannelMappings) - .innerJoin(channels, eq(channels.id, intelligenceChannelMappings.channelId)) - .innerJoin( - channelMemberships, - and( - eq(channelMemberships.channelId, channels.id), - eq(channelMemberships.userId, run.actorId), - ), - ) - .innerJoin(channelAgents, eq(channelAgents.channelId, channels.id)) - .where( - and( - eq(intelligenceChannelMappings.threadId, run.threadId), - eq(intelligenceChannelMappings.userId, run.actorId), - isNull(channels.deletedAt), - ), - ); - return ( - sources.find((source) => source.botId === run.botId) ?? - (run.handoff ? (sources[0] ?? null) : null) - ); -}; -const personQuestions = createWorkQueue(database); -const coordination = createCoordinationTools({ - desk: handoffDesk, - caps: config.handoff, - approvalGate: approvalService.gate, - privateShare: { - check: createPrivateShareCheck({ approvals: approvalService.store }), - // Resolved the way the desk resolves it: an id wins, then a single visible name. Null for the - // owner's own Bot or a deployment Bot, since nobody else reads that work. - audienceFor: async (from, target) => { - const roster = await agentProfileStore.list(await actorFor(from.actorId)); - const wanted = target.trim().toLowerCase(); - const found = - roster.find((candidate) => candidate.id.toLowerCase() === wanted) ?? - roster.find( - (candidate) => - !candidate.hidden && - candidate.deletedAt === null && - candidate.name.toLowerCase() === wanted, - ); - if (!found?.ownerUserId || found.ownerUserId === from.actorId) - return null; - return { - kind: "handoff", - id: found.ownerUserId, - recipientUserIds: [found.ownerUserId], - label: found.name, - }; - }, - // A question from a conversation other people are in reaches them too, so it is held like a - // handoff to another person. Only the owner reading it is the ordinary, unheld case. - questionAudience: async (from) => { - const channel = await channelSourceForRun(from); - const group = channel ? null : await groupSourceForRun(database, from); - const source = channel ?? group; - if (!source) return null; - const members = ( - await database - .select({ userId: channelMemberships.userId }) - .from(channelMemberships) - .where(eq(channelMemberships.channelId, source.channelId)) - ).map((row) => row.userId); - if (members.every((userId) => userId === from.actorId)) return null; - return { - audience: { - kind: group ? "group" : "channel", - id: source.channelId, - recipientUserIds: members, - }, - // A channel thread is the conversation itself; a group Bot's own thread also holds context - // only its owner has seen. - origin: channel - ? { kind: "shared_conversation", sharedWithUserIds: members } - : { kind: "private_conversation" }, - }; - }, - }, - botsReachableFrom: (botId) => pluginStore.botsReachableFrom(botId), - auditStore: bootAuditStore, - authoriseRun: (run) => - authoriseCoordinationRun(run, { - canUseBot: async (scope) => { - const person = await peopleStore.find(scope.actorId); - if (!person || person.revoked) return false; - return ( - (await agentProfileStore.get( - await actorFor(scope.actorId), - scope.botId, - )) !== null - ); - }, - sourceThread: sourceForCoordinationRun, - delegationFor: (claim) => readCoordinationHandoffClaim(database, claim), - }), - route: async (question) => { - const source = await sourceForCoordinationRun(question); - if (!source) - return { - refusal: "That question could not reach its source conversation.", - }; - const key = createHash("sha256") - .update( - JSON.stringify([ - question.actorId, - question.botId, - question.runId, - question.question, - ]), - ) - .digest("hex"); - const queued = await personQuestions.offer({ - kind: "person.question", - key, - payload: { - ...question, - sourceBotId: source.botId, - channelId: source.channelId, - mode: "completed_question", - toolName: "ask_person", - args: { - question: question.question, - ...(question.why ? { why: question.why } : {}), - }, - }, - }); - if (queued === "refused") - return { refusal: "The question could not be saved for its person." }; - await channelStore.recordActivity( - await actorFor(question.actorId), - source.channelId, - { - text: `A question needs your answer: ${question.question}`, - agentId: source.botId, - at: new Date(), - }, - { id: `question:${key}` }, - ); - if (question.threadId) { - // A person reads this in Slack or on their phone, where "general-assistant" is an id, not a name. - const botName = - ( - await agentProfileStore - .get(await actorFor(question.actorId), question.botId) - .catch(() => null) - )?.name ?? question.botId; - await deliveryNotifications( - { - ownerUserId: question.actorId, - channelId: source.channelId, - agentId: source.botId, - threadId: question.threadId, - }, - { - id: key, - text: `${botName} asks: ${question.question}`, - kind: "question", - requestId: key, - }, - ); - } - return { - reached: - "the person in the source conversation; the question is saved awaiting their reply", - }; - }, -}); -const coordinationForActor = - ( - actorId: string, - initiator: AuditInitiator = PERSON_INITIATOR, - baseDepth = 0, - ) => - async (botId: string, input: import("@ag-ui/client").RunAgentInput) => { - const assertion = readRunAssertion( - input.forwardedProps?.openbotRun, - config.keyEncryptionKey, - ); - if ( - assertion && - (assertion.botId !== botId || assertion.actorId !== actorId) - ) - throw new Error("The run assertion does not match this Bot and owner."); - const run: RunAssertion = assertion?.handoff - ? assertion - : { - botId, - actorId, - runId: input.runId, - threadId: input.threadId, - depth: assertion?.depth ?? baseDepth, - initiator: assertion?.initiator ?? initiator, - }; - const tools = await coordination.toolsForRun(run); - return tools.map((tool) => ({ - ...tool, - execute: async (args: unknown) => { - if (!args || typeof args !== "object" || Array.isArray(args)) - return `${REFUSAL_MARKER} Tool arguments must be an object.`; - const result = await coordination.call({ - name: tool.name, - args: args as Record, - run, - }); - if (!result) throw new Error("A coordination tool was not dispatched."); - /* - * A responsibility's question pauses its run, the way an approval does. - * - * Left running, the run ended "succeeded" the moment the question was saved, and the answer - * came back to a run that was already over: the Bot read it, and then the store refused its - * progress because it was not from the responsibility's current run, so the goal stayed - * open for ever. Suspended, the run waits, and the answer resumes that same run as this - * tool's result, with the right to record progress and finish. - */ - const snapshot = currentApprovalContext(); - if ( - tool.name === "ask_person" && - run.initiator?.kind === "responsibility" && - snapshot && - !result.text.startsWith(REFUSAL_MARKER) - ) - throw new HeadlessToolSuspension( - "This responsibility is waiting for the person's answer.", - { - kind: PERSON_QUESTION_WAITING, - continuation: snapshot, - }, - ); - return result.text; - }, - })); - }; - -/** - * One Bot, built for a routine's turn, as its owner. - * - * Per turn rather than per boot, for the same reason the request path rebuilds: a Bot registered or - * edited since the last firing has to count, and a private coworker must be absent for everybody but - * its owner. No header and no request are involved — the owner is asserted by construction, from the - * routine row — which is the whole point of doing it here rather than adding an impersonation path to - * a public route. + * Per turn rather than per boot, for the same reason the request path rebuilds: a Bot registered or + * edited since the last firing has to count, and a private coworker must be absent for everybody but + * its owner. No header and no request are involved — the owner is asserted by construction, from the + * routine row — which is the whole point of doing it here rather than adding an impersonation path to + * a public route. */ const buildAgentFor = async ({ ownerUserId, @@ -1816,29 +1554,6 @@ const buildAgentFor = async ({ } return agent; }; - -/* - * The pair a headless turn is driven through, built ONCE. - * - * Not the runtime's own pair: `mountCopilotRuntime` keeps its client and its runner inside - * `CopilotRuntime` and hands neither back, and reaching into that object would be a worse seam than - * building our own from the same three settings. Built from `config.runtime.intelligence`, which is - * required and not optional — `RuntimeCapabilities` has exactly one mode and every Intelligence field - * with it (`config.ts:10-22`), and `loadConfig` refuses to boot without them — so there is no - * not-in-Intelligence-mode branch to write here. If a second mode is ever added, THIS is the line that - * has to grow a guard, and the routine runner must then be left off `createApp` entirely. - * - * One runner for the process, reused across firings: it opens a socket per run and holds no idle - * connection, but its `threads` map is per instance, and a runner per turn would fragment the - * already-running check that keeps two turns off one thread. See `routines/run-turn.ts`. - */ -const routineIntelligence = observeIntelligenceAuthentication( - new CopilotKitIntelligence({ - apiUrl: config.runtime.intelligence.apiUrl, - wsUrl: config.runtime.intelligence.gatewayWsUrl, - apiKey: config.runtime.intelligence.apiKey, - }), -); const routineAgentRunner = new IntelligenceAgentRunner({ url: routineIntelligence.ɵgetRunnerWsUrl(), authToken: routineIntelligence.ɵgetRunnerAuthToken(), @@ -1848,9 +1563,10 @@ const routineRunner = createRoutineRunner({ routineStore, channelStore, runTurn: createTurnRunner({ + admission: serverTurnAdmission, intelligence: routineIntelligence, runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, + components: componentCatalog, buildAgentFor, toolsForTurn: async ({ ownerUserId, agentId, initiator }) => { if (!computerGateway) return []; @@ -1926,114 +1642,22 @@ const copilotRuntime = mountCopilotRuntime( }, ); -const responsibilityQueue = createWorkQueue(database); -const responsibilityEngine: ResponsibilityEngine = createResponsibilityEngine({ - store: responsibilityStore, - queue: responsibilityQueue, - runTurn: async (context) => { - const initiator: AuditInitiator = { - kind: "responsibility", - id: context.responsibilityId, - }; - const tools = responsibilityTools({ - store: responsibilityStore, - engine: responsibilityEngine, - ownerUserId: context.ownerUserId, - agentId: context.agentId, - channelId: context.channelId, - responsibilityRunId: context.runId, - }); - const runner = createTurnRunner({ - intelligence: routineIntelligence, - runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, - buildAgentFor: (input) => buildAgentFor({ ...input, initiator }), - toolsForTurn: async () => { - const actor = await actorFor(context.ownerUserId); - // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. - const computer = - computerGateway && - canUseComputer( - await agentProfileStore - .get(actor, context.agentId) - .catch(() => null), - actor, - ) - ? createHeadlessComputerTools({ - gateway: computerGateway, - botId: context.agentId, - actor: { id: actor.id, userId: actor.id, initiator }, - auditStore: bootAuditStore, - ...(signInService ? { signIn: signInService } : {}), - }) - : []; - return [ - ...computer, - ...tools.map((tool) => ({ - definition: { - name: tool.name, - description: tool.description, - parameters: z.toJSONSchema(tool.parameters), - }, - execute: (args: unknown) => tool.execute(args), - })), - ]; - }, - learningContainerForThread: (input) => - copilotRuntime.learning?.containerForThread(input) ?? - Promise.resolve(undefined), - }); - const saved = context.continuation; - const snapshot = saved - ? parseApprovalContinuation(saved.waiting.continuation) - : undefined; - const toolResult = saved ? parseApprovalResult(saved.response) : undefined; - return runner({ - ownerUserId: context.ownerUserId, - routineId: context.responsibilityId, - agentId: context.agentId, - threadId: context.threadId, - initiator, - signal: context.signal, - ...(snapshot && toolResult - ? { - continuation: { - snapshot, - result: toolResult, - messageId: `responsibility-result:${context.runId}:${snapshot.toolCallId}`, - }, - } - : {}), - instruction: buildResponsibilityTurn({ - instruction: context.instruction, - successCriteria: context.successCriteria, - progress: context.progress, - trigger: context.event, - eventData: markUntrusted( - JSON.stringify(context.event.payload), - `${context.event.source} event`, - ), - responsibilityId: context.responsibilityId, - }), - }); - }, - onReply: async (context, text) => { - await channelStore.recordActivity( - await actorFor(context.ownerUserId), - context.channelId, - { text, agentId: context.agentId, at: new Date() }, - { id: `responsibility:${context.runId}` }, - ); - await deliveryNotifications( - { - ownerUserId: context.ownerUserId, - channelId: context.channelId, - agentId: context.agentId, - threadId: context.threadId, - }, - { id: `responsibility:${context.runId}`, kind: "reply", text }, - ); - }, +const responsibilityEngine = createServerResponsibilityEngine({ + database, + responsibilityStore, + routineIntelligence, + routineAgentRunner, + componentCatalog, + buildAgentFor, + actorFor, + computerGateway, + agentProfileStore, + bootAuditStore, + signInService, + copilotRuntime, + channelStore, + // Assigned further down, once delivery is wired. + notify: (scope, input) => deliveryNotifications(scope, input), }); /** * A turn for a responsibility that is not one of the engine's own firings: a person answering the @@ -2060,9 +1684,10 @@ const responsibilityAnswerTurn = ({ ...(responsibilityRunId ? { responsibilityRunId } : {}), }); return createTurnRunner({ + admission: serverTurnAdmission, intelligence: routineIntelligence, runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, + components: componentCatalog, buildAgentFor: (input) => buildAgentFor({ ...input, initiator }), toolsForTurn: async () => [ ...(await approvalComputerTools( @@ -2111,26 +1736,6 @@ const responsibilitySweep = repeatAfterEach(async () => { const approvalQueue = createWorkQueue(database); const approvalOwner = workOwner("approval"); -const approvalComputerTools = async ( - ownerUserId: string, - agentId: string, - initiator: AuditInitiator, -) => { - const actor = await actorFor(ownerUserId); - const profile = await agentProfileStore.get(actor, agentId); - if (!profile) - throw new ApprovalRefusedError("That Bot is no longer available to you."); - // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. - return computerGateway && canUseComputer(profile, actor) - ? createHeadlessComputerTools({ - gateway: computerGateway, - botId: agentId, - actor: { id: actor.id, userId: actor.id, initiator }, - auditStore: bootAuditStore, - ...(signInService ? { signIn: signInService } : {}), - }) - : []; -}; const executeApprovedAction = createApprovedActionExecutor({ sourceFor: sourceForCoordinationRun, gate: approvalService.gate, @@ -2154,9 +1759,10 @@ const executeApprovedAction = createApprovedActionExecutor({ personInitiator: PERSON_INITIATOR, }); const approvalContinuationRunner = createTurnRunner({ + admission: serverTurnAdmission, intelligence: routineIntelligence, runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, + components: componentCatalog, buildAgentFor, toolsForTurn: async ({ ownerUserId, agentId, initiator }) => approvalComputerTools(ownerUserId, agentId, initiator), @@ -2164,190 +1770,97 @@ const approvalContinuationRunner = createTurnRunner({ copilotRuntime.learning?.containerForThread(input) ?? Promise.resolve(undefined), }); -const approvalSweep = repeatAfterEach(async () => { - try { - const [item] = await approvalQueue.claim({ - kind: "approval.resume", - owner: approvalOwner, - leaseMs: 15 * 60_000, - limit: 1, - }); - if (!item) return; - // Set once the item is understood, so its last failed try can still tell the Bot. - let abandon: ((reason: string) => Promise) | undefined; - try { - const input = z - .object({ - ownerUserId: z.string().min(1), - approvalId: z.string().min(1), - }) - .parse(item.payload); - const resumeDependencies: ApprovalResumeDependencies = { - // Asked before the action runs: with Use Bots off, or the Bot's model off the allowlist, - // nothing is carried out and the refusal is final rather than retried. - refusal: (action) => - headlessTurnRefusal({ - ownerUserId: action.actorId, - agentId: action.botId, - }), - validate: (action) => - approvalService.validateReentry(action, () => - executeApprovedAction(action), - ), - execute: executeApprovedAction, - continue: async ({ - continuation: snapshot, - result, - messageId, - approvalId, - }) => { - const [waitingRun] = await database - .select({ id: responsibilityRuns.id }) - .from(responsibilityRuns) - .where( - and( - eq(responsibilityRuns.status, "waiting"), - sql`${responsibilityRuns.waiting}->>'approvalId' = ${approvalId}`, - ), - ) - .limit(1); - if (waitingRun) { - await responsibilityStore.resumeWaiting( - input.ownerUserId, - waitingRun.id, - result, - ); - return; - } - const request = await approvalService.store.get( - input.ownerUserId, - approvalId, - ); - // A group turn's approval also writes the resumed reply into the shared transcript. - const outcome = await groupConversations.continueWaiting( - approvalId, - () => - approvalContinuationRunner({ - ownerUserId: input.ownerUserId, - routineId: "approval", - agentId: request.action.botId, - threadId: snapshot.threadId, - instruction: "", - initiator: snapshot.initiator ?? PERSON_INITIATOR, - continuation: { snapshot, result, messageId }, - }), - ); - const [source] = await database - .select({ channelId: intelligenceChannelMappings.channelId }) - .from(intelligenceChannelMappings) - .where( - and( - eq(intelligenceChannelMappings.userId, input.ownerUserId), - eq(intelligenceChannelMappings.threadId, snapshot.threadId), - ), - ) - .limit(1); - if (source && outcome.replyText) - await channelStore.recordActivity( - await actorFor(input.ownerUserId), - source.channelId, - { - text: outcome.replyText, - agentId: request.action.botId, - at: new Date(), - }, - { id: messageId }, - ); - // A decision made in Slack, SMS or the phone gets its result back there too. - if (source && outcome.replyText) - await deliveryNotifications( - { - ownerUserId: input.ownerUserId, - channelId: source.channelId, - agentId: request.action.botId, - threadId: snapshot.threadId, - }, - { id: messageId, text: outcome.replyText, kind: "reply" }, - ); - await database - .update(routineRuns) - .set({ - status: "succeeded", - finishedAt: new Date(), - error: null, - waiting: null, - }) - .where( - and( - eq(routineRuns.status, "waiting"), - sql`${routineRuns.waiting}->>'approvalId' = ${approvalId}`, - ), - ); - }, - }; - abandon = (reason) => - approvalService.abandon( - input.ownerUserId, - input.approvalId, - reason, - resumeDependencies, - ); - await approvalService.resume( - input.ownerUserId, - input.approvalId, - resumeDependencies, - ); - if ( - !(await approvalQueue.finish({ - kind: item.kind, - key: item.key, - owner: approvalOwner, - })) - ) - throw new Error("The approval work lease was lost before completion."); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - // The queue will not hand this item out again, so the conversation hears now or never. - if (abandon && item.attempts >= DEFAULT_MAX_ATTEMPTS) { - try { - await abandon(reason); - await approvalQueue.finish({ - kind: item.kind, - key: item.key, - owner: approvalOwner, - }); - return; - } catch (failure) { - console.error( - JSON.stringify({ - type: "approval-abandon-error", - error: - failure instanceof Error ? failure.message : String(failure), - timestamp: new Date().toISOString(), - }), - ); - } - } - await approvalQueue.release({ - kind: item.kind, - key: item.key, - owner: approvalOwner, - delayMs: 10_000, - reason, - }); - throw error; +/** + * Conversations with several Bots. Each Bot turn is an ordinary headless AG-UI turn into that Bot's + * own group thread, built exactly as a routine's is, so governance, learning and the computer tools + * apply unchanged; Bots answering each other reuse the handoff desk's grant and caps. + */ +const groupConversations = createGroupConversations({ + store: createGroupStore(database, () => threadIdentity.mint()), + queue: createWorkQueue(database), + owner: workOwner("group"), + roster: async (ownerUserId, channelId) => { + const actor = await actorFor(ownerUserId); + const channel = await channelStore.get(actor, channelId); + if (!channel || channel.agentIds.length < 2) return null; + const bots: { id: string; name: string }[] = []; + for (const agentId of channel.agentIds) { + const profile = await agentProfileStore.get(actor, agentId); + if (profile) bots.push({ id: agentId, name: profile.name }); } - } catch (error) { - console.error( - JSON.stringify({ - type: "approval-resume-error", - error: error instanceof Error ? error.message : String(error), - timestamp: new Date().toISOString(), - }), - ); - } -}, 2_000); -const deliveryStore = createDeliveryStore(database); + return bots; + }, + runTurn: createTurnRunner({ + admission: serverTurnAdmission, + intelligence: routineIntelligence, + runner: routineAgentRunner, + components: componentCatalog, + buildAgentFor, + toolsForTurn: async ({ ownerUserId, agentId, initiator }) => { + if (!computerGateway) return []; + const actor = await actorFor(ownerUserId); + const profile = await agentProfileStore.get(actor, agentId); + if (!profile) throw new Error("That Bot is not available to this owner."); + // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. + if (!canUseComputer(profile, actor)) return []; + return createHeadlessComputerTools({ + gateway: computerGateway, + botId: agentId, + actor: { id: actor.id, userId: actor.id, initiator }, + auditStore: bootAuditStore, + ...(signInService ? { signIn: signInService } : {}), + }); + }, + learningContainerForThread: (input) => + copilotRuntime.learning?.containerForThread(input) ?? + Promise.resolve(undefined), + }), + caps: config.handoff, + // The handoff desk's own grant check, read per hop and failing closed. + mayAddress: async (fromBotId, toBotId) => + ( + await pluginStore.botsReachableFrom(fromBotId).catch(() => [] as string[]) + ).includes(toBotId), + auditStore: bootAuditStore, + activity: async (turn, agentId, text, sourceId) => + channelStore.recordActivity( + await actorFor(turn.ownerUserId), + turn.channelId, + { text, agentId, at: new Date() }, + { id: sourceId }, + ), + busy: async (turn, busy) => + channelStore.signalChannelBusy( + await actorFor(turn.ownerUserId), + turn.channelId, + busy, + ), + createChannel: async (ownerUserId, agentIds) => + channelStore.create(await actorFor(ownerUserId), agentIds), + // The owner says whether a Bot's words may reach the other people in a group. + privateShare: createPrivateShareCheck({ approvals: approvalService.store }), + // A Team Bot's consent card raised in a group turn is drawn in the shared transcript. + listenForConsent: (ownerUserId, agentId) => + teamBots.listenForConsent(ownerUserId, agentId), +}); + +const approvalSweep = repeatAfterEach( + createApprovalResumeTick({ + approvalQueue, + approvalOwner, + approvalService, + executeApprovedAction, + database, + responsibilityStore, + groupConversations, + approvalContinuationRunner, + channelStore, + actorFor, + // Assigned further down, once delivery is wired. + notify: (scope, input) => deliveryNotifications(scope, input), + }), + 2_000, +); const deliveryProviders = configuredDeliveryProviders( config.publicUrl ?? config.auth?.baseUrl ?? `http://localhost:${config.port}`, ); @@ -2357,22 +1870,6 @@ useSlackChannelMembership((input) => ? deliveryProviders.slack.isMember(input) : Promise.resolve(false), ); -const deliveryScopeFor: DeliveryScopeResolver = async ( - ownerUserId, - channelId, - agentId, -) => { - const person = await peopleStore.find(ownerUserId); - if (!person || person.revoked) return null; - const actor = await actorFor(ownerUserId); - const [channel, bot] = await Promise.all([ - channelStore.get(actor, channelId), - agentProfileStore.get(actor, agentId), - ]); - return channel?.active && bot && channel.agentIds.includes(agentId) - ? { ownerUserId, channelId, agentId, threadId: channel.threadId } - : null; -}; const authoriseDeliveryScope = async (scope: DeliveryScope) => (await deliveryScopeFor(scope.ownerUserId, scope.channelId, scope.agentId)) ?.threadId === scope.threadId; @@ -2455,6 +1952,7 @@ const proactiveEngine = createProactiveEngine({ queue: createWorkQueue(database), owner: workOwner("proactive"), runTurn: createTurnRunner({ + admission: serverTurnAdmission, intelligence: routineIntelligence, runner: routineAgentRunner, buildAgentFor, @@ -2528,9 +2026,10 @@ const wakeUpRunner = createWakeUpRunner({ actorFor, channels: channelStore, runTurn: createTurnRunner({ + admission: serverTurnAdmission, intelligence: routineIntelligence, runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, + components: componentCatalog, buildAgentFor, toolsForTurn: async ({ ownerUserId, agentId, initiator }) => { if (!computerGateway) return []; @@ -2588,78 +2087,6 @@ const deliverySweeps = DELIVERY_WORK_KINDS.map((kind) => ); const personResponseOwner = workOwner("person-response"); -/** - * Conversations with several Bots. Each Bot turn is an ordinary headless AG-UI turn into that Bot's - * own group thread, built exactly as a routine's is, so governance, learning and the computer tools - * apply unchanged; Bots answering each other reuse the handoff desk's grant and caps. - */ -const groupConversations = createGroupConversations({ - store: createGroupStore(database, () => threadIdentity.mint()), - queue: createWorkQueue(database), - owner: workOwner("group"), - roster: async (ownerUserId, channelId) => { - const actor = await actorFor(ownerUserId); - const channel = await channelStore.get(actor, channelId); - if (!channel || channel.agentIds.length < 2) return null; - const bots: { id: string; name: string }[] = []; - for (const agentId of channel.agentIds) { - const profile = await agentProfileStore.get(actor, agentId); - if (profile) bots.push({ id: agentId, name: profile.name }); - } - return bots; - }, - runTurn: createTurnRunner({ - intelligence: routineIntelligence, - runner: routineAgentRunner, - components: { store: componentStore, auditStore: bootAuditStore }, - buildAgentFor, - toolsForTurn: async ({ ownerUserId, agentId, initiator }) => { - if (!computerGateway) return []; - const actor = await actorFor(ownerUserId); - const profile = await agentProfileStore.get(actor, agentId); - if (!profile) throw new Error("That Bot is not available to this owner."); - // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. - if (!canUseComputer(profile, actor)) return []; - return createHeadlessComputerTools({ - gateway: computerGateway, - botId: agentId, - actor: { id: actor.id, userId: actor.id, initiator }, - auditStore: bootAuditStore, - ...(signInService ? { signIn: signInService } : {}), - }); - }, - learningContainerForThread: (input) => - copilotRuntime.learning?.containerForThread(input) ?? - Promise.resolve(undefined), - }), - caps: config.handoff, - // The handoff desk's own grant check, read per hop and failing closed. - mayAddress: async (fromBotId, toBotId) => - ( - await pluginStore.botsReachableFrom(fromBotId).catch(() => [] as string[]) - ).includes(toBotId), - auditStore: bootAuditStore, - activity: async (turn, agentId, text, sourceId) => - channelStore.recordActivity( - await actorFor(turn.ownerUserId), - turn.channelId, - { text, agentId, at: new Date() }, - { id: sourceId }, - ), - busy: async (turn, busy) => - channelStore.signalChannelBusy( - await actorFor(turn.ownerUserId), - turn.channelId, - busy, - ), - createChannel: async (ownerUserId, agentIds) => - channelStore.create(await actorFor(ownerUserId), agentIds), - // The owner says whether a Bot's words may reach the other people in a group. - privateShare: createPrivateShareCheck({ approvals: approvalService.store }), - // A Team Bot's consent card raised in a group turn is drawn in the shared transcript. - listenForConsent: (ownerUserId, agentId) => - teamBots.listenForConsent(ownerUserId, agentId), -}); const groupSweep = repeatAfterEach(async () => { try { while (await groupConversations.sweep()) {} @@ -2674,99 +2101,22 @@ const groupSweep = repeatAfterEach(async () => { } }, 1_000); -const personResponseSweep = repeatAfterEach(async () => { - try { - const [item] = await approvalQueue.claim({ - kind: "person.response", - owner: personResponseOwner, - leaseMs: 10 * 60_000, - limit: 1, - maxAttempts: 1, - }); - if (!item) return; - try { - const payload = z - .object({ - ownerUserId: z.string().min(1), - // The saved question, initiator included, so the answer resumes the turn that asked. - question: personQuestionSchema, - response: z.string().min(1).max(6000), - }) - .parse(item.payload); - if (payload.ownerUserId !== payload.question.actorId) - throw new ApprovalRefusedError( - "The saved question owner does not match.", - ); - await authoriseQuestion(payload.question); - const outcome = await resumePersonQuestion({ - ownerUserId: payload.ownerUserId, - question: payload.question, - response: payload.response, - messageId: `person-response:${item.key}`, - runTurn: approvalContinuationRunner, - responsibilities: responsibilityStore, - responsibilityTurn: responsibilityAnswerTurn, - }); - const source = await sourceForCoordinationRun({ - ...payload.question, - botId: questionConversationBot(payload.question), - depth: 0, - }); - if (source && outcome.replyText) - await channelStore.recordActivity( - await actorFor(payload.ownerUserId), - source.channelId, - { - text: outcome.replyText, - agentId: questionConversationBot(payload.question), - at: new Date(), - }, - { id: `person-response:${item.key}` }, - ); - if (source && outcome.replyText) - await deliveryNotifications( - { - ownerUserId: payload.ownerUserId, - channelId: source.channelId, - agentId: questionConversationBot(payload.question), - threadId: payload.question.threadId, - }, - { - id: `person-response:${item.key}`, - text: outcome.replyText, - kind: "reply", - }, - ); - if ( - !(await approvalQueue.finish({ - kind: item.kind, - key: item.key, - owner: personResponseOwner, - })) - ) - throw new Error( - "The question response lease was lost before completion.", - ); - } catch (error) { - await approvalQueue.release({ - kind: item.kind, - key: item.key, - owner: personResponseOwner, - delayMs: 0, - reason: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } catch (error) { - console.error( - JSON.stringify({ - type: "person-response-error", - error: error instanceof Error ? error.message : String(error), - timestamp: new Date().toISOString(), - }), - ); - } -}, 2_000); +const personResponseSweep = repeatAfterEach( + createPersonResponseTick({ + approvalQueue, + personResponseOwner, + authoriseQuestion, + approvalContinuationRunner, + responsibilityStore, + responsibilityAnswerTurn, + sourceForCoordinationRun, + channelStore, + actorFor, + // Assigned further down, once delivery is wired. + notify: (scope, input) => deliveryNotifications(scope, input), + }), + 2_000, +); /** * Delivering hops, on every replica. @@ -2795,138 +2145,16 @@ const personResponseSweep = repeatAfterEach(async () => { let workOfferedListener: WorkOfferedListener | undefined; if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { - const runner = createHandoffRunner({ - queue: createWorkQueue(database), - owner: workOwner("handoff"), - auditStore: bootAuditStore, - /* - * The signed statement of the run the addressed Bot is about to start, carrying how deep the - * chain has gone. Minted here, where the key lives, and one deeper than the run that asked. - */ - sign: (work, claim) => - signHandoffDeliveryRun(work, config.keyEncryptionKey, undefined, claim), - delivery: createHandoffDelivery({ - /* - * Built as the person, WITH THEIR ROLE. The desk resolved it to decide the hop was allowed; a - * delivery that then rebuilt them as an ordinary user could not find the Bot the desk had just - * agreed to, and the person was told it never answered. - */ - agentFor: async ({ actorId, botId, fromBotId, initiator }) => { - const actor = await actorFor(actorId).catch(() => null); - if (!actor) { - throw new Error( - "who this is for could not be confirmed, so the Bot was not run", - ); - } - return copilotRuntime.agentFor({ - actor, - botId, - initiator: handoffInitiator(fromBotId, initiator), - }); - }, - history: copilotRuntime.history, - lock: copilotRuntime.threadLock, - /* - * A scratch thread of the addressed Bot's own, one per hop. - * - * An Intelligence thread has exactly one agent, so a second Bot cannot answer inside the first - * Bot's conversation however it asks. Its turn runs here instead, unmapped to any channel, and - * what it said comes back to the conversation that asked through the relay — in the asking - * Bot's voice, which is the only voice that thread admits. Minted with the deployment's own - * identity, like every thread this deployment starts. - */ - mintThreadId: () => threadIdentity.mint(), - /* - * The roster, told that a relayed answer landed. The delivery knows only the thread it ran - * in; this resolves which channel shows that thread — a scratch thread maps to nothing and - * announces nowhere, which is the point of a scratch thread. - */ - announce: async (input) => { - const [mapped] = await database - .select({ channelId: intelligenceChannelMappings.channelId }) - .from(intelligenceChannelMappings) - .where(eq(intelligenceChannelMappings.threadId, input.threadId)) - .limit(1); - // A Bot's group thread maps to no channel; its answer goes to the shared transcript. - if (!mapped) { - await groupConversations.announceHandoff(input); - return; - } - const actor = await actorFor(input.actorId).catch(() => null); - if (!actor) return; - await channelStore.recordActivity(actor, mapped.channelId, { - text: input.text, - agentId: input.agentId, - at: new Date(), - }); - }, - // The asking conversation shown as working while a hop runs in it. Keyed by thread, resolved - // to its channel by the store; a scratch thread maps to none and signals nowhere. - setBusy: (input) => channelStore.signalBusy(input.threadId, input.busy), - newRunId: () => randomUUID(), - // The same address and the same token the runtime uses. Assembling either from configuration - // produced a runner every join was refused for, because the thread's active run is a lock the - // platform issues rather than something an API key can claim. - runner: new IntelligenceAgentRunner( - copilotRuntime.runnerConnection(), - ) as never, - }), + workOfferedListener = await startHandoffDelivery({ + config, + database, + bootAuditStore, + actorFor, + copilotRuntime, + threadIdentity, + groupConversations, + channelStore, }); - - const sweep = async () => { - try { - const report = await runner.sweep(); - if (report.delivered.length > 0 || report.skipped.length > 0) { - console.info(JSON.stringify({ type: "bot-handoff", ...report })); - } - } catch (error) { - // A sweep that failed must not take the loop with it: the next one may find the database back. - console.warn( - "[handoff] a sweep could not run:", - error instanceof Error ? error.message : error, - ); - } - }; - - /* - * ONE SWEEP AT A TIME ON THIS REPLICA, from both callers below. A sweep poked while one is - * running is remembered rather than started, and runs once the current one ends — a wake-up - * that arrived mid-sweep may be for a hop the running sweep's claim already missed. - */ - let sweeping = false; - let sweepAgain = false; - const kick = async () => { - if (sweeping) { - sweepAgain = true; - return; - } - sweeping = true; - try { - do { - sweepAgain = false; - await sweep(); - } while (sweepAgain); - } finally { - sweeping = false; - } - }; - - /* - * Woken by the queue itself, from any replica: a person is waiting through every hop, and the - * poll below would spend up to two seconds per leg doing nothing. The poll stays as the - * backstop — a notification is a latency optimisation, and one lost in transit costs one - * interval, never the work. See repeatAfterEach for why an interval must not be used: an - * interval would start another sweep every two seconds while a five-minute delivery runs, each - * claiming a different batch, and this replica's concurrent agent runs would grow with the - * backlog rather than stopping at the limit it was asked for. - */ - workOfferedListener = await startWorkOfferedListener( - config.databaseUrl, - (kind) => { - if (kind === HANDOFF_KIND) void kick(); - }, - ); - repeatAfterEach(kick, 2_000); } /* @@ -3254,226 +2482,15 @@ const app = createApp( identityGithubCallback, ); -/** - * The live screen, proxied. - * - * Proxied rather than connected directly. `agent-computer` authenticates its callers with a - * shared token, not with a person's session, and it must never be reachable from a browser. So the - * socket terminates here, behind the same session guard as every other route, and this process opens - * a second socket inward carrying the token. - * - * Not a Hono route because an upgrade is not a request/response: Bun hands it over before Hono sees a - * body, so it is handled in `fetch` ahead of the app. - */ -const toStreamUrl = (baseUrl: string, botId: string) => - // The Bot travels in the query, because a websocket upgrade carries no custom header for the - // computer to read and every call it serves is per Bot. The secret travels the same way and for the - // same reason, this socket is the one a person can type into, so it is the last thing that should - // be reachable without it. - `${baseUrl.replace(/^http/, "ws").replace(/\/$/, "")}/stream?bot=${encodeURIComponent(botId)}&token=${encodeURIComponent(config.computer?.token ?? "")}`; - -/** What each proxied socket carries: where to connect inward, and the socket once opened. */ -type StreamData = { - upstream: string; - ownerUserId: string; - botId: string; - recordingId?: string; - inward?: WebSocket; -}; - -/** - * Bun takes exactly one WebSocket handler for the server, and two features need one: the app proxies - * the computer stream, and it pushes channel activity through Hono's adapter. So this one - * dispatches on what the upgrade attached, a proxy socket carries `upstream`, a Hono socket does - * not, rather than either feature quietly taking the slot and breaking the other on connect. - */ -type ChannelSocket = Parameters[0]; -type SocketData = StreamData | ChannelSocket["data"]; - -const isProxiedStream = (data: SocketData): data is StreamData => - typeof (data as StreamData).upstream === "string"; - -// Hono owns the socket's data once it has upgraded it; this hands its own back to it. -const asChannelSocket = (ws: { data: SocketData }) => - ws as unknown as ChannelSocket; - -serve({ +startHttpServer({ port, - async fetch(request, server) { - const url = new URL(request.url); - if (url.pathname === "/api/audio/transcriptions") { - server.timeout(request, DICTATION_HTTP_IDLE_SECONDS); - } - if (url.pathname === "/api/voice/calls") server.timeout(request, 30); - if (url.pathname === "/api/voice/sessions") server.timeout(request, 30); - const streamBotId = streamPathBotId(url.pathname); - if ( - streamBotId !== null && - request.headers.get("upgrade")?.toLowerCase() === "websocket" - ) { - if (!config.computer) { - return new Response("No computer is configured.", { status: 503 }); - } - // The session guard, applied by hand because middleware does not run on an upgrade. An - // unauthenticated socket here would be the whole point of the proxy defeated. - const actor = await resolveRequestActor(request).catch(() => null); - if (!actor) { - return new Response("Sign in first.", { status: 401 }); - } - // And which Bot, which the guard above does not answer. This socket carries that Bot's screen, - // so signing in is not enough: without this, anybody signed in watches anybody's Bot work. And - // seeing a Team Bot is not enough either: its screen is its owner's signed-in browser. - if ( - !(await computerAccessCheck(agentProfileStore)( - { id: actor.id, role: actor.role }, - streamBotId, - )) - ) { - return new Response("There is no such Bot.", { status: 404 }); - } - /* - * Through the gateway, not the provider. - * - * `gateway.locate` runs checkComputerAddress; `provider.locate` does not, and the URL built - * below carries COMPUTER_TOKEN in its query string. A provider that answered with a foreign - * host was handed the deployment's computer token, which is the case that check was written - * for. Every acting path already went through the gateway; this one did not. - */ - let upstream: string; - try { - const streamBase = computerGateway - ? await computerGateway.locate(streamBotId) - : undefined; - if (!streamBase) { - return new Response("No computer address is configured.", { - status: 503, - }); - } - upstream = toStreamUrl(streamBase, streamBotId); - } catch (error) { - // Said out loud rather than falling back to another Bot's computer, which is the failure this - // whole path exists to prevent. - return new Response( - error instanceof Error - ? error.message - : "That Bot's computer could not be reached.", - { status: 502 }, - ); - } - const recording = await demonstrationStore.active(actor.id, streamBotId); - if (recording) - upstream += `&recording=${encodeURIComponent(recording.id)}`; - if ( - server.upgrade(request, { - data: { - upstream, - ownerUserId: actor.id, - botId: streamBotId, - ...(recording ? { recordingId: recording.id } : {}), - }, - }) - ) { - return undefined as unknown as Response; - } - return new Response("Expected a WebSocket upgrade.", { status: 400 }); - } - return app.fetch(request, { server }); - }, - websocket: { - open(ws) { - if (!isProxiedStream(ws.data)) { - channelSocket.open(asChannelSocket(ws)); - return; - } - const stream = ws.data; - const inward = new WebSocket(stream.upstream); - stream.inward = inward; - // Frames outward, input inward. Buffered by neither side: a frame the browser is too slow for - // should be dropped, not queued, because a stale frame is worse than a missing one. - inward.onmessage = (event) => { - if (stream.recordingId) { - let recorded: unknown; - try { - recorded = JSON.parse(String(event.data)); - } catch { - recorded = null; - } - if ( - recorded && - typeof recorded === "object" && - "type" in recorded && - recorded.type === "demonstration.action" - ) { - const data = stream; - const message = recorded as { - recordingId?: unknown; - action?: unknown; - }; - if (message.recordingId !== data.recordingId) { - ws.send( - JSON.stringify({ - type: "error", - error: - "The recording identity changed. Stop and restart recording.", - }), - ); - return; - } - demonstrationRecorder - .capture( - data.ownerUserId, - data.botId, - data.recordingId ?? "", - message.action, - ) - .catch((error) => { - console.error( - JSON.stringify({ - type: "demonstration-capture-error", - error: error instanceof Error ? error.name : "UnknownError", - context: { recordingId: data.recordingId }, - timestamp: new Date().toISOString(), - }), - ); - try { - ws.send( - JSON.stringify({ - type: "error", - error: - "A browser step could not be saved. Stop and review the demonstration before continuing.", - }), - ); - } catch { - /* A closed viewer cannot receive the already logged failure. */ - } - }); - return; - } - } - try { - ws.send(String(event.data)); - } catch { - inward.close(); - } - }; - inward.onclose = () => ws.close(); - inward.onerror = () => ws.close(); - }, - message(ws, raw) { - if (!isProxiedStream(ws.data)) { - channelSocket.message(asChannelSocket(ws), raw); - return; - } - if (ws.data.inward?.readyState === 1) ws.data.inward.send(String(raw)); - }, - close(ws, code, reason) { - if (!isProxiedStream(ws.data)) { - channelSocket.close(asChannelSocket(ws), code, reason); - return; - } - ws.data.inward?.close(); - }, - }, + config, + resolveRequestActor, + agentProfileStore, + computerGateway, + demonstrationStore, + demonstrationRecorder, + app, }); if (config.singleUser) { diff --git a/server/src/learning/auto-assign.ts b/server/src/learning/auto-assign.ts index d5e179248..edb103ffa 100644 --- a/server/src/learning/auto-assign.ts +++ b/server/src/learning/auto-assign.ts @@ -2,7 +2,7 @@ import { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; import { AUTOMATIC_LEARNING_CONTAINER_ID, type LearningAutoAssignState, -} from "../../../shared/learning"; +} from "@openbot/shared/learning"; import { type AuditStore, DEPLOYMENT_INITIATOR, diff --git a/server/src/learning/routes.ts b/server/src/learning/routes.ts index c37edfb5f..a577b8fa7 100644 --- a/server/src/learning/routes.ts +++ b/server/src/learning/routes.ts @@ -5,7 +5,7 @@ import type { LearningAutoAssignState, LearningDeliveryStatus, LearningOverview, -} from "../../../shared/learning"; +} from "@openbot/shared/learning"; import { type AuditStore, recordAuditEvent } from "../audit"; import { type AppVariables, requireAdmin } from "../auth/guards"; import type { LearningAutoAssign } from "./auto-assign"; diff --git a/server/src/learning/runtime.ts b/server/src/learning/runtime.ts index c84b4b180..0013c9c9f 100644 --- a/server/src/learning/runtime.ts +++ b/server/src/learning/runtime.ts @@ -11,7 +11,7 @@ import type { LearningDeliveryStatus, LearningSettings, LearningTarget, -} from "../../../shared/learning"; +} from "@openbot/shared/learning"; import type { LearningSettingsStore } from "./settings"; // This published internal export is pinned to runtime 1.73.1. Keep its use here: the SDK owns diff --git a/server/src/learning/settings.ts b/server/src/learning/settings.ts index 634b23b7d..e23171d1f 100644 --- a/server/src/learning/settings.ts +++ b/server/src/learning/settings.ts @@ -3,7 +3,7 @@ import { isLearningContainerId, type LearningSettings, type LearningTarget, -} from "../../../shared/learning"; +} from "@openbot/shared/learning"; import type { Database } from "../db/client"; import { learningSettings, diff --git a/server/src/memory/tools.ts b/server/src/memory/tools.ts index 0dd69baa7..a1bba3df4 100644 --- a/server/src/memory/tools.ts +++ b/server/src/memory/tools.ts @@ -6,7 +6,7 @@ import { } from "@ag-ui/client"; import { defer, from, type Observable, switchMap } from "rxjs"; import { z } from "zod"; -import type { GrantedTool } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import type { MemoryIngestion } from "./ingestion"; import type { MemoryStore } from "./store"; import { diff --git a/server/src/plugins/builtin-routines.ts b/server/src/plugins/builtin-routines.ts index 1cbf86e45..995dabcbc 100644 --- a/server/src/plugins/builtin-routines.ts +++ b/server/src/plugins/builtin-routines.ts @@ -76,7 +76,7 @@ export function useRoutineTools(tools: RoutineTools | null): void { * HOW THE INSTRUCTION IS PHRASED IS PART OF THAT CONTRACT, and it was learned the hard way: a * routine stored as "Every run, append the current date and time to the Notion page …" fired, and the * turn read its own schedule-shaped text as a question about SCHEDULING — it checked that the routine - * existed, said so, and appended nothing, successfully. `routines/run-turn.ts` now frames the firing + * existed, said so, and appended nothing, successfully. `toolkit/run-turn.ts` now frames the firing * turn as a firing; this is the other half, so the sentence is written as work to begin with. */ const TOOLS: readonly McpTool[] = Object.freeze([ diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 12a2a42cd..716db9e4c 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1,24 +1,15 @@ import { Composio } from "@composio/core"; -import { - type AuthConfigOutcome, - type BrokerApp, - type BrokerConnection, - type BrokerField, - BrokerRefusalError, - type ComposioBroker, - type ConnectedAppBroker, - type FieldScheme, - flagOf, - isFieldScheme, -} from "./broker"; -import { - type ComposioAction, - type ComposioActions, - type ComposioResult, - LISTING_LIMIT, - vendorSentence, -} from "./composio"; -import type { AccountRef } from "./shared-accounts"; +import type { ComposioBroker, ConnectedAppBroker } from "./broker"; +import type { ComposioActions } from "./composio"; +import { accountMethods } from "./composio-client/accounts"; +import { actionsFor } from "./composio-client/actions"; +import { consentMethods } from "./composio-client/consent"; +import { createClientContext } from "./composio-client/context"; +import { directoryMethods } from "./composio-client/directory"; +import { fieldMethods } from "./composio-client/fields"; +import type { ComposioVendor } from "./composio-client/model"; + +export type { ComposioVendor } from "./composio-client/model"; /** * The one file in `server/src` that imports `@composio/core`, and what it owes the rest of them. @@ -64,4637 +55,64 @@ import type { AccountRef } from "./shared-accounts"; */ /** - * One tool as the SDK's SINGLE-TOOL call hands it over, in as much detail as anything here reads. - * - * Declared structurally rather than imported as `Tool`, for the same reason the seams it feeds are - * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` - * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for - * what is done when it is in fact missing. - * - * THIS IS THE ONE ROW THE SDK STILL VALIDATES, AND IT IS NO LONGER TWO. `Tools.transformToolCases` - * ends in `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing - * parse rather than the warn-only `transform()` every other answer here goes through, and - * `getRawComposioToolBySlug` runs it (`:719`). The LISTING used to as well (`:561`) and does not - * any more: it reads {@link VendorToolRow} off the raw client, because the wrapper that ran the - * parse is also the wrapper that could not be paged. So `ToolkitSchema` spelling that inner `slug` - * required (`src/types/tool.types.ts:12-16`) is a guarantee this declaration may still rest on, - * and it is a guarantee about exactly one call. - * - * WHAT THIS FILE HANDS ON IS STILL `unknown`, WHICH IS WHERE THAT ARGUMENT ALWAYS STOPPED BEING - * TRUE. A parse is a fact about one method of one version of one package, and what these - * declarations govern is {@link ComposioVendor} — the seam a test satisfies with a literal and the - * shape the next version will be read against. Running it shows the gap is not academic: a - * `description` of 42 crosses into `./composio` as the `string` this said it was and reaches - * `.replaceAll` in `./store` as a bare `TypeError`. A declaration is an assertion and not a check. - */ -type VendorTool = { - slug: string; - description?: unknown; - inputParameters?: unknown; - tags?: string[]; - version?: unknown; - toolkit?: { slug: string }; -}; - -/** - * One tool as the LISTING hands it over, which is the wire's own spelling and nobody's parse. - * - * SNAKE_CASE BECAUSE THIS IS COMPOSIO'S ANSWER RATHER THAN THE SDK'S RESTATEMENT OF IT. The listing - * reads `client.tools.list` directly — see {@link ComposioVendor} for why it has to — so nothing - * renames `input_parameters` on the way here and nothing runs `ToolSchema` over it. Both halves of - * that are deliberate. The rename was never a service: `transformToolCases` re-spelled the field - * and `ToolSchema.parse` then STRIPPED every schema key its `ParametersSchema` did not name — - * `if`, `then`, `else`, `examples`, every `x-` extension at the root, and `deprecated` and - * `contentEncoding` per property (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-174`) — - * before any caller could see them. What a model is shown is now what Composio published; see - * {@link ComposioAction.inputParameters}, where that loss was written down as unavoidable. - * - * AND EVERY FIELD IS DECLARED AT WHAT THE WIRE CAN HOLD, because with the parse gone there is - * nothing between Composio and {@link actionOf} at all. `slug` widens for exactly that reason: - * `ToolSchema` required it and nothing does now, and `actionOf` was already checking it anyway. - * `tags` stays narrow on the same argument it always stood on, which never involved the parse — - * `./composio` refuses a `tags` that is not a list of labels where it reads them, container and - * contents both, and a check on both sides of one seam is a check nobody maintains. - */ -type VendorToolRow = { - slug?: unknown; - description?: unknown; - input_parameters?: unknown; - tags?: string[]; - version?: unknown; -}; - -/** - * One catalogue row as the vendor hands it over, which is the wire's own spelling and nobody's map. - * - * `meta` is where all of it lives and every field of it is optional, which is not the SDK being - * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. - * See {@link ComposioBroker.listApps} below for what each absence becomes. - * - * SNAKE_CASE AND `unknown` THROUGHOUT, BECAUSE THE TRANSFORMER THIS WAS WRITTEN AGAINST IS GONE. - * The catalogue reads `client.toolkits.list` directly — see {@link ComposioVendor} for why it has - * to — so `transformToolkitListResponse` no longer stands between Composio and {@link appOf}, and - * three things it was doing have to be accounted for rather than assumed. - * - * IT RENAMED, so the count is `tools_count` here and the category's own word is `name` - * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-435`). That rename was the one - * thing in this projection easiest to get silently wrong — a count read off the wrong key is a - * plausible zero rather than an error — which is why {@link appOf} is where it is read and why a - * test asserts the figure rather than the field. - * - * IT REBUILT `meta` AND THE `categories` LIST, spreading each into a fresh literal and mapping - * every entry (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:21-34`). Those were - * this file's two structural guarantees and they were real: a `meta` of null and a `categories` of - * "crm" each raised a `TypeError` from that line when 0.18.1 was run, which is why neither was - * declared `unknown` and neither was checked. Nothing raises now. A `meta` that is a string reads - * as an app with no description, no logo, no categories and no count, and a `categories` that is a - * string reads as an app in no category — two silent, plausible answers about a real app. Both are - * declared at what the wire can hold and both are refused in {@link appOf}. - * - * AND IT NEVER VALIDATED, which is the part that does not change. `transform()` checks with - * `safeParse` and, where that fails, logs a warning and returns the unvalidated object anyway - * (`src/utils/transform.ts:26-36`), so `ToolKitItemSchema` spelling `name` required and the count a - * number always described the answer Composio MEANS to send rather than the one that arrived. Every - * wire-valued field was already `unknown` on that argument and stays so: the slug, which is the - * only name this deployment has for an app; the name, which is the only thing to show a person - * choosing between apps; the description, the logo, each category's word, and the count. - */ -type VendorToolkit = { - slug?: unknown; - name?: unknown; - meta?: unknown; - no_auth?: unknown; - auth_schemes?: unknown; - composio_managed_auth_schemes?: unknown; -}; - -/** - * One toolkit read on its own, which this file wants for exactly one thing: what it asks a person. - * - * ONE FIELD, BECAUSE ONE FIELD IS WHAT IS READ. The per-app retrieve answers everything the - * catalogue row does and a good deal more, and naming any of it here would be this file declaring - * knowledge of a shape nothing below opens. - * - * `unknown` FOR THE REASON EVERY OTHER VENDOR TYPE IN THIS FILE SAYS SO. What hangs off - * `auth_config_details` is a list of modes, each carrying the fields its scheme wants, and every - * one of those is copied across verbatim — so the declaration would be an assertion about the wire - * rather than a fact about it. {@link ComposioBroker.connectionFields} reads it a step at a time, - * and refuses at whichever step it stops being able to read rather than showing the shorter form - * that is left. - */ -type VendorToolkitDetail = { - auth_config_details?: unknown; -}; - -/** - * One auth config as the vendor hands it over, which is three fields because all three decide. - * - * `name` IS THE ONLY PROVENANCE THERE IS. Composio publishes no field saying which client created a - * config, and the listing is scoped to the project rather than to this deployment, so a config an - * operator made by hand in the dashboard comes back beside the ones made here and is otherwise - * identical. The name is the one field this deployment chooses, which is why {@link CONFIG_SUFFIX} - * is written into it and why every decision below reads it. - * - * `status` because a DISABLED config is still a config: it answers the listing, it satisfies the - * "does one exist" question, and a connect link minted against it does not work. The two facts have - * to be separable or an app with a disabled config reads as an app that is ready. - * - * BOTH OF THOSE ARE DECLARED AS THE WIRE CAN SEND THEM RATHER THAN AS THE SDK SPELLS THEM, for the - * reason the toolkit row above gives at length: `transformAuthConfigRetrieveResponse` copies - * `name` and `status` across verbatim inside a warn-only `transform()` - * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:29-58`), so - * `AuthConfigRetrieveResponseSchema` requiring a string name and an `ENABLED`/`DISABLED` enum is - * not something this file can rest on. A null name reaches {@link madeHere}, and a status the - * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and - * "disabled" are different facts and only one of them is worth telling an operator. - * - * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link readableConfigs} makes - * that check, so the declaration no longer claims more than the wire promises. It is the one of the - * three whose absence sends a request: a delete named with `undefined` asks Composio to remove - * whatever it cares to, and this deployment then records that the app was withdrawn. - * - * THE ROW BEING AN OBJECT AT ALL IS THE ONE THING THAT IS NOT IN DOUBT. - * `transformAuthConfigRetrieveResponse` reads `authConfig.toolkit.logo` while building every row - * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:41`), so a row that is not an - * object raises a `TypeError` inside the vendor's own code and never arrives. All three fields - * below are `unknown` for the opposite reason: the same function copies them across verbatim. - */ -type VendorAuthConfig = { - id?: unknown; - name?: unknown; - status?: unknown; -}; - -/** - * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. - * - * The whole enum is named rather than the two or three in use, because the point of the two lists - * below is that they are CHOICES: a reader comparing them can see which statuses each question - * leaves out, and a status added by a vendor version shows up here as a name nothing mentions - * rather than as an answer that quietly got narrower. Written as literals for the reason the - * previous `"ACTIVE"[]` was: the vendor's parameter is an enum and a widened `string[]` does not - * satisfy it. - */ -type VendorAccountStatus = - | "INITIALIZING" - | "INITIATED" - | "ACTIVE" - | "FAILED" - | "EXPIRED" - | "INACTIVE" - | "REVOKED"; - -/* - * WHERE THE LINE BETWEEN "READ AT ITS TYPE" AND "READ OUT OF `unknown`" IS DRAWN, AND WHY IT MOVED. - * - * Everything below used to be read out of `unknown` — containers and fields alike — on one argument: - * `transform()` validates with `safeParse`, logs a warning where it fails, and returns the - * unvalidated object anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`), so a - * TypeScript declaration over a wire value is an assertion and not a check. - * - * THAT ARGUMENT IS TRUE OF THE FIELDS AND FALSE OF THE SHAPES AROUND THEM, which a round of running - * the SDK against malformed answers established rather than reasoned about. `transform()` returns - * whatever its TRANSFORMER built, and every one of these transformers builds its result by - * dereferencing the raw answer — `response.items.map(...)`, `item.meta.categories`, - * `authConfig.toolkit.logo`, `response.auth_config.id`. So the containers and the rows inside them - * are the vendor's own construction and cannot arrive malformed; only the values copied ACROSS - * those lines can, and those are exactly the fields declared `unknown` above. Guards were written - * for both halves, and the ones covering the shapes were branches no answer could reach, standing - * where the next reader would take them for what was keeping them safe. - * - * WHAT KEEPS THEM SAFE IS `askVendor`. A shape the SDK could not read raises inside the SDK, and - * the `TypeError` row in {@link vendorRefusal} turns that into a sentence — one guard, at the layer - * where the fault actually surfaces, covering every shape rather than the four somebody listed. - * - * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT FOR THE FIELDS. A `?? ""`, a - * `String(x)` or a cast does not make a malformed answer safe; it converts a fault this deployment - * could have reported into an answer it gives wrongly, and the wrong answers are not small ones — - * an app with no name in an administrator's picker, an action with no slug put in front of a model, - * and the one that was actually happening: a delete sent with `undefined` where an account id belongs, - * answered by Composio however it likes, after which the audit trail records that a person's access - * was withdrawn and nothing had been. Every reader below therefore answers null on anything it - * cannot read, and every caller turns that null into a sentence naming what Composio sent. - */ - -/** - * The remedy every shape refusal here ends with, because it is the same act in every one of them. - * - * None of these is a misconfiguration. The key is right, the request is right and the answer - * arrived; what changed is the shape of it, which is a thing nobody operating this deployment can - * correct from any page it has. Saying so is the difference between an operator reading their own - * settings for an hour and an operator upgrading a package. - */ -const VENDOR_SHAPE_REMEDY = - "That is a change in what Composio answers rather than a setting an operator can correct, so upgrading this deployment's @composio/core is what fixes it."; - -/** - * WHAT COMPOSIO PUT SOMEWHERE, NAMED IN A SENTENCE A READER CAN ACT ON. - * - * Every refusal below says what arrived where something else belonged, because "Composio's answer - * was not a shape this deployment reads" sends whoever is holding the page looking through a vendor - * dashboard with nothing to look for. - * - * THE VALUE IS NEVER QUOTED, AND THAT IS THE POINT OF THE FUNCTION RATHER THAN AN INTERPOLATION. A - * listing row carries a person's mailbox address, an account handle and whatever else the vendor - * chose to put on it, and a refusal from here is read off an admin page, written into an app's - * `lastError` and put in front of a model. The shape is the part that is safe to say and is also - * the only part that helps: a reader who knows a list arrived where an object belongs knows which - * vendor change they are looking at. - * - * AND THE EMPTINESS IT REPORTS IS THE ONE THE CALLER JUDGED, WHICH IT WAS NOT. Every caller reaches - * this function on the branch {@link textOf} sent it down, and `textOf` decides on the TRIMMED - * value while this tested `value === ""` — so a padded blank, which is the shape a wire value - * actually arrives in, was refused for being empty and then described as "a string". "Composio sent - * a string where the id belongs" is a sentence with no finding in it: a string is what an id IS, so - * the reader is told the field was right and the call refused anyway. The two branches now agree on - * what blank means, and they say which of the two blanks arrived, because an id that is three - * spaces and an id that is absent are different things to go looking at in a dashboard. - */ -function sent(value: unknown): string { - if (value === undefined) return "nothing"; - if (value === null) return "null"; - if (Array.isArray(value)) return "a list"; - if (typeof value === "string") { - if (value === "") return "an empty string"; - return value.trim() === "" ? "a string of blank space" : "a string"; - } - if (typeof value === "object") return "an object"; - return `a ${typeof value}`; -} - -/** - * A vendor status named as itself, because an enum value is the one wire value worth quoting. - * - * {@link sent} withholds what it is given for a reason that does not reach here: an auth config's - * status is one of a closed set of vendor enum names, carries nobody's data, and IS the finding — - * "Composio called it PENDING" is something an operator can search their dashboard and the vendor's - * changelog for, where "Composio sent a string" is something they can only shrug at. - * - * AND THE ARGUMENT ONLY HOLDS WHILE THE VALUE IS ACTUALLY ONE OF THOSE NAMES, which is the hole - * this closes. Every caller reaches this function on the branch taken precisely BECAUSE the value - * is not one of the words the code expects — so what arrives is not "an enum name Composio has - * added", it is whatever came off the wire: a gateway's HTML error page, a stack trace, a sentence - * carrying a person's mailbox address, a megabyte of it. That string was interpolated whole into a - * refusal that is read off an admin page, written into an app's `lastError` and put in front of a - * model. - * - * SO THE SHAPE OF AN ENUM NAME IS THE TEST, and anything that is not one is described by - * {@link sent} like every other wire value in this file. A vendor enum name is a short run of - * letters, digits and underscores; nothing that fails that is a word an operator could search a - * changelog for, which was the entire argument for quoting it. - */ -const VENDOR_ENUM_NAME = /^[A-Za-z0-9_]{1,40}$/; - -function named(value: unknown): string { - const text = typeof value === "string" ? value.trim() : ""; - return VENDOR_ENUM_NAME.test(text) ? `"${text}"` : sent(value); -} - -/* - * THE CONTAINER OF A LISTING IS THE ONE THING BELOW THAT IS NOT READ OUT OF `unknown`, AND THE - * REASON IS THAT THE SDK PROVES IT. - * - * There used to be a `hasFields` predicate and an `itemsOf` reader here, and every listing passed - * its answer through them before touching a field — on the argument the row types give at length, - * that a TypeScript interface over a wire value is an assertion rather than a check. That argument - * is correct about the FIELDS and wrong about the CONTAINER, which running `@composio/core` 0.18.1 - * settles rather than reasons about. Every list transformer dereferences the answer before - * returning it: `response.items.map(...)` in `transformAuthConfigListResponse` - * (`src/utils/transformers/authConfigs.ts:79`), in `transformConnectedAccountListResponse` - * (`connectedAccounts.ts:113`). So a container of the wrong shape — null, a bare list where an - * envelope belongs, an `items` that is a string — dies inside the vendor's code and NEVER arrives - * here. Each of those guards was therefore a branch no input could reach, sitting where the next - * reader would take it for the thing keeping them safe. - * - * WHAT KEEPS THEM SAFE IS ONE LAYER DOWN NOW. The vendor's crash is a bare `TypeError`, and - * {@link vendorRefusal} translates it into a sentence naming what did not happen and the one act - * that changes it — which catches every malformed container, including the shapes nobody here - * thought to enumerate. - * - * AND IT IS TWO OF THE FOUR LISTINGS NOW RATHER THAN ALL OF THEM, WHICH IS THE COST OF PAGING THE - * OTHER TWO. The catalogue and the action listing read `@composio/client` directly — a generated - * client that parses the body and returns it — so nothing dereferences their answers before this - * file does. The same two shapes therefore reach this code rather than dying in the vendor's, and - * {@link pageOf} answers them at exactly those two call sites. The argument above still holds - * everywhere it is made: a guard is written where an input can reach it and nowhere else. - * - * SO THE DECLARATIONS BELOW ARE READ AT THEIR TYPES, and each one says which vendor line makes it - * true. The fields inside them stay `unknown`, because the warn-only `transform()` really does copy - * those across whatever they turn out to be. - */ - -/** - * One field as the non-empty string it has to be, or null where the vendor sent anything else. - * - * EMPTY COUNTS AS ABSENT because every caller of this reads an identifier — a slug, an id, a name - * this file matches a suffix against — and an empty identifier is unusable in exactly the way a - * missing one is, while being the one that reads as present at every glance. - * - * AND THE STRING THAT COMES BACK IS THE ONE THAT WAS JUDGED, which it was not. This decided - * emptiness on the TRIMMED value and answered the PADDED one, so " " was correctly refused while - * " ac_1 " was accepted and handed on with its spaces — a guard that checked one thing and passed - * along another. What that reached is the whole of this file: a padded id is what an auth-config - * delete and an account withdrawal NAME, so Composio is asked to remove an object nobody has; - * a padded slug is what an enabled app records into its url and what an action list writes into - * `mcp_tools`; and a padded app slug on the vendor's own answer compares unequal to the app the - * caller was gated on, refusing a call that was about the right app the whole time. Trimming is - * not tidying here — it is answering with the identifier rather than with the identifier plus - * whatever the wire wrapped it in. - */ -function textOf(value: unknown): string | null { - if (typeof value !== "string") return null; - const text = value.trim(); - return text === "" ? null : text; -} - -/** - * Who an error sentence is about, named the way the holder it acts for would read it. - * - * A DEPLOYMENT HOLDS NO "PERSON" TO BE TOLD ABOUT. The sentences below were all written for a - * person pressing Connect or Disconnect on their own settings page, and every one of them says so - * in the words "this person" — which is simply wrong where the account acted for is the - * deployment's own shared one, with nobody's personal credential anywhere behind it. - */ -const whose = (account: AccountRef) => - account.holder === "deployment" - ? "this deployment's shared account" - : "this person"; - -/** - * How many pages of one listing this deployment will read before it stops and says so. - * - * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. What it guards is a - * cursor that keeps being handed back, which without a ceiling is a request that never returns: a - * person waiting on a page they pressed disconnect from, and a process holding every row it has - * read so far. - * - * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for - * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling - * again, one order of magnitude further out and harder to notice. - * - * IT WAS 50, AND 50 WAS ARGUED FROM TWO NARROW LISTINGS THAT ARE NO LONGER THE ONLY ONES. The - * number was justified by one app's authorization configs and one person's accounts for one app: - * at {@link LISTING_LIMIT} rows a page a second page is extraordinary there and a fiftieth is not - * a data set. The app CATALOGUE is not that listing. Composio publishes more than - * {@link LISTING_LIMIT} toolkits today, so it is a listing whose SECOND page is the ordinary case, - * and a ceiling reasoned about from the narrow two would be sitting on top of a healthy answer - * rather than above it. - * - * 200 IS CHOSEN AGAINST THE PAGE THE VENDOR MIGHT ACTUALLY SEND rather than the one asked for, and - * that is the whole of the arithmetic. Asking for {@link LISTING_LIMIT} does not oblige Composio to - * answer with it — the cursor is documented as "a base64 encoded string of the page and limit" - * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:469-478`), so the page size is the - * vendor's to settle. At the page asked for, 200 is 200,000 rows, two orders of magnitude past any - * catalogue Composio has published. At the vendor's OWN default page of twenty it is 4,000 rows, - * which still clears today's catalogue with room — where 50 pages of twenty is 1,000, which is - * today's catalogue exactly, and a ceiling that lands on the real answer is a healthy vendor turned - * into a refusal. And 200 sequential requests is still a request that ends. - * - * ONE NUMBER FOR ALL FOUR LISTINGS, because the two narrow ones lose nothing by it: they refuse a - * runaway cursor after 200 pages instead of 50, and there is no state in which a real answer to - * either of those questions is even a second page. A per-listing ceiling would be a second number - * to reason about in exchange for tightening a bound that nothing genuine approaches. - * - * AND IT IS THE NUMBER OF PAGES THAT ARE READ, WHICH IS NOT WHAT IT USED TO BE. The test stood - * ahead of the line recording the page it was counting, so the set held one fewer than had - * arrived and the refusal fired on the two-hundred-FIRST page while telling its reader two hundred. - * A ceiling is a number somebody reasons about; stating one and doing another makes it the one - * number here nobody can check. - */ -const PAGE_CEILING = 200; - -/** - * The listing a refusal is about, as the two clauses every sentence below is built from. - * - * WRITTEN AT THE CALL SITE for the same reason {@link VendorCall}'s outcome is: what could not be - * told is a fact about the question being asked — "whether this person is connected" is not - * something {@link everyRowOf} can know — and composing it beside the call keeps it true. - */ -type Listing = { - /** The listing as a noun phrase: "this person's gmail accounts". */ - noun: string; - /** What could not be told, as a clause following "so": "whether one exists could not be read". */ - consequence: string; -}; - -/** - * EVERY ROW OF A LISTING THE VENDOR PAGES, or a refusal rather than a fragment read as the whole. - * - * EVERY LISTING IN THIS FILE COMES THROUGH HERE NOW, AND TWO OF THEM USED TO REFUSE INSTEAD. This - * paragraph said that `fetchDirectory` met a full page and refused, that `./composio` did the same - * with a full action listing, and that both were right to: "the SDK offers no cursor to ask for a - * second page with, so an answer at the ceiling and an answer past it are indistinguishable and no - * second request could tell them apart". The reasoning was sound and the premise was wrong. It was - * a fact about the WRAPPER — `ToolListParamsSchema` names no cursor and - * `transformToolkitListResponse` drops the response's — and never about the request, which the raw - * client has always been able to compose: both list params carry `cursor` and both responses carry - * `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:467-478` and - * `:322-326`, `resources/tools.d.ts:421-432` and `:200-204`). The cost of the mistake was not - * theoretical: Composio publishes more than {@link LISTING_LIMIT} toolkits, so the catalogue - * refusal fired on the first call every time and the app picker showed an operator nothing at all. - * See {@link ComposioVendor}, where both of those listings now name the raw client. - * - * THE OTHER TWO WERE ALWAYS EXPRESSIBLE THROUGH THE WRAPPER: `AuthConfigListParamsSchema` and - * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, - * `src/types/authConfigs.types.ts:124-131`, `src/types/connectedAccounts.types.ts:259-266`), both - * models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`) and - * both transformers fill `nextCursor` in from the response's `next_cursor` - * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). - * - * AND THE CALLER THAT DECIDES IT IS `revoke`. Refusing a truncated listing would be honest and - * would also mean that the person it happened to could never disconnect: every attempt would meet - * the same page and the same refusal, with their grants standing the whole time. The removal of an - * app is the same shape one level up. Reading the rest is the answer that finishes the job, and - * refusing is what is left for the cases where reading the rest is not possible — which is what the - * three refusals below are, and why none of them can be reached by a caller carrying a partial - * answer that reports itself complete. - * - * THE FIRST REQUEST CARRIES NO CURSOR FIELD AT ALL rather than an undefined one, which is what - * {@link everyRowOf}'s callers spread for: a `cursor: undefined` would reach the vendor's `parse` - * as a key, and an explicit undefined is not something this file needs to make the SDK have an - * opinion about. - * - * THERE WAS A FOURTH REFUSAL HERE AND IT MOVED RATHER THAN DIED, which is the correction the raw - * client forces. It stood at the top of this loop for a page that is not an envelope at all, and - * for the two SDK listings no answer can reach it: both transformers begin `response.items.map(...)` - * (`src/utils/transformers/authConfigs.ts:79`, `connectedAccounts.ts:113`), so a null, a bare list - * and an `items` that is not one all raise a `TypeError` inside the vendor's own code, answered - * where it happens — see the `TypeError` row in {@link vendorRefusal}. Nothing dereferences the - * answer on the two RAW listings, so for those the same shapes reach this loop, and - * `rows.push(...answered.items)` over a string is "string is not iterable" with nothing in it a - * person can act on. {@link pageOf} is where that is answered, at the two call sites that need it, - * rather than as a branch every listing pays for and two of them cannot reach. - * - * What is left below are the three faults every one of the four can hand over, all of them about - * the cursor, because the cursor is the one field nothing on any of these paths checks. - * - * AND A QUESTION ALREADY ANSWERED STOPS HERE, WHICH IS WHAT `enough` IS FOR. Paging made three of - * this file's answers complete and made one of them FAILABLE: {@link ComposioBroker.isConnected} - * returns a boolean, and the first page carrying a single row has settled it — no cursor Composio - * could send next, and no fiftieth page, can turn that `true` into anything else. Reading on - * anyway put the two cursor refusals and the page ceiling in front of a person whose account had - * already been found, so a fault on a page nobody needed decided the answer to a question nobody - * still had. The default reads every page, because that is what a withdrawal and a config listing - * genuinely need; a caller that says when it has enough is a caller that cannot be failed after it - * has its answer. - * - * CHECKED WHERE THE ROWS LAND AND BEFORE THE CURSOR IS LOOKED AT, deliberately. Reading the cursor - * first and stopping afterwards would keep every one of the three refusals reachable on the page - * that already answered the question, which is the whole of what this closes. - */ -async function everyRowOf( - listing: Listing, - page: ( - cursor: string | undefined, - ) => Promise<{ items: Row[]; nextCursor?: unknown }>, - enough: (rows: Row[]) => boolean = () => false, -): Promise { - const rows: Row[] = []; - const followed = new Set(); - let cursor: string | undefined; - - for (;;) { - const answered = await page(cursor); - rows.push(...answered.items); - if (enough(rows)) return rows; - - /* - * ABSENT AND NULL BOTH MEAN THE END. The auth-config schema spells the field nullable and the - * connected-account one spells it nullish, and a transformer that met no `next_cursor` writes - * `response.next_cursor ?? null`. - * - * AND SO DOES A CURSOR WITH NOTHING IN IT, WHICH IS THE CORRECTION AND WAS THE COSTLIEST - * REFUSAL IN THIS FILE. The claim above used to be that absent and null are "the two the vendor - * actually sends" — which is not something this deployment can know, and the installed types - * say otherwise: all four list responses declare `next_cursor?: string | null` - * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, - * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal - * on the wire, and `?? null` does not catch it. It therefore arrived here, failed - * {@link textOf}, and became a refusal — one that kills `revoke`, `authorize`, - * `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` for EVERY app at once, permanently, - * over a field whose whole content is that there is nothing in it. - * - * A CURSOR NAMES A POSITION, AND THE BLANK ONE NAMES NONE. It is exactly what this loop sends - * when it has no position — the first request omits the field — so following it would ask for - * page one again, and the repeat guard below would then answer the vendor's empty string with - * a sentence accusing it of sending the same page twice. There is no reading of `""` under - * which a second request could reach anything the first did not. So it is the end of the - * listing, which is the same rule {@link textOf} already applies to every other identifier - * here, applied to the one field that had been left out of it. - * - * WHICH IS NOT COERCION, AND THE DIFFERENCE IS THE TEST BELOW IT. A cursor that is a number, an - * object or a list is a position this deployment cannot express and CANNOT rule out being real, - * so it is still the refusal it always was: one page read as the whole answer is the mistake - * this function exists to prevent. What changed is only the string that says nothing. - */ - const next = answered.nextCursor; - if (next === undefined || next === null) return rows; - - const follow = textOf(next); - if (follow === null) { - if (typeof next === "string") return rows; - throw new BrokerRefusalError( - `Composio sent ${sent(next)} where the cursor to the next page of ${listing.noun} belongs, so ${listing.consequence}: there are more of them than arrived and no cursor this deployment can ask for the rest with. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * A CURSOR ALREADY FOLLOWED IS A LOOP AND NOT A PAGE. Nothing here can tell a vendor bug from a - * proxy answering from a cache, and both end the same way: the same rows for ever. Refusing on - * the second sight of one is what keeps a person's disconnect a request that returns. - */ - if (followed.has(follow)) { - throw new BrokerRefusalError( - `Composio answered the same page of ${listing.noun} twice, so ${listing.consequence}: following its cursor did not advance, so the rest of them cannot be reached. ${VENDOR_SHAPE_REMEDY}`, - ); - } - followed.add(follow); - - /* - * COUNTED AFTER THE PAGE IS RECORDED, so the number in the sentence is the number of pages that - * happened — see {@link PAGE_CEILING} for the off-by-one this order closes. - * - * AND THE ROWS ARE THE ONES THAT ARRIVED. The sentence asserted `at ${LISTING_LIMIT} rows each`, - * which is the page this deployment ASKED FOR and not one thing it measured: Composio is free - * to answer fifty pages of one row, and a reader told the listing was fifty thousand rows long - * would be reading a figure nothing had counted. What is said now is what was collected. - */ - if (followed.size >= PAGE_CEILING) { - throw new BrokerRefusalError( - `Composio has answered ${PAGE_CEILING} pages of ${listing.noun}, ${rows.length} rows in all, and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, - ); - } - cursor = follow; - } -} - -/** - * ONE PAGE OFF THE RAW CLIENT, CHECKED FOR BEING A PAGE, in the shape {@link everyRowOf} reads. - * - * THE TWO SDK LISTINGS DO NOT NEED THIS AND THE TWO RAW ONES CANNOT DO WITHOUT IT. Every answer the - * wrapper hands over has already been dereferenced inside the vendor's package — both transformers - * open with `response.items.map(...)` — so a malformed envelope there is a `TypeError` raised - * inside `@composio/core` and translated by {@link vendorRefusal} with a sentence about a package - * upgrade. `@composio/client` is a generated client: it parses the body and returns it. Nothing - * looks at `items` before this file does. - * - * SO THE TWO SHAPES THE WRAPPER USED TO CATCH ARE CAUGHT HERE, and they are the same two: - * an answer that is not an envelope, and an `items` that is not a list of rows. Both would - * otherwise reach `rows.push(...answered.items)` as a bare `TypeError` naming a vendor field — - * the crash-wearing-a-refusal's-clothes that every sentence in this file exists not to be. - * - * OUTSIDE {@link askVendor} RATHER THAN INSIDE IT, deliberately, and it is the reason this is a - * function rather than four lines at each call site. That wrapper goes around the `await vendor.*` - * AND NOTHING ELSE, which is what makes "a throw reaching `vendorRefusal` came out of the vendor's - * code" true by construction; a refusal composed here is this file's own reading, and it already - * carries an authored sentence. - * - * THE CURSOR IS RENAMED AND NOT READ. `next_cursor` is the wire's spelling and `nextCursor` is what - * {@link everyRowOf} looks for, and it is carried across as `unknown` — every check on it belongs - * there, where the three faults it can carry are enumerated and answered together for all four - * listings. - */ -async function pageOf( - listing: Listing, - ask: () => Promise<{ items?: unknown; next_cursor?: unknown } | null>, -): Promise<{ items: Row[]; nextCursor?: unknown }> { - const answered = await ask(); - /* - * A BARE LIST IS EXCLUDED HERE AND NOT LEFT TO THE `items` GUARD, which is the same correction - * every other container in this file has already had. `typeof [] === "object"` and `[] !== null`, - * so a page that arrived as a bare list satisfied this test, fell through to the one below, and - * was refused with "Composio sent nothing where the rows of the app catalogue belong" — a sentence - * about an envelope whose rows did not arrive, told of an answer that held no envelope at all. An - * operator reading it goes looking for a listing that came back short; there was no listing. - * - * AND IT IS THE LIKELIER OF THE TWO SHAPES RATHER THAN AN EXOTIC ONE. The SDK transformers these - * two listings stopped going through returned bare arrays, so "a list where a page belongs" is - * precisely what a drift back towards them would put on the wire — the one shape this guard's own - * sentence is written for and the one it could not see. - */ - if ( - answered === null || - typeof answered !== "object" || - Array.isArray(answered) - ) { - throw new BrokerRefusalError( - `Composio sent ${sent(answered)} where a page of ${listing.noun} belongs, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, - ); - } - if (!Array.isArray(answered.items)) { - throw new BrokerRefusalError( - `Composio sent ${sent(answered.items)} where the rows of ${listing.noun} belong, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return { items: answered.items as Row[], nextCursor: answered.next_cursor }; -} - -/** - * Where each field scheme sits in the order a person would rather meet them. - * - * A RECORD KEYED BY {@link FieldScheme} RATHER THAN A LIST OF THEM, because the compiler counts the - * keys of a record and counts nothing at all about a list. This was a `readonly FieldScheme[]` - * spelling the same four names a second time, which is precisely what the comment on - * {@link FIELD_SCHEME_NAMES} argues against: the same set stated twice, where a member added to one - * of them and not the other typechecks perfectly. An element type refuses a name that is not a - * scheme and says nothing whatever about a scheme left out. - * - * AND THE ONE LEFT OUT FAILS OPEN, MORE QUIETLY HERE THAN THERE. A fifth scheme added to the names - * and not given a rank here would match nothing in the pick below, so every app publishing it would - * fall through to `unsupported` — and the directory route hides an unsupported app, so those apps - * would simply leave the picker: no refusal for anyone to read, no operator sentence, and no failing - * test. `Record` does not compile until the new scheme has a place, which is - * the whole of the protection, and the reason the pick goes through {@link isFieldScheme} rather - * than through a second list of names. - * - * THE ORDER ITSELF IS THE PERSON'S AND NOT THE WIRE'S, and it is unchanged: a plain key first, - * because it is the one they are likeliest to already hold, then the other three ways of spelling a - * secret they have to go and assemble. - */ -const FIELD_SCHEME_ORDER: Record = { - API_KEY: 0, - BEARER_TOKEN: 1, - BASIC: 2, - BASIC_WITH_JWT: 3, -}; - -/** - * The schemes a managed config can actually send somebody to a page for, as the VENDOR names them. - * - * WHICH IS THE WHOLE OF WHAT `consent` CLAIMS. That kind means one thing below: create a - * `use_composio_managed_auth` config and mint a link the person visits. Composio publishes the set - * of schemes a link exists for and calls it exactly that — `RedirectableAuthSchemeSchema` is - * `z.enum([OAUTH1, OAUTH2])` (`@composio/core` 0.18.1, - * `src/types/connectedAccountAuthStates.types.ts:15-18`) — so a managed scheme outside it has no - * page to send anybody to, whatever else is true of it. - * - * `DCR_OAUTH` IS NOT HERE AND IS NOT AN OMISSION. The vendor leaves it out of that enum and this - * file drives it through its own kind — `self-registering`, a CUSTOM config whose client registers - * itself at connect time — so it is read off {@link VendorToolkit.auth_schemes} below rather than - * off the managed list. - */ -const REDIRECTING_SCHEMES = ["OAUTH2", "OAUTH1"]; - -/** - * The scheme Composio publishes for an app that asks nobody for anything. - * - * THE FLAG AND THE SCHEME ARE TWO SPELLINGS OF ONE FACT, and only the second is guaranteed. A - * toolkit needing no authentication publishes `NO_AUTH` among its schemes; `no_auth` is an - * additional boolean the catalogue MAY carry, and `ToolKitItemSchema` spells it optional. Reading - * only the flag made an app that published the scheme without it `unsupported` — whose sentence - * claims the app wants an OAuth application registered here, which is the opposite of true — and - * `routes.ts` then hid the app from the picker over it. - */ -const NO_AUTH_SCHEME = "NO_AUTH"; - -/** - * The words out of an `unknown`, which is all a vendor list promises — and a refusal for the rest. - * - * ABSENT IS AN ANSWER AND UNREADABLE IS NOT, WHICH IS THE WHOLE OF THE CORRECTION. Composio - * genuinely lists toolkits with no scheme beside them at all, and {@link connectionOf} reads that - * as an app nothing here can connect — a true thing to say about a real row. This used to answer - * the same empty list for an `auth_schemes` that arrived as a string, and for a list one of whose - * members was not a word: the unreadable answer and the honest absence became the same sentence, - * and an unsupported app is HIDDEN from the picker, so the one nobody could act on was also the one - * nobody could see. On the managed list it is louder still — an app whose consent screen would have - * asked a person for nothing drops a rank and starts asking them to go and find a key. - * - * A MEMBER IS GUARDED AS HARD AS THE LIST, because dropping the ones that are not words leaves a - * shorter list that reads exactly like a shorter list the app published, and the decision below is - * made on which words are in it. - * - * AND THE WORD IS THE TRIMMED ONE, WHICH IS WHY THE MEMBER GOES THROUGH {@link textOf}. A scheme is - * the NAME of a flow: it is compared against the literals below and recorded on the app's row when - * somebody enables it, so a padded `" OAUTH2 "` is the app's own scheme wearing whatever the wire - * wrapped it in, and `" "` is the one unusable value that reads as present at every glance. - * - * `where` IS THE NOUN PHRASE THE SENTENCE IS BUILT AROUND, so the two call sites differ in the one - * thing that differs between them: which of an app's two scheme lists could not be read. - */ -function labelsOf(value: unknown, where: string): string[] { - const listed = value ?? []; - if (!Array.isArray(listed)) { - throw new BrokerRefusalError( - `Composio sent ${sent(value)} where ${where} belong. This deployment reads that list to decide which flow the app gets, and an app that publishes no scheme at all is ordinary and shows as one nothing here can connect — so a list that is not a list would show as that same app, missing from the picker for a reason nobody could act on. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return listed.map((entry: unknown, index: number) => { - const scheme = textOf(entry); - if (scheme === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(entry)} where scheme ${index + 1} of ${where} belongs, and a scheme is the word this deployment matches against the flows it knows how to run. Leaving out the ones that are not words makes a shorter list, which reads exactly like a shorter list the app published. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return scheme; - }); -} - -/** - * One step of the connection form's read that Composio answered with a shape nothing here can open. - * - * WHY THIS IS A REFUSAL AND NOT AN EMPTY FORM is the argument {@link labelsOf} makes about an app's - * scheme lists, arriving where it costs the most. {@link ComposioBroker.connectionFields} - * reads the boxes an app asks for a step at a time off `unknown` — the modes, the recorded one's - * `fields`, the initiation block inside that, and the two lists of rows inside that — and every - * step of it answered an empty list for a shape it could not make sense of. An empty list is a - * sentence some modes truthfully say. A step that arrived as something else says only that Composio - * moved the shape, and the two reached the browser as the same empty form: a person presses submit - * on it, {@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. - * - * NOT THE SENTENCE FOR A MODE THE APP HAS STOPPED PUBLISHING, which is the other half of the same - * confusion. That one is a real drift between the recorded scheme and the vendor, and its remedy is - * an administrator's: remove the app and add it again so the scheme is recorded afresh. Nothing an - * administrator can do fixes a shape Composio changed, and handing them that remedy sends them to - * re-add an app whose publication never moved — after which the re-add reads the same unreadable - * answer and records the same word. - */ -function unreadableForm(was: unknown, where: string): BrokerRefusalError { - return new BrokerRefusalError( - `Composio sent ${sent(was)} where ${where} belongs, and this deployment reads that answer to draw the boxes a person types their credential into. An app that asks for nothing and an answer this deployment cannot read would be the same empty form, and submitting an empty form makes a connection carrying no credential at all that Composio accepts and the first tool call discovers — so no form was drawn. ${VENDOR_SHAPE_REMEDY}`, - ); -} - -/** - * Which flow an app gets, and why that order. - * - * NO AUTHENTICATION 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 that says so has no other reading available, whatever else it - * publishes beside it. It says so in either of two places; see {@link NO_AUTH_SCHEME}. + * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. * - * Then managed OAuth, because it asks the person for nothing at all — and MANAGED OAUTH IS READ - * RATHER THAN COUNTED, which is the difference between a consent and a dead end. This tested the - * LENGTH of the managed list, so a list holding any word at all became `consent`: the app got a - * Composio-managed config and a person got sent to a link mint for a scheme with no page behind it. - * {@link REDIRECTING_SCHEMES} is the vendor's own answer to which words have one. + * 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. * - * 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. + * 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. * - * AND "UNSUPPORTED" IS A VERDICT RATHER THAN A PLACE TO PUT WHAT COULD NOT BE READ. `routes.ts` - * hides an unsupported app from the picker, and the enable route answers its `reason` — so a row - * whose shape this file could not read used to leave the catalogue silently, under a sentence - * telling an administrator the app wants an OAuth application when nothing here had established - * that. Every unreadable shape now refuses, which stops the whole catalogue for the reason - * {@link appOf} gives at length: one refusal an operator can act on is worth more than several - * hundred rows, one of which is a guess. + * 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 function connectionOf(row: VendorToolkit): BrokerConnection { - /* - * THE APP IS NAMED OFF THE ROW RATHER THAN HANDED IN, because a refusal out of {@link labelsOf} - * travels up through {@link appOf} and onto an operator's page, where "this app" is a sentence - * with nothing to look for in it. {@link appOf} refuses a row with no readable slug before it - * reaches here, so the fallback is for the one caller that is a test. - */ - const at = textOf(row.slug) ?? "this app"; - const offered = labelsOf( - row.auth_schemes, - `the authentication schemes ${at} offers`, - ); - const managed = labelsOf( - row.composio_managed_auth_schemes, - `the authentication schemes Composio holds ${at}'s own credentials for`, - ); - - /* - * AND A FLAG THIS FILE CANNOT READ IS NOT THE SAME AS ONE THE APP DOES NOT PUBLISH. Most rows - * carry no `no_auth` at all and reading that as "this app needs authenticating" is the right - * answer, which is what {@link flagOf}'s default keeps — and the scheme list below carries the - * same fact for every toolkit that publishes it. A `no_auth` that is PRESENT and is not a - * boolean has no such reading: `"true" === true` is false, so the vendor saying an app needs - * nothing arrived at the very check that acts on it as the vendor having said nothing — and the - * app is then enabled down a path Composio refuses outright ("Cannot create an auth config for - * toolkit hackernews because it does not require authentication"), or a person is sent to a form - * for a credential the app has no use for. - */ - const flagged = flagOf(row.no_auth, false); - if (flagged === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.no_auth)} where its flag saying whether ${at} needs authenticating at all belongs, and that one field decides the whole of the flow: an app flagged this way is one Composio REFUSES an authorization config for, whatever else it publishes beside the flag. A value this deployment cannot read is not the app's silence on the question, so it is not read as one. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - if (flagged || offered.includes(NO_AUTH_SCHEME)) return { kind: "no-auth" }; - - if (managed.some((scheme) => REDIRECTING_SCHEMES.includes(scheme))) { - return { kind: "consent" }; - } - if (offered.includes("DCR_OAUTH")) return { kind: "self-registering" }; - - const field = offered - .filter((scheme) => isFieldScheme(scheme)) - .sort( - (left, right) => FIELD_SCHEME_ORDER[left] - FIELD_SCHEME_ORDER[right], - )[0]; - if (field) return { kind: "fields", authScheme: field }; - - /* - * THE SENTENCE NAMES WHAT WAS ACTUALLY PUBLISHED, INCLUDING THE MANAGED WORDS NOTHING ELSE READS. - * A managed list of schemes that do not redirect is now the commonest way to reach this branch, - * and a reason built from `auth_schemes` alone would tell an administrator the app published - * nothing — about a row that published something this deployment simply cannot drive. - */ - const published = [...new Set([...offered, ...managed])]; - return { - kind: "unsupported", - reason: published.length - ? `${published.join(", ")} needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.` - : "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", - }; -} /** - * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. - * - * A ROW THIS FILE CANNOT READ STOPS THE WHOLE CATALOGUE, for the reason the full-page guard in - * {@link buildComposioClient} gives at length: the directory is held for ten minutes and both the - * picker and the enable route read the held copy, so a row quietly dropped is an app missing from a - * search and an app whose Add button reports that Composio does not publish it. One refusal an - * operator can act on is worth more than several hundred rows, one of which is a guess. + * Both seams, over one vendor client. * - * 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. + * 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 ROW'S SHAPE IS CHECKED AGAIN, AND THAT IS THE RAW CLIENT RATHER THAN A CHANGE OF MIND. Two - * refusals used to open this function — one for a row that is not an object, one for a row with no - * meta — and they were deleted because neither could be reached: `transformToolkitListResponse` - * read `item.meta.categories` while building each row (`@composio/core` 0.18.1, - * `src/utils/transformers/toolkits.ts:27`) and `Toolkits.getToolkits` rethrew everything as - * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`), so a catalogue this deployment - * could not read never became a row here at all. The catalogue does not go through that function - * any more — see {@link ComposioVendor} — and both shapes now arrive. + * 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. * - * 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. + * @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. */ -function appOf(row: VendorToolkit, position: number): BrokerApp { - const at = `row ${position + 1} of Composio's app catalogue`; - - if (typeof row !== "object" || row === null || Array.isArray(row)) { - throw new BrokerRefusalError( - `Composio sent ${sent(row)} where ${at} belongs, and a catalogue row is an object carrying an app's slug and name. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const slug = textOf(row.slug); - if (slug === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.slug)} where the slug of ${at} belongs. The slug is the only name this deployment has for an app — it is what enabling one records and what every later call names — so the directory was not shown, rather than shown with an app nothing could be done with. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const name = textOf(row.name); - if (name === null) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.name)} where the name of ${at} belongs, and ${slug} is a slug rather than a title, so there is nothing to show an administrator choosing between apps. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * `meta` IS CHECKED FOR BEING AN OBJECT, WHICH IT DID NOT USED TO BE AND NOW HAS TO BE. - * - * The argument for taking it on trust was `transformToolkitListResponse`: it read - * `item.meta.categories` while building each row and then spread the result into a fresh literal - * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:24-38`), so a meta that was - * absent or null raised there and every meta that did arrive was an object whatever the wire had - * sent. That was a guarantee about the transformer, it was written down as one, and the catalogue - * no longer goes through it — see {@link ComposioVendor}, where the toolkit listing names the raw - * client because the wrapper could not be paged. - * - * WHAT THE ABSENT GUARD WOULD COST IS FOUR SILENT ABSENCES RATHER THAN A CRASH, which is the - * worse of the two. `meta.description` off the string "productivity" is `undefined`, not a throw, - * and so are the logo, the categories and the count — so a row this file cannot read would show - * on an administrator's screen as a real app that publishes nothing, indistinguishable from the - * many that genuinely publish little. Absent IS an answer here, which is exactly why a meta that - * is present and is not a meta cannot be allowed to look like one. - */ - const rawMeta = row.meta; - if ( - typeof rawMeta !== "object" || - rawMeta === null || - Array.isArray(rawMeta) - ) { - throw new BrokerRefusalError( - `Composio sent ${sent(row.meta)} where ${slug}'s description, logo, categories and action count belong. Every one of those is a thing an app is allowed to publish none of, so a row whose metadata is not readable at all would show as a real app that publishes nothing rather than as the answer this deployment could not read. ${VENDOR_SHAPE_REMEDY}`, - ); - } - const meta = rawMeta as { - description?: unknown; - logo?: unknown; - categories?: unknown; - tools_count?: unknown; - }; - - const description = meta.description ?? ""; - if (typeof description !== "string") { - throw new BrokerRefusalError( - `Composio sent ${sent(meta.description)} where ${slug}'s description belongs. An app that publishes none is ordinary and reads as a gap on the page; an app whose description is not text is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const logo = meta.logo ?? null; - if (typeof logo !== "string" && logo !== null) { - throw new BrokerRefusalError( - `Composio sent ${sent(meta.logo)} where ${slug}'s logo belongs, and this deployment puts that value in an image address. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * NEITHER THE LIST NOR THE ENTRIES IN IT ARE ANYBODY'S CONSTRUCTION NOW, so both are checked. - * - * The list used to be the transformer's: `item.meta.categories?.map(category => ({ slug: - * category.id, name: category.name }))` (`@composio/core` 0.18.1, - * `src/utils/transformers/toolkits.ts:27-30`) has no `.map` for a `categories` that is not a - * list, and that half genuinely held when it was run. It does not hold through the raw client, - * where nothing maps this field at all — a `categories` of "productivity" would simply read as an - * app in no category, which is a state real apps are in. - * - * THE ENTRY GUARD WAS ALREADY BACK, AND FOR A REASON WORTH KEEPING IN VIEW. It had been deleted - * on the ground that "a category that is null throws on `.id`, and everything that survives is an - * object". Only the first clause was ever true: `("crm").id` is `undefined`, not a throw, and so - * is `(7).id` — so a primitive survived the map as `{ slug: undefined, name: undefined }`, and - * the whole catalogue was refused with "Composio sent nothing where the name of gmail's category - * 1 belongs" about a value that was the string "crm". An operator reading that goes looking in a - * dashboard for a category with a missing name, and there is no such category. - * - * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, twice over, and the three faults get the three - * sentences they are. The vendor's own word is `name` on both spellings of this row - * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:425-435`), which is why that is - * the one field read. - */ - const listed = meta.categories ?? []; - if (!Array.isArray(listed)) { - throw new BrokerRefusalError( - `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong, and the catalogue shows an app's categories as the words a person chooses by. An app in no category is ordinary; a list of them that is not a list is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, - ); - } - const categories = listed.map((entry: unknown, index: number) => { - const at = `${slug}'s category ${index + 1}`; - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { - throw new BrokerRefusalError( - `Composio sent ${sent(entry)} where ${at} belongs, and a category is an object carrying the word a person picks an app by. ${VENDOR_SHAPE_REMEDY}`, - ); - } - const label = textOf((entry as { name?: unknown }).name); - if (label === null) { - throw new BrokerRefusalError( - `Composio sent ${sent((entry as { name?: unknown }).name)} where the name of ${at} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return label; - }); - - /* - * `tools_count` IS THE WIRE'S OWN KEY AND `toolsCount` WAS THE WRAPPER'S RESTATEMENT OF IT - * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-408`; - * `@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:34`). Reading the old key off the - * new answer is the one mistake in this function that would not look like one: every count would - * be `undefined`, every count would default to zero, and every app in the picker would say it - * publishes no actions — a plausible figure on a page whose whole job is to show one. Which is - * why it is a number a test asserts rather than a field a type checks. - */ - const actionCount = meta.tools_count ?? 0; - if (typeof actionCount !== "number" || !Number.isFinite(actionCount)) { - throw new BrokerRefusalError( - `Composio sent ${sent(meta.tools_count)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, - ); - } +export function buildComposioClient( + vendor: ComposioVendor, + now: () => number = Date.now, +): { + actions: ComposioActions; + broker: ConnectedAppBroker; +} { + const context = createClientContext(vendor, now); + const actions = actionsFor(context); - return { - slug, - name, - description, - logo, - categories, - actionCount, - connection: connectionOf(row), + const broker: ComposioBroker = { + ...directoryMethods(context), + ...consentMethods(context), + ...fieldMethods(context), + ...accountMethods(context), }; -} - -/** - * 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; -}; - -/** - * That status as the WORD it is, so the test and the sentence about it are about the same value. - * - * THE TWO READINGS OF THIS ONE FIELD DISAGREED, AND THE DISAGREEMENT REACHED AN OPERATOR AS A - * SENTENCE CONTRADICTING ITSELF. Both callers tested the RAW value — `=== "ENABLED"` to pick the - * config a connection is made against, `!== "DISABLED"` to sort the rest — while {@link named} - * quotes the TRIMMED one. So a status of `" ENABLED "` was refused for not being ENABLED and then - * reported as `"ENABLED", which is neither ENABLED nor DISABLED`: a claim the reader can see is - * false, ending in a remedy — upgrade the package — that is nobody's act on the page they are - * standing on, about a config Composio calls enabled and this deployment made. - * - * IT COSTS MORE THAN THE SENTENCE. A padded `" ENABLED "` is an app nobody can connect at all, - * through either door, for as long as the vendor pads it; a padded `" DISABLED "` is a disabled - * config an operator is never told to go and enable. This is the same argument {@link textOf} makes - * for every identifier in this file and {@link labelsOf} makes for a scheme name — the value is the - * word, and the padding is whatever the wire wrapped it in — arriving at the one field that had - * been left out of it. - * - * NULL FOR ANYTHING THAT IS NOT A WORD, which keeps the third answer this type exists to preserve: - * a status that is absent, or that arrived as a number or an object, is neither ENABLED nor - * DISABLED and is sorted by both callers exactly as it was before. - */ -function statusOf(config: CheckedAuthConfig): string | null { - return textOf(config.status); -} - -/** - * One app's auth configs split into the ones a decision can be made about and the ones it cannot. - * - * EVERY ROW IS CHECKED AND NOT ONLY THE ONES THAT TURN OUT TO BE OURS, because which ones are ours - * is precisely what the name decides. A row whose name cannot be read cannot be sorted into "made - * here" or "somebody's dashboard work", and both of the guesses are damaging in opposite - * directions: read as somebody else's, {@link ComposioBroker.ensureAuthConfig} creates a second - * config beside it and splits one app's connections in two; read as ours, - * {@link ComposioBroker.deleteAuthConfig} deletes an object nobody here chose and every account - * anybody had connected against it. - * - * PARTITIONED RATHER THAN THROWN, WHICH IS THE SAME CORRECTION {@link withdrawableAccounts} - * ALREADY CARRIES ONE LEVEL DOWN, ARRIVING HERE A WAVE LATE. This checked every row on the way out - * of the listing and threw on the first one it could not read, so a single unreadable config row - * was a permanent block on everything behind it: a person pressing disconnect got the same throw - * every time, for ever, because the row will be exactly as unreadable on the next attempt and - * nothing they can reach changes it. That is not the safe end of the trade — it is the SAME defect - * as a false success, pointing the other way, which is the finding the accounts path was corrected - * for and this one was left standing on. - * - * WHAT A CALLER IS OWED IS BOTH HALVES: every config this file CAN decide about, decided about, and - * a refusal counting the ones it cannot. Which half matters differs per caller and is therefore - * settled at each of the four rather than here — a withdrawal acts on what it can name and reports - * the rest, a creation must not put a second config beside a row that might already be ours, and a - * connect link minted against a readable config of ours is right whatever else the listing held. - * - * AND ONE ID IS ONE CONFIG, HOWEVER MANY TIMES THE LISTING NAMED IT — the other half - * {@link withdrawableAccounts} had and this did not. The paging loop guards against a repeated - * CURSOR and not a repeated ROW, and a page boundary crossed while a config is being created, or a - * proxy stitching two overlapping pages together, hands one id over twice with the cursor - * advancing normally each time. The second delete of one config then meets Composio's "there is no - * such auth config", which arrives as a refusal — so {@link ComposioBroker.deleteAuthConfig} - * counted a removal that had in fact completed as a partial one and refused to finish removing the - * app, every time, over a duplicate that is still there on the retry. It also inflates the count - * both that method and {@link ComposioBroker.authorize} put in front of an operator, and sends the - * same id twice in the withdrawal's `authConfigIds` filter. - * - * THE FIRST SIGHTING KEEPS ITS PLACE, exactly as it does for accounts; the caller sorts on the id - * afterwards, so the order two callers see is the same one either way. - */ -function readableConfigs( - rows: VendorAuthConfig[], - toolkit: string, -): { configs: CheckedAuthConfig[]; unreadable: BrokerRefusalError[] } { - /* - * ONE SLOT PER CONFIG, HOLDING WHICHEVER COPY OF IT COULD BE READ. - * - * ONE ID IS ONE CONFIG HOWEVER MANY TIMES THE LISTING NAMED IT, and the invariant is about the - * CONFIG rather than about the first row that happened to mention it. Two orderings of one - * repeated id therefore have to answer the same, and the previous shape — dedup on first - * sighting, then read the name — answered them differently: good copy first and unreadable - * repeat second was right, unreadable copy first and good copy second claimed the id for the - * unreadable one and the readable copy returned at the dedup test, so the config never reached - * `configs` at all. `deleteAuthConfig` then left standing a config the other ordering would have - * deleted. - * - * THE POSITION IS THE FIRST SIGHTING'S AND THE VERDICT IS THE BEST COPY'S, which is what a slot - * buys over a `Set`: a later readable copy REPLACES an unreadable one in place, so neither the - * order two callers see nor the count of what could not be read depends on which copy the paging - * happened to hand over first. A readable copy is never replaced — a second reading of a config - * this deployment has already read tells it nothing new. - * - * A ROW WITH NO ID HAS NO SLOT TO SHARE, because the id is the whole of what identifies a config: - * two id-less rows are two rows nothing can say are one, so each keeps its own place in the list - * and its own refusal. - */ - const settled: (CheckedAuthConfig | BrokerRefusalError)[] = []; - const slotOf = new Map(); - - rows.forEach((row, position) => { - const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; - - const id = textOf(row.id); - if (id === null) { - settled.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; - } - - /* - * THE ID IS READ FIRST AND THE SLOT IS FOUND BEFORE ANYTHING ELSE IS ASKED OF THE ROW. - * - * A repeat whose verdict is already settled readable is dropped here, which is the half that - * was already right: the listing repeating `ac_1` across a page boundary can perfectly well - * hand the repeat over as `{ id: "ac_1", name: null }` — a rename in flight in the dashboard, - * or a proxy stitching two partial reads — and counting that as a config this deployment could - * not read cost a refusal AFTER THE WORK WAS ALREADY DONE. `configs` held the one readable - * `ac_1` and `unreadable.length` was 1, so `deleteAuthConfig` and `revoke` — which both act on - * what they can name and then report the rest — deleted the config, succeeded, and THEN threw - * "…and the app has not been fully withdrawn", over a config that was already gone. - * `removeServer` therefore left the app's row standing behind a completed removal. - */ - const slot = slotOf.get(id); - if (slot !== undefined && !(settled[slot] instanceof BrokerRefusalError)) { - return; - } - - const name = textOf(row.name); - if (name === null) { - // The first copy of this id that could not be read keeps the slot; a second one adds nothing - // to what a caller can act on and would count one config twice. - if (slot === undefined) { - slotOf.set(id, settled.length); - settled.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; - } - - const config: CheckedAuthConfig = { id, name, status: row.status }; - if (slot === undefined) { - slotOf.set(id, settled.length); - settled.push(config); - return; - } - // The readable copy of a config an earlier row could not describe, in that row's place. - settled[slot] = config; - }); - - const configs: CheckedAuthConfig[] = []; - const unreadable: BrokerRefusalError[] = []; - for (const verdict of settled) { - if (verdict instanceof BrokerRefusalError) unreadable.push(verdict); - else configs.push(verdict); - } - - return { configs, unreadable }; -} - -/** - * This person's accounts split into the ones a withdrawal can name and the ones it cannot. - * - * THE ID IS THE WHOLE OF WHAT A WITHDRAWAL NAMES, which is why it is read at all. - * {@link ComposioBroker.revoke} deletes by id and then answers `true`, and `store.ts` writes that - * answer into the audit trail as this person's access having been withdrawn before deleting the one - * row in this deployment naming which app they had connected. An id-less account reaching the - * delete is a request to withdraw `undefined` — which the vendor is free to read as anything at all - * — followed by a `true`, a trail entry, and a live grant with nothing left pointing at it. - * - * PARTITIONED RATHER THAN THROWN, AND THAT IS THE CORRECTION. This reader used to refuse, and the - * caller mapped EVERY row through it before sending a single delete — so one row whose id Composio - * omitted threw ahead of the first withdrawal, and the next attempt met the same row and threw in - * the same place. A person with three grants and one unreadable row could not withdraw any of them, - * ever, while being told to try again. That replaced a false success with a permanent block, which - * is the same defect pointing the other way. - * - * WHAT A PERSON IS OWED IS BOTH HALVES: every grant this deployment CAN name withdrawn, and a - * sentence counting the ones it cannot. The second half is not a thing they can retry — the row - * will be unreadable next time too — so the refusal names the dashboard rather than the button - * they just pressed. - * - * A ROW THAT IS NOT AN OBJECT IS NOT A CASE HERE, and that is the SDK's doing rather than an - * omission. `transformConnectedAccountResponse` reads `response.auth_config.id` while building - * every row (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:60`), so a - * non-object row raises a `TypeError` inside the vendor's own code and never reaches this function - * — see the `TypeError` row in {@link vendorRefusal}, which is where that answer is now given. - * - * ONE ID IS ONE WITHDRAWAL, HOWEVER MANY TIMES THE LISTING NAMED IT. The paging loop above guards - * against a vendor repeating a CURSOR and not against it repeating a ROW, and those are different - * faults: a page boundary crossed while an account is created or deleted, or a proxy stitching two - * overlapping pages together, hands the same account id over twice with a cursor that advanced - * normally every time. The second delete of one account then meets Composio's "there is no such - * account", which arrives here as a refusal — so {@link ComposioBroker.revoke} counted a - * withdrawal that had in fact completed as a partial one, threw over it, and left the person's - * connection row standing to be pressed again. Every retry meets the same duplicate. Deduplicating - * is not tidying the listing: it is the difference between one account and two. - * - * THE FIRST SIGHTING KEEPS ITS PLACE, so the order the deletes go out in is still the listing's, - * which is the order {@link buildComposioClient}'s sort makes stable. - */ -function withdrawableAccounts( - rows: { id?: unknown }[], - toolkit: string, -): { ids: string[]; nameless: BrokerRefusalError[] } { - const ids: string[] = []; - const alreadyNamed = new Set(); - const nameless: BrokerRefusalError[] = []; - - rows.forEach((row, position) => { - const id = textOf(row.id); - if (id === null) { - nameless.push( - new BrokerRefusalError( - `Composio sent ${sent(row.id)} where the id of row ${position + 1} of its ${toolkit} accounts for this person belongs, and the id is the whole of what a withdrawal names. Nothing was sent for that account, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, - ), - ); - return; - } - if (alreadyNamed.has(id)) return; - alreadyNamed.add(id); - ids.push(id); - }); - - return { ids, nameless }; -} - -/** - * Composio's own verdict on one withdrawal, as a refusal wherever it is not a yes. - * - * THE VENDOR TELLS US WHEN THE DELETE DID NOT HAPPEN AND NOTHING WAS LOOKING. `success` is a - * required field of `ConnectedAccountDeleteResponse` — see the declaration on - * {@link ComposioVendor}'s `connectedAccounts.delete` — and the answer used to be thrown away - * unread. A 200 whose body says `success: false` therefore became a withdrawn account in - * {@link ComposioBroker.revoke}'s count, a `true` out of that method, and a - * `vendorRevocationRequested: true` in the audit trail, over a grant Composio had just said it had - * not touched. That is the same class of lie as the unflagged delete before it and the one-page - * listing before that, arriving through the one door left unwatched: the reply. - * - * WHICH DOES NOT BLUR "ASKED" INTO "DONE", and the distinction is worth being exact about because - * the whole audit field rests on it. `success: true` still claims no more than it ever did — the - * account is gone at the broker and the revocation job was started — and whether Google honoured it - * happens afterwards, out of sight, with no supported way to poll. What `success: false` adds is - * the other end: Composio did not delete the account, so there is no job and nothing was asked of - * the provider at all. Reading it narrows the set of things `true` can be covering up rather than - * widening what `true` means. - * - * TWO SENTENCES, BECAUSE THEY ARE TWO DIFFERENT FACTS ABOUT TWO DIFFERENT THINGS. "Composio said - * no" is a fact about this account: the account is still there, a second press reaches it, and the - * caller's own count already says to press again. "Composio answered with something where its - * verdict belongs" is a fact about the package — nobody holding an admin page can correct the shape - * of a reply, and pressing disconnect again would be answered identically — so it carries - * {@link VENDOR_SHAPE_REMEDY} instead. Collapsing the two would send an operator to press a button - * for a condition no button changes. - * - * NULLABLE IN THE PARAMETER THOUGH THE DECLARATION SAYS OTHERWISE. The generated client parses the - * body and returns it, and there are two answers for which it hands over no body at all. - * - * AND NO BODY AT ALL IS NOT A REFUSAL, WHICH IS THE CORRECTION AND THE OPPOSITE MISTAKE TO THE ONE - * ABOVE. `defaultParseResponse` resolves a 204 to `null` — "fetch refuses to read the body when the - * status code is 204" — and a JSON reply carrying `content-length: 0` to `undefined` - * (`@composio/client` 0.1.0-alpha.76, `src/internal/parse.ts:16-42`). Neither of those ever reaches - * a non-2xx: the client throws `APIError` for every `!response.ok` before parsing - * (`src/client.ts:539`), so an answer arriving here at all is Composio having accepted the request. - * A 204 is therefore the vendor saying it did the delete and has nothing to add, and the guard - * added for `success: false` read it as the one shape it could not tell apart from a failure — - * turning a withdrawal that HAPPENED into a partial-withdrawal refusal, over a grant that was in - * fact ended. That is the same lie as the one this function exists to stop, pointing the other way. - * - * WHICH IS NOT THE SAME AS A BODY THAT ARRIVED WITHOUT THE FIELD. `{}` is Composio answering with a - * document whose verdict is missing, and a document this deployment cannot read is a fact about the - * package rather than an outcome — so it stays in the unreadable branch below. What is exempted - * here is the narrower thing the client documents: no document. - * - * AND A BODY THAT IS NOT A DOCUMENT IS A THIRD THING AGAIN, WHICH THE LAST BRANCH USED TO SPEAK FOR. - * The same parse that hands over `{}` hands over whatever else the body held, so a reply that is a - * bare string or a list reaches this read too — and `("deleted").success` is `undefined`, not a - * throw. The refusal then said "Composio sent nothing where its verdict belongs, and that field is - * the only thing in the reply that says whether the account was deleted": a claim about a reply with - * one field missing, made about an answer that was not a reply. The shape is the whole finding, and - * it was the one part the sentence did not name. The parameter widens to `unknown` for the same - * reason — a declaration naming `{ success?: unknown }` over a wire value is the assertion this file - * refuses to make everywhere else. - */ -function withdrawalDeclined( - answer: unknown, - toolkit: string, -): BrokerRefusalError | null { - if (answer === null || answer === undefined) return null; - if (typeof answer !== "object" || Array.isArray(answer)) { - return new BrokerRefusalError( - `Composio sent ${sent(answer)} where its reply to the withdrawal of one of this person's ${toolkit} accounts belongs, so there is no field in it saying 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}`, - ); - } - const verdict = (answer as { success?: unknown }).success; - if (verdict === true) return null; - if (verdict === false) { - return new BrokerRefusalError( - `Composio answered the withdrawal of one of this person's ${toolkit} accounts with success: false, so it did not delete the account and started no revocation of the grant behind it. Nothing was asked of the provider for that account, whatever this deployment would otherwise have recorded. Disconnecting again asks Composio for it a second time.`, - ); - } - return new BrokerRefusalError( - `Composio sent ${sent(verdict)} where its verdict on the withdrawal of one of this person's ${toolkit} accounts belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing rather than counted as ended. ${VENDOR_SHAPE_REMEDY}`, - ); -} - -/** - * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. - * - * THE VENDOR USED TO VALIDATE THIS ROW AND NO LONGER DOES, WHICH IS WHY THE GUARDS ARE ALL HERE. - * `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the - * warn-only `transform()` every other listing goes through (`@composio/core` 0.18.1, - * `src/models/Tools.ts:193`) — and the listing used to run through it (`:561`). It does not any - * more: `getRawComposioTools` is the one method of the vendor's tool model that cannot be paged, - * so the listing reads `client.tools.list` and {@link VendorToolRow} is the wire's own shape. Five - * refusals once stood here for five shapes that parse caught — a description that is a number, an - * input schema that is a string of JSON, tags that are not all labels, a version that is a number, - * and a row that is not an object at all — and each was deleted as unreachable. Four of the five - * had already come back, on the argument below. The fifth, the container, comes back with this - * change, because there is nothing at all between Composio and this function now. - * - * THE ARGUMENT THAT BROUGHT THE OTHER FOUR BACK IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD - * IN {@link appOf}, CORRECTED THE SAME WAY. "The SDK validates this" is a fact about one method of - * one version, and it is not a fact about {@link ComposioVendor}, which is the seam this function - * actually sits on and the shape a test satisfies with a literal. Running it settles what the - * difference costs: a `description` of 42 and an input schema of "not-a-schema" both travel through - * here untouched into {@link ComposioAction}, whose declared types say they cannot. What they reach - * is not a refusal. `storableTools` writes - * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")` - * (`./store`), so a description or a version that is not a string is a bare - * "42.replaceAll is not a function" thrown from outside every vendor `try` in this file — a crash - * wearing a refusal's clothes, which is the one outcome none of this file's sentences may become. - * An `inputParameters` that is not an object is quieter and worse: it is stored as the app's input - * schema and then shown to a model as Composio's own. - * - * SO THE THREE WIRE VALUES THIS FILE HANDS ON ARE CHECKED HERE, BESIDE THE SLUG, and `tags` is not - * — `./composio` already refuses a `tags` that is not a list where it reads them, and a check on - * both sides of one seam is a check nobody maintains. - * - * WHAT THE SCHEMA DOES NOT SETTLE IS THE FOURTH. `slug: z.string()` is satisfied by the - * empty string, and an action's slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL - * and half that table's primary key, it is what a grant points at, and it is what a later call - * sends back to Composio. An empty one is a row that cannot be written and a call that names - * nothing, so it is refused here. - * - * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not - * answered to a route. `./composio` records them in an app's `lastError` for an administrator to - * read on its Plugins page, and `listingSentence` passes an authored message through untouched. - */ -function actionOf( - row: VendorToolRow, - position: number, - toolkit: string, -): ComposioAction { - const at = `row ${position + 1} of Composio's action list for ${toolkit}`; - - /* - * THE CONTAINER, FOR THE REASON {@link appOf}'S ROW GUARD IS BACK ONE FUNCTION UP. `ToolSchema` - * refused a row that is not an object and nothing does now. Read without it, `("GMAIL").slug` is - * `undefined` rather than a throw, so a listing of bare strings would be refused with "Composio - * sent nothing where the slug of row 1 ... belongs" — an administrator sent to look for an action - * with a missing name, about an answer that had no action in it. - */ - if (typeof row !== "object" || row === null || Array.isArray(row)) { - throw new Error( - `Composio sent ${sent(row)} where ${at} belongs, and an action is an object carrying the name a call to it uses, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const slug = textOf(row.slug); - if (slug === null) { - throw new Error( - `Composio sent ${sent(row.slug)} where the slug of ${at} belongs, and the slug is what calling the action names, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * ABSENT IS AN ANSWER AND PRESENT-AND-WRONG IS NOT, which is the same split {@link appOf} makes - * over a catalogue row. Composio genuinely publishes actions with no description and no version, - * and one that publishes no parameters at all arrives with none — the SDK normalizes a `{}` to - * absent before parsing. Each of those reaches `./composio` as the absence it is and is defaulted - * where a column has a default. What is refused is the other thing: a field that is THERE and is - * not what this file has told `./composio` it is. - * - * AND `null` IS THE WIRE'S OTHER SPELLING OF ABSENT, WHICH ALL THREE OF THESE USED TO REFUSE. - * That directly contradicted the file this seam reports to: `./composio`'s version guard exempts - * `null` on purpose and writes down why — "refusing it would turn a healthy refresh into a total - * failure for every app that publishes one" — and the three reads it makes of these fields are - * `description ?? ""`, `inputParameters ?? {}` and `version?.trim()`, every one of them already - * null-safe. So the layer that had no trouble with a null was protected by a layer that refused - * it, and the refusal is not a dropped field: {@link ComposioBroker}'s listing stops whole, so - * every OTHER action on the app keeps neither its effect nor its version, and the grants pointing - * at them are stranded until a vendor this deployment does not control stops sending a null. - * - * NORMALIZED HERE RATHER THAN HANDED ON, because {@link ComposioAction} spells all three - * optional-and-typed, and a `null` travelling under that declaration is the same untrue assertion - * these guards exist to stop. `undefined` is what an absence is on this side of the seam. - * - * `tags` IS STILL NOT READ HERE AT ALL, for the reason {@link VendorToolRow} gives: `./composio` - * refuses a `tags` that is not a list of labels where it reads them — and exempts a null there on - * exactly this argument — and a check on both sides of one seam is a check nobody maintains. - */ - const description = row.description ?? undefined; - if (description !== undefined && typeof description !== "string") { - throw new Error( - `Composio sent ${sent(description)} where the description of ${at} belongs, and that value is written into this app's tools and read back as text, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * `input_parameters` IS THE WIRE'S KEY AND `inputParameters` WAS THE WRAPPER'S — see - * {@link VendorToolRow}. Reading the old one off the new answer would put no schema on any - * action, which `./composio` treats as the ordinary case of an action that publishes none: every - * tool would reach a model with an open schema and nothing would report a fault. - */ - const inputParameters = row.input_parameters ?? undefined; - if ( - inputParameters !== undefined && - (typeof inputParameters !== "object" || 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 ?? undefined; - if (version !== undefined && typeof version !== "string") { - throw new Error( - `Composio sent ${sent(version)} where the version of ${at} belongs, and the version is what a later call to this action asks Composio for, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - /* - * Mapped field by field rather than spread, so what crosses the seam is the four things - * `./composio` documents and not whatever else the vendor's tool object happens to carry. - */ - return { - slug, - description, - inputParameters: inputParameters as Record | undefined, - tags: row.tags, - version, - }; -} - -/** - * WHAT THIS DEPLOYMENT WAS DOING WHEN A VENDOR CALL REFUSED, so a translated refusal can say. - * - * A CONDITION AND AN OUTCOME ARE TWO DIFFERENT HALVES OF A SENTENCE, and only one of them is the - * vendor's. "This person already holds an account for gmail" is what Composio determined; "so their - * connection to gmail was not begun" is what this deployment did about it, and a reader needs both - * — the first to know why asking again will not help, the second to know what state they are in - * now. The vendor cannot supply the second, because it does not know which of this file's ten calls - * it was answering. - * - * WRITTEN AT THE CALL SITE AS A FINISHED CLAUSE, in the past tense, so that it reads after "so" - * without any sentence below having to conjugate it. That is also what keeps the outcomes honest: - * each one is composed beside the call it describes, where whether anything was sent is a fact - * rather than a guess. - */ -type VendorCall = { - /** What did not happen, as a clause following "so": "the app catalogue was not read". */ - outcome: string; - /** The app the call is about, or null where the question names no app at all. */ - app: string | null; -}; - -/** - * The vendor's own name for the condition it raised, or null where it raised something anonymous. - * - * READ OFF `name` RATHER THAN ASKED WITH `instanceof`, which is a deliberate choice and not a - * shortcut. Every error class in `@composio/core` 0.18.1 ends its constructor by assigning its own - * `name` (`src/errors/*.ts`), so the name is the vendor's published discriminator and is stable - * across the package boundary; `instanceof` is not, because it is identity on a constructor and - * therefore hostage to a second copy of the package anywhere in the tree. The SDK makes the same - * judgement about its own classes — `isRequestAbortError` falls back to `constructor.name` and - * `name` and says why: "dual-package-hazard cases" (`src/errors/SDKErrors.ts`). `./composio`'s - * `isSchemaMismatch` reaches for a shape rather than a class for the same reason. - * - * AND IT IS WHAT LETS THE TABLE BE A TABLE. Naming eight classes as values would mean importing - * eight symbols from the vendor into the one file whose whole argument is that the vendor's surface - * is confined — and a version that renames one would then be a compile error in a switch that is - * meant to degrade to "not a condition this file knows" rather than to fail the build. - */ -function conditionOf(error: unknown): string | null { - /* - * AND THE NAME 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 — {@link textOf}'s own defect, on - * the value that decides which of eight authored remedies a reader gets. The switch below - * compares against literals, so a name the package or a proxy padded matched none of them and - * every condition this file translates fell through to "Composio said nothing about why", which - * sends an operator to check a key that is fine. - */ - return textOf((error as { name?: unknown } | null | undefined)?.name); -} - -/** - * A VENDOR CONDITION AS A SENTENCE THIS DEPLOYMENT WROTE, or null where there is none to write. - * - * THE DEFECT THIS CLOSES IS THAT ALMOST NOTHING WAS TRANSLATED. `routes.ts` answers a thrown broker - * error by reaching for the vendor's own sentence and, finding none, telling the reader that - * Composio said nothing about why and that an administrator should check this deployment's Composio - * key. That is exactly right about a socket that hung up. It is wrong twice over about a condition - * `@composio/core` raised by name: the key is fine — the call that failed usually went out through - * a listing that had just succeeded on the same key — and several of these states are settled at - * the vendor, so the "and try again" half of the advice is an instruction to repeat something that - * will answer identically for ever. The worst of them is the first row below: a person who already - * has an account for an app was told to check an API key and retry. - * - * ONE SENTENCE PER CONDITION AND NO SENTENCE SHARED, which is the property its test asserts in both - * directions. A translation that gave two conditions one wording would be worse than leaving both - * alone: the reader would be handed a remedy that is right for somebody else's failure and would - * have no way to tell, where an untranslated failure at least says plainly that nothing is known. - * - * NULL WHERE COMPOSIO'S OWN SERVER EXPLAINED ITSELF, WHICH IS THE LIMIT ON DOING THIS AT ALL. - * `routes.ts` reads `brokerSentence` first and `vendorSentence` second, so a refusal authored here - * HIDES the vendor's message rather than joining it. Several of the SDK's classes are wrappers - * around whatever the API returned — `ComposioFailedToCreateConnectedAccountLink` keeps the - * `BadRequestError` as its `cause` (`src/models/ConnectedAccounts.ts`), and `vendorSentence` reaches - * through exactly that nesting — so translating one of those unconditionally would replace a - * specific server sentence with this deployment's general one. Where the vendor said something a - * reader can use, the error goes on untouched and the vendor gets the last word. - * - * THE ORIGINAL IS KEPT AS `cause` on every refusal, for whoever is reading a log rather than a page. - * It is never quoted into the message: the file's promise about {@link BrokerRefusalError} is that - * its sentence is safe to show anybody who could have made the request, and a vendor error object - * out of `connectedAccounts.link` carries the request that was being minted. - * - * WHAT IS DELIBERATELY NOT HERE, because the route's default answer is the correct one for it: - * `ComposioToolkitFetchError`, which `Toolkits.getToolkits` wraps around EVERY catalogue failure - * including its own validation one, and whose message is the bare "Failed to fetch toolkits" — the - * key and the status page genuinely are the remedy; and `ComposioToolExecutionError`, the same - * wrapper one call further on, whose `cause` carries the server's own words for `vendorSentence` to - * find and whose own message `./composio`'s {@link VENDOR_PLACEHOLDER} already refuses to pass on. - */ -function vendorRefusal( - error: unknown, - call: VendorCall, -): BrokerRefusalError | null { - if (vendorSentence(error) !== null) return null; - - const app = call.app ?? "the app"; - const outcome = call.outcome; - const refusal = (message: string): BrokerRefusalError => - new BrokerRefusalError(message, { cause: error }); - - switch (conditionOf(error)) { - /* - * THE ONE THAT WAS DOING THE MOST DAMAGE. `connectedAccounts.link` lists this person's active - * accounts for the config before it mints anything and refuses where it finds one - * (`@composio/core` 0.18.1, `src/models/ConnectedAccounts.ts`), which is this deployment's own - * rule met one layer down — one person holds one account per app, because the call that runs an - * action names the person and not the account. So it is a settled fact rather than a moment, - * and "check the key and try again" is advice that cannot ever come true. - */ - case "ComposioMultipleConnectedAccountsError": - return refusal( - `Composio answered that this person already holds a connected account for ${app}, so ${outcome}. That is a settled state at Composio rather than a moment that passes — asking again meets the same answer — and what clears it is disconnecting the account they already hold, on this deployment's Connected accounts page, before another is attached.`, - ); - - /* - * ACCESS RULES ARE NOT SOMETHING THIS FILE SENDS, which is the whole of why this one is worth a - * sentence. The SDK raises it when the server rejects ACL fields on an account that is not - * shared, and nothing here asks for sharing — so the reader must not go looking through this - * deployment's settings for a field it does not have. The config in Composio's dashboard is - * where the sharing is decided and where an operator can change it. - */ - case "ComposioAclOnlyForSharedError": - return refusal( - `Composio refused account-sharing rules on an account that is not a shared one, so ${outcome}. This deployment attaches every account to one person and asks for no sharing, so the rules are on the authorization config rather than on anything sent from here: an operator changing how ${app} is shared in Composio's own dashboard is what clears it.`, - ); - - /* - * REACHED ONLY WHERE THE SERVER SAID NOTHING, by the guard at the top of this function. What is - * left when it is reached is still worth far more than the route's default, because of what has - * already happened by the time this call is made: the auth configs were listed through the same - * key moments earlier and one of them was found enabled. So the two things the default sends an - * operator to check are both already proven, and the two things a person needs to know — that - * they were not sent anywhere, and that their consent is unspent — are facts about this - * particular call that no general sentence carries. - */ - case "ComposioFailedToCreateConnectedAccountLink": - return refusal( - `Composio would not mint a connect link for ${app} and said nothing about why, so ${outcome}. Nobody was sent to a consent screen and no consent was spent. This deployment's key and its authorization config for the app were both read through successfully moments earlier, so neither of those is what to check; Composio's status page is.`, - ); - - /* - * THE SDK'S OWN SCHEMA REFUSING, ON EITHER SIDE OF THE WIRE. `ValidationError` is raised by - * nearly every model here — the auth-config create, the connected-account listing and link, the - * tool listing and the execute all `safeParse` what they are handed and what comes back — and in - * both directions it means the same thing: this deployment's copy of `@composio/core` and - * Composio's API no longer agree. It is the one condition below whose remedy is a package - * rather than a page, which is why it shares its wording with the shape refusals above. - */ - case "ValidationError": - return refusal( - `This deployment's @composio/core refused the request or Composio's answer against its own schema, so ${outcome} — either before Composio was asked or after it had replied. Nothing an operator can set corrects that and the key is not what to check: upgrading this deployment's @composio/core is what fixes it.`, - ); - - /* - * NOTHING IS WRONG AT COMPOSIO, WHICH IS THE ENTIRE MESSAGE. A cancelled request is a caller's - * own abort, so sending somebody to a key or a status page is sending them to look at two things - * that are working. The one honest thing to add is the ambiguity: a call cancelled in flight may - * or may not have been acted on at the vendor, and this deployment cannot tell which. - */ - case "ComposioRequestCancelledError": - return refusal( - `The request was cancelled before Composio answered, so ${outcome} as far as this deployment can tell — and how far it had got when the cancellation landed is exactly what it cannot tell. Nothing is wrong at Composio and nothing needs setting here; asking for it again is what settles which of the two it was.`, - ); - - /* - * THE ACCOUNT IS GONE AT THE VENDOR, WHICH THIS DEPLOYMENT'S ROWS DO NOT KNOW. `tools.execute` - * maps API error code 1803 onto this class (`src/errors/ToolErrors.ts`), and what it reports is - * a person whose `composio_connections` row still stands over an account Composio no longer - * holds — a grant withdrawn at Google, or an account removed in the dashboard. Retrying reaches - * neither; connecting again is what puts an account back under the row. - */ - case "ComposioConnectedAccountNotFoundError": - return refusal( - `Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`, - ); - - /* - * THE ACTION COULD NOT BE FETCHED, WHICH IS NOT THE SAME CLAIM AS THE ONE THIS USED TO MAKE. - * - * The sentence here read "Composio no longer publishes that action", on the strength of the - * class's name. The name does not carry that: `getRawComposioToolBySlug` wraps its whole - * retrieve in a try whose catch rethrows EVERYTHING except a cancellation as this class — - * `throw new ComposioToolNotFoundError(\`Unable to retrieve tool with slug ${"${slug}"}\`, { cause: error })` - * (`@composio/core` 0.18.1, `src/models/Tools.ts:709-721`) — and `tools.execute` resolves - * through that same method (`:1163`). So a 500, a 429, a refused key, a socket that hung up and - * an action genuinely withdrawn all arrive under one name, and nothing on the error tells them - * apart. An outage was being reported to an administrator as a catalogue change, with an - * instruction to press Refresh at a vendor that was not answering. - * - * WHAT CAN HONESTLY BE SAID IS THAT IT COULD NOT BE FETCHED, AND WHICH TWO READINGS THAT HAS. - * The refresh stays in the sentence because a withdrawn action is the commonest of them and - * the refresh is the only act that settles it — but it is named as the remedy for ONE of the - * readings rather than as the remedy, and the reader is given the fact that separates them: - * whether every other action of every other app is failing too. That is a thing they can look - * at, which "no longer publishes" was not. - * - * THE VENDOR'S OWN WORDS STILL WIN WHERE THERE ARE ANY. Where the failure underneath was an API - * error carrying a server sentence, the guard at the top of this function has already returned - * null and none of this is reached — so what this row answers is the half of the class that - * explained itself least. - */ - case "ComposioToolNotFoundError": - return refusal( - `Composio would not hand that action over at the version this deployment recorded for it, so ${outcome}. This deployment's @composio/core reports an action Composio has withdrawn and a request for one that failed — a timeout, a dropped connection, a 500, a refused key — under one condition and says nothing that tells the two apart, so neither can this deployment. Refreshing ${app}'s tools on its Plugins page records what Composio publishes now, which settles it where the action is gone; where every action of every app is failing the same way, it is the request rather than the action, and Composio's status page is where that shows.`, - ); - - /* - * A RECORDED VERSION OF "latest" IS A ROW THAT NEEDS REWRITING, not a call that needs repeating. - * The SDK refuses `latest` for a tool executed one at a time (`src/models/Tools.ts`), and the - * version travelling with a call is whatever the listing wrote down for the action, so the fix - * is on the row rather than at Composio. - */ - case "ComposioToolVersionRequiredError": - return refusal( - `Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so ${outcome}. A dated version is recorded when an app's actions are listed, so refreshing ${app}'s tools on its Plugins page replaces "latest" with a version Composio will accept.`, - ); - - /* - * AN ANSWER THE VENDOR'S OWN PACKAGE COULD NOT READ, WHICH IS THE ROW THE FILE HAD BACKWARDS. - * - * This used to be classified as a bug of this deployment's — see the note now on - * {@link askForEach} — on the premise that a `TypeError` is what a program's own mistake looks - * like and never something Composio can reply. Running `@composio/core` 0.18.1 falsifies that - * outright. Its list transformers dereference the answer before returning it, so a malformed - * reply dies inside the vendor's code and arrives here as a bare `TypeError`: - * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list or an `items` that - * is a string (`src/utils/transformers/authConfigs.ts:79`), `authConfig.toolkit.logo` off a row - * that is not an object (`:41`), and the same two shapes at - * `src/utils/transformers/connectedAccounts.ts:113` and `:60` and at `src/models/Tools.ts:561`. - * Five vendor answers, five `TypeError`s, none of them this deployment's doing. - * - * IT IS TRANSLATED HERE BECAUSE HERE IS WHERE IT SURFACES. {@link askVendor} wraps the - * `await vendor.*` and nothing else, so a throw reaching this function came out of the vendor's - * code by construction rather than by inspection — which is exactly the distinction the guards - * this replaces were trying to make one layer too late, in readers the SDK never let them reach. - * - * WHAT THE READER IS TOLD IS DELIBERATELY NOT A THING TO TRY. Nobody holding an admin page can - * correct the shape of a reply, and "try again" would be advice to repeat a request that will - * be answered identically; the one act that changes anything is a package upgrade, and the two - * things the route's default sends an operator to check are both already proven fine — the - * request went out and Composio replied to it. The crash itself is carried as `cause` and never - * quoted: a sentence that reads like a stack trace is what every refusal here exists not to be. - */ - case "TypeError": { - const about = call.app === null ? "" : ` for ${call.app}`; - return refusal( - `Composio's answer${about} was a shape this deployment's @composio/core could not read, so ${outcome}. The failure was raised inside the vendor's own package as it read the reply, so the request went out and Composio answered it: neither the key nor anything on this deployment's pages is what to check. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - default: - return null; - } -} - -/** - * One vendor call, with whatever it refuses with translated on the way out. - * - * WRAPPED AROUND THE `await vendor.*` AND NOTHING ELSE, which is the discipline that makes this - * safe to apply everywhere. Everything else inside these methods is this file's own reading and - * refusing, and those already carry authored sentences; passing one back through - * {@link vendorRefusal} could only find a name it does not know, but the narrower scope is what - * makes that true by construction rather than by inspection. - * - * A CALL WITH NO ENTRY HERE IS THE FAILURE MODE THE TABLE IN THE TESTS EXISTS FOR. TypeScript has - * no checked exceptions, so nothing enumerates the calls that translate and nothing notices a new - * one that does not; the seam's own test walks every method of both projections and fails the - * method that forgot. - */ -async function askVendor( - call: VendorCall, - ask: () => Promise, -): Promise { - try { - return await ask(); - } catch (error) { - const refusal = vendorRefusal(error, call); - if (refusal !== null) throw refusal; - throw error; - } -} - -/** - * Every reason a set-wide refusal collected, in one value a `cause` can hold. - * - * ALL OF THEM, WHICH IS THE CORRECTION. The two loops below used to throw with `cause: refused[0]`, - * so a person with five accounts of which three refused left one reason attached and two discarded - * — and the sentence those throws carry is a COUNT, deliberately, because a count is the thing a - * reader can act on. The reasons were therefore the only place the detail existed at all, and two - * thirds of it was being dropped on the floor. - * - * CARRIED RATHER THAN LOGGED, and that is this file's rule rather than a preference. A vendor error - * out of `connectedAccounts.link` or an account delete carries the request it was made for, and a - * console line is a line in an aggregator; nothing in this module logs a vendor object, for the same - * reason nothing in it logs the key. A `cause` travels to whoever is already holding the failure. - * - * ONE REFUSAL STAYS ITSELF. Wrapping a single error in an `AggregateError` would make every reader - * unwrap a list to find one thing, and `store.ts` already reads `error.cause` directly. - */ -function everyRefusal(refused: unknown[]): unknown { - if (refused.length === 1) return refused[0]; - return new AggregateError( - refused, - `Composio refused ${refused.length} of the requests this call made.`, - ); -} - -/** - * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. - * - * A TEST SATISFIES IT WITH AN OBJECT LITERAL, which is the entire argument for it and the reason - * {@link buildComposioClient} takes one of these instead of an API key. The vendor's own client - * cannot be constructed without a key and answers nothing without a network, so an adapter that - * built its own client would be an adapter no test could reach — and this is the file where the - * field names, the argument order and the refusal below would go wrong unnoticed. - * - * Every member is written with METHOD syntax deliberately. Method parameters are compared - * bivariantly, so a real `Composio` — whose signatures carry optional request-options arguments - * and wider parameter types than the calls here use — satisfies this without a cast. - * - * THE TWO DELETES ARE NOT `Composio`'s, AND THAT IS THE ONE PLACE THIS SHAPE DIVERGES FROM IT. Both - * of the vendor's own wrappers hard-code the request body they send — `this.client.authConfigs - * .delete(nanoid, undefined, requestOptions)` and the same line for connected accounts - * (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311` and - * `src/models/ConnectedAccounts.ts:532-540`) — so through them the `revoke_on_delete` parameter - * cannot be passed at all, and both calls soft-delete while the grant at Google or Slack stands. So - * the shape below asks for the underlying client's signature instead, and - * {@link createComposioClient} satisfies it from `composio.getClient()`. See - * {@link ComposioBroker.revoke} for what that flag is and what its absence made this deployment - * claim. - */ -export type ComposioVendor = { - tools: { - /** - * Every action of one app, one page at a time, THROUGH THE RAW CLIENT RATHER THAN THE WRAPPER. - * - * `getRawComposioTools` was here and could not be paged. `ToolListParamsSchema` names no cursor - * field at all (`@composio/core` 0.18.1, `src/types/tool.types.ts:257-266`) and the method ends - * `tools.items.map(...)` (`src/models/Tools.ts:561`), dropping the response's `next_cursor` - * before any caller sees it — so through the wrapper a page at {@link LISTING_LIMIT} and a - * listing longer than one were the same array, and `./composio` refused rather than commit a - * fragment as the whole truth about an app. The raw client is the difference: `ToolListParams` - * carries `cursor`, "a base64 encoded string of the page and limit", and `ToolListResponse` - * carries `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:421-432` and - * `:200-204`). The request IS expressible, and {@link everyRowOf} makes it. - * - * WHAT THE WRAPPER WAS DOING THAT THIS HAS TO KEEP DOING IS TWO PARAMETERS AND ONE NO-OP. - * `toolkit_versions` is the SDK's own `toolkitVersions` config, which defaults to "latest" - * (`src/utils/config-defaults/ConfigDefaults.node.ts`) and was forwarded on every listing it - * made (`src/models/Tools.ts:548`); it decides which `version` each action comes back with, - * which is the value a later call sends back to Composio, so it is passed here as the literal - * the default is. `important` is the one this file already knew about and is now simply not - * named: the wrapper set it to "true" whenever a toolkit query carried no limit (`:505-515`), - * which NARROWS the answer to a featured subset that nothing in the answer declares. And the - * no-op is `applyDefaultSchemaModifiers`, which returns its argument untouched unless - * `dangerouslyAllowAutoUploadDownloadFiles` is on (`:242-248`); it defaults off - * (`ConfigDefaults.node.ts`) and {@link createComposioClient} does not turn it on — which is - * the premise `./composio`'s file-upload guard is written on. - * - * AND `ToolSchema.parse` IS GONE WITH IT, WHICH IS A GAIN AND A COST, BOTH WRITTEN DOWN - * ELSEWHERE. The gain is that the parse STRIPPED every schema key its `ParametersSchema` did - * not name before any caller could see it — see {@link ComposioAction.inputParameters}, where - * that loss was recorded as unavoidable and where the fix was named as this exact call. The - * cost is that nothing validates the row: {@link actionOf} carries every check now, including - * the container, and {@link pageOf} carries the envelope. - */ - list(query: { - toolkit_slug: string; - limit: number; - toolkit_versions: "latest"; - /** Where the last page left off, ABSENT on the first request rather than undefined. */ - cursor?: string; - }): Promise<{ - /** - * `unknown` BECAUSE NOTHING HAS LOOKED AT IT, which is the difference between this listing - * and the two the SDK still serves. Their transformers open `response.items.map(...)`, so a - * malformed envelope dies inside the vendor's package; a generated client parses the body and - * returns it. {@link pageOf} is where that is answered. - */ - items?: unknown; - /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ - next_cursor?: unknown; - } | null>; - getRawComposioToolBySlug( - slug: string, - options?: { version?: string }, - ): Promise; - execute( - slug: string, - body: { - arguments: Record; - userId: string; - version: string; - /** - * WHICH ACCOUNT OF THE PERSON'S TO RUN IN, absent where any of theirs will do. - * - * `ToolExecuteParams` carries it and `Tools.execute` forwards it to the client - * (`@composio/core` 0.18.1), so this is the wire's own field rather than one this - * deployment composes. Why a verification cannot go without it is written at - * {@link ComposioActions.execute}: a person and an app do not name an account, and one - * person may hold several for one app. - */ - connectedAccountId?: string; - }, - ): Promise; - }; - toolkits: { - /** - * The app catalogue, one page at a time, THROUGH THE RAW CLIENT FOR THE SAME REASON. - * - * AND THIS IS THE ONE THAT WAS BROKEN IN FRONT OF PEOPLE. `Toolkits.getToolkits` does forward a - * `cursor` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:68`) — so the wrapper could ASK - * for a second page — but `transformToolkitListResponse` returns `response.items.map(...)`, a - * bare array (`src/utils/transformers/toolkits.ts:16-43`), so there is no cursor to put in it. - * One half of a pager is not a pager, and `fetchDirectory` refused a full page because it could - * not tell a complete catalogue from a truncated one. Composio publishes more than - * {@link LISTING_LIMIT} toolkits, so that refusal fired on the FIRST call, every time, and an - * operator opening the app picker saw no apps at all. `ToolkitListResponse` carries - * `next_cursor` and `ToolkitListParams` carries `cursor` (`@composio/client` 0.1.0-alpha.76, - * `resources/toolkits.d.ts:322-326`, `:467-478`), so the whole request was expressible the - * entire time. - * - * `sort_by` IS THE WIRE'S SPELLING OF WHAT WAS `sortBy`, and it still matters even though the - * listing is no longer finite. What it buys now is order rather than coverage: the rows reach a - * picker in the order Composio thinks people want them, and the page ceiling in - * {@link PAGE_CEILING} is the one case left where being cut off is possible at all. - * - * NO SEARCH TERM, WHICH IS THE SAME DECISION FOR A DIFFERENT REASON THAN BEFORE. It used to be - * that the SDK's params named no search field and the parse stripped what it did not name, so a - * term passed here would vanish before the request. `ToolkitListParams` DOES name `search` - * (`:480-484`), so that is no longer the argument. The argument is that the catalogue is held - * for ten minutes and searched in this process by both of its callers — the picker and the - * enable route's slug check — and a per-term request would be a per-term cache. Searching over - * the held rows is this deployment's own job and stays so. - */ - list(query: { - limit: number; - sort_by: "usage"; - /** Where the last page left off, ABSENT on the first request rather than undefined. */ - cursor?: string; - }): Promise<{ - /** `unknown` for the reason the action listing's is — see {@link pageOf}. */ - items?: unknown; - /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ - next_cursor?: unknown; - } | null>; - /** - * ONE app, read for the one thing the catalogue listing does not carry: what it asks a person. - * - * The listing above answers which apps exist and which scheme each of them authenticates with; - * it does not answer which boxes a form for that scheme needs. That is published per app and it - * moves — one app wants a key, the next wants a key and a workspace subdomain — so it is asked - * for at the moment a person presses Connect rather than derived from anything held here. See - * {@link ComposioBroker.connectionFields}. - * - * NO PAGE AND NO CURSOR, because a toolkit is one object rather than a listing: this is the one - * vendor read in this file where there is no second page to be mistaken for the whole answer. - */ - retrieve(slug: string): Promise; - }; - authConfigs: { - list(query: { - toolkit: string; - limit: number; - /** - * Ask for the disabled ones too, ALWAYS, which is why this is the literal and not a boolean. - * - * The vendor's listing returns enabled configs unless asked otherwise - * (`AuthConfigListParamsSchema.showDisabled`, `@composio/core` 0.18.1, - * `src/types/authConfigs.types.ts:124-131`), and a config this listing cannot see is a config - * {@link ComposioBroker.ensureAuthConfig} creates a second of — which is the exact split that - * method exists to prevent, arriving through the one door it was not watching. Every question - * this file asks of the listing is better served by seeing a disabled config and saying so: - * creation must not duplicate it, deletion must remove it, and consent must refuse against - * it rather than mint a link that cannot work. - */ - showDisabled: true; - /** - * Where the last page left off, ABSENT on the first request rather than undefined. - * - * `AuthConfigListParamsSchema` names it and `AuthConfigs.list` forwards it - * (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:124-131`, - * `src/models/AuthConfigs.ts:95`), which is the fact that decides how a truncated answer is - * handled here: the catalogue refuses a full page because it has no way to ask for the next - * one, and this listing does. See {@link everyRowOf}. - */ - cursor?: string; - }): Promise<{ - items: VendorAuthConfig[]; - /** - * The vendor's own word for "there is another page", which was being discarded at this type. - * - * `unknown` RATHER THAN `string | null`, because the transformer writes `response.next_cursor - * ?? null` (`src/utils/transformers/authConfigs.ts:80`) and that is the wire's value with no - * check on it — a numeric cursor reaches {@link everyRowOf} exactly as Composio sent it, which - * is the one shape that could be read as the end of a listing that has not ended. - * - * `AuthConfigListResponseSchema` carries it (`@composio/core` 0.18.1, - * `src/types/authConfigs.types.ts:129-133`) and - * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer - * (`src/utils/transformers/authConfigs.ts:72-80`). Omitting it here did not make the - * truncation go away; it made it unobservable, because a field a projection does not name is - * a field no caller and no test of a caller can ask about. {@link everyRowOf} reads it, and - * follows it until the vendor stops offering one — so a listing at {@link LISTING_LIMIT} is - * no longer a fragment this file can mistake for the whole answer. - */ - nextCursor?: unknown; - }>; - /** - * Create one auth config, and hand back the id Composio gave it. - * - * `Promise` UNTIL NOW, AND THE ANSWER WAS NEVER LOOKED AT. That was read as harmless - * because nothing here needs the id — the next listing finds the config by its name. It is not: - * `transformCreateAuthConfigResponse` builds what this resolves to by dereferencing - * `response.auth_config.id` and `response.toolkit.slug` (`@composio/core` 0.18.1, - * `src/utils/transformers/authConfigs.ts:96-106`), so a drift in the answer's SHAPE raises a - * `TypeError` inside the vendor's package — after the create has gone out and been answered. - * {@link ComposioBroker.ensureAuthConfig} then reported that no config had been created, over a - * config standing at Composio that nothing in this deployment names. Reading the reply is what - * makes the difference between "nothing was created" and "something may have been" a fact - * rather than an assumption. - * - * `id?: unknown` FOR THE REASON EVERY OTHER FIELD IN THIS PROJECTION IS ONE: the `transform()` - * around it is the warn-only kind (`src/utils/transform.ts:26-36`), so - * `CreateAuthConfigResponseSchema` spelling the id a required string is the answer Composio - * MEANS to send rather than a fact about the one that arrived. - * - * NULLABLE THOUGH THE VENDOR'S OWN DECLARATION IS NOT, exactly as the account delete below is: - * the generated client parses the body and returns it, and reading a field off a `null` would - * be a crash carrying a sentence that reads like a stack trace. - */ - create( - toolkit: string, - /** - * THE UNION, BECAUSE THE MANAGED TYPE IS RIGHT FOR ONE KIND OF APP AND WAS SENT FOR ALL OF - * THEM. A self-registering app has no Composio-owned OAuth client behind it and a key app - * has no consent screen to send anybody to, so both are created as custom configs — carrying - * the scheme, and never a credential, which is per-connection rather than per-config. The - * managed shape has no field to name a scheme with, which is why this is a union of two - * shapes rather than one shape with an optional field. - */ - options: - | { type: "use_composio_managed_auth"; name: string } - | { - type: "use_custom_auth"; - authScheme: FieldScheme | "DCR_OAUTH"; - name: string; - /** - * EMPTY, AND PRESENT, WHICH IS NOT THE CONTRADICTION IT LOOKS LIKE. - * - * Neither config this deployment creates carries a secret — a self-registering app - * needs none and a key app's key belongs to each connection made against the config - * rather than to the config — but `CreateCustomAuthConfigParamsSchema` spells - * `credentials` REQUIRED (`@composio/core` 0.18.1, - * `src/types/authConfigs.types.ts:52-65`) and `AuthConfigs.create` `safeParse`s its - * options before it builds a body (`src/models/AuthConfigs.ts:132-137`). So an absent - * field is not "no credentials sent"; it is a `ValidationError` raised inside the - * vendor's package with no request made at all. The empty record is what carries - * nothing THROUGH that check, and the type says so rather than leaving the next reader - * to discover it from a failed enable. - */ - credentials: Record; - }, - ): Promise<{ id?: unknown } | null>; - /** - * Delete one auth config, and ask for the upstream credentials on it to be revoked too. - * - * THE SAME TRAP AS THE ACCOUNT DELETE BELOW, one level up. The endpoint "soft-deletes an - * authentication configuration" and revokes "the upstream credentials of every connection using - * this auth config" only when the flag is passed (`@composio/client` 0.1.0-alpha.76, - * `resources/auth-configs.d.ts:60-72` and `:651-659`), so a delete without it leaves every - * grant that was ever made against this config alive at the provider. - * - * WHICH IS WHY IT IS PASSED HERE EVEN THOUGH THE ACCOUNTS WERE ALREADY ASKED FOR INDIVIDUALLY. - * Removing an app revokes each connected person first and drops the config last, and that loop - * reaches exactly the people this deployment has a `composio_connections` row for. An account - * whose row drifted — cleared by a confirm that Composio answered `false` to, or lost with a - * database this deployment restored — is invisible to it and still live at the vendor. This is - * the one call that reaches those, and there is nothing on this config that removing the app is - * not meant to end. - * - * AND ITS REPLY CARRIES NO VERDICT, WHICH IS WHY THIS ONE IS NOT READ WHERE THE ACCOUNT DELETE - * BELOW IS. That is the obvious question to ask of a call whose answer is awaited and dropped — - * {@link ComposioBroker.revokeAccount} exists to argue that not throwing is not the same as - * having been done, and the same gap one level up would have `removeServer` deleting the app's - * row behind a config that is still standing. It was asked and the answer is in the vendor's own - * type: `AuthConfigDeleteResponse` has exactly one field, an OPTIONAL `revoke_job_id`, and no - * `success` (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:377-385`). The - * account delete's `ConnectedAccountDeleteResponse` does carry `success: boolean` (`:7447-7451`), - * which is what {@link withdrawalDeclined} reads. There is nothing here to read, so `unknown` - * is the honest declaration rather than a field this deployment would be inventing. - * - * WHAT THE REPLY DOES SAY IS THAT THE REVOCATION IS A BACKGROUND JOB. `revoke_job_id` is - * "present only when `revoke_on_delete=true`", and the vendor's own comment on it says to track - * the job and its per-connection results in the Composio dashboard because "a programmatic - * endpoint to poll this job is not yet generally available". So a clean return here means the - * config was deleted and the upstream revocations were STARTED — not that they finished. That - * limit belongs to Composio rather than to this deployment, and it is why `removeServer`'s - * promise is the one it makes: every account is asked to be withdrawn before anything here is - * deleted, which is as far as the vendor lets anybody go. - */ - delete(id: string, params: { revoke_on_delete: true }): Promise; - }; - connectedAccounts: { - list(query: { - userIds: string[]; - toolkitSlugs: string[]; - /** - * Which statuses this particular question is about — see {@link CONNECTED} and - * {@link REVOCABLE}, which are the only two answers this file gives. - */ - statuses: VendorAccountStatus[]; - /** - * Both sharing models, ALWAYS, which is why this is the literal and not the enum. - * - * OMITTING IT IS NOT "NO OPINION", in exactly the way an omitted limit is not. The parameter - * defaults to private accounts only (`ConnectedAccountListParamsSchema.accountType`, - * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:286-293`), so a person whose - * account for an app is a SHARED one reads as not connected, is told to connect an app they - * already have, and — far worse — is invisible to the revoke, which then reports that there - * was nothing to withdraw while their grant stands. Neither of the two questions this file - * asks has any reason to care how an account is shared: the gate is about whether this person - * can act through the app, and the revoke is about ending every account they can act through. - */ - accountType: "ALL"; - /** - * Which authorization configs the question is about, ABSENT where it is about all of them. - * - * THE TWO QUESTIONS DIFFER HERE TOO, AND ONLY ONE OF THEM MAY ACT. `authConfigIds` is a - * parameter of the listing (`ConnectedAccountListParamsSchema.authConfigIds`, - * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:260-264`) forwarded as - * `auth_config_ids` (`src/models/ConnectedAccounts.ts:117`), and omitting it asks about every - * config in the project — this deployment's and an operator's hand-made ones alike. That is - * the right question for {@link ComposioBroker.isConnected}, which only reads whether a person - * can act through the app, and the wrong one for {@link ComposioBroker.revoke}, which deletes - * what it finds: see there for why an account on somebody else's config is not this - * deployment's to end. - * - * NEVER THE EMPTY ARRAY. A caller with no configs of its own has nothing to scope TO, and an - * empty filter is the shape most likely to be read as no filter at all by whatever is on the - * far side — which would be the unscoped listing arriving through the parameter added to - * prevent it. `revoke` answers before it asks in that case. - */ - authConfigIds?: string[]; - limit: number; - /** - * Where the last page left off, ABSENT on the first request rather than undefined. - * - * `ConnectedAccountListParamsSchema` names it and `ConnectedAccounts.list` forwards it - * (`@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:259-266`, - * `src/models/ConnectedAccounts.ts:118`). This is the listing the paging matters most for: - * {@link ComposioBroker.revoke} answers `true` for "this person's access has ended", and one - * page of their accounts is not the set of their accounts. See {@link everyRowOf}. - */ - cursor?: string; - }): Promise<{ - /** - * The id, and `data.displayName` read by name, are the only fields read off an account. - * `data` and `state.val` also carry the account's tokens, and nothing else is read from them. - * - * `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` and `data` arrive exactly as Composio sent them inside the same warn-only - * `transform()` as everything else here. An account with no id is the one shape the revoke - * below cannot act on, and {@link withdrawableAccounts} is what says so about it. - * - * THE ROW ITSELF IS AN OBJECT BY CONSTRUCTION, which is why only the fields are in doubt. The - * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a - * row that is not an object raises inside the vendor's code and never reaches this listing. - */ - items: { id?: unknown; data?: unknown }[]; - /** - * The same truncation signal as the auth-config listing above, from the same vendor schema. - * - * `ConnectedAccountListResponseSchema` spells it `nullish` - * (`src/types/connectedAccounts.types.ts:297-303`) and the transformer sets it on every - * answer (`src/utils/transformers/connectedAccounts.ts:109-117`). It matters more here than - * anywhere else in this file: {@link ComposioBroker.revoke} answers `true` for "this - * person's access has ended", and it used to have seen only one page of the accounts it - * would have to end. {@link everyRowOf} follows it until there is none left. - */ - nextCursor?: unknown; - }>; - /** - * Mint one person's connect link against one auth config, with the page to come back to. - * - * `link` RATHER THAN `initiate`, AND RATHER THAN `toolkits.authorize`. All three end at a - * redirect url, and only the choice between them decides whether this deployment keeps working. - * `toolkits.authorize` takes a user id, a toolkit and an optional auth config id and has no - * parameter for a callback at all (`@composio/core` 0.18.1, `src/models/Toolkits.ts:333-338`), - * which is why consent used to end on Composio's hosted page — the address below has nowhere - * to travel on that call. `initiate` does carry one (`:249`), but the endpoint under it is - * retired for Composio-managed OAuth on redirectable schemes — cutover 2026-05-08 for new - * organizations and 2026-07-03 for the rest, after which it throws - * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`src/models/ConnectedAccounts.ts:146-160`) - * — and `use_composio_managed_auth` is exactly what {@link ComposioBroker.ensureAuthConfig} - * creates. `link` is the vendor's own named replacement for that combination, carries the - * callback, and answers in the same shape. - * - * TAKING THE AUTH CONFIG ID IS NOT A COST HERE. It is the one thing `toolkits.authorize` was - * doing for us, and it did it by listing the configs and creating one at Composio's managed - * defaults where it found none — which this deployment already does for itself, at enable - * time, named so an operator can find it in their dashboard. The listing below is that same - * read; the creation is not repeated, because an app with no config is a state to report - * rather than one to paper over. - */ - link( - userId: string, - authConfigId: string, - options: { callbackUrl: string }, - ): Promise<{ redirectUrl?: unknown }>; - /** - * Create one person's account from the secret they typed, WITHOUT a consent screen anywhere. - * - * THE RAW CLIENT FOR THE REASON BOTH DELETES ARE RAW, and a sharper one. `@composio/core`'s - * `connectedAccounts.initiate` is the wrapper over this endpoint, and the endpoint under it is - * retired for the managed-auth path — it throws - * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`@composio/core` 0.18.1, - * `src/models/ConnectedAccounts.ts:146-160`) — while `link`, which replaced it, mints a consent - * url and has nowhere to put a typed value at all. What this flow needs is neither: the person - * has already typed their credential into a form here, so there is no screen to send them to - * and nothing to come back from. `@composio/client`'s own create takes the state directly - * (`0.1.0-alpha.76`, `resources/connected-accounts.d.ts:33`, `:7502-7511`), which is the whole - * of what this call is. - * - * NO `validate_credentials`, AND ITS ABSENCE IS DELIBERATE RATHER THAN AN OVERSIGHT. The - * parameter exists on the same body and the vendor marks it EXPERIMENTAL (`:7505-7509`). - * Whether a typed key actually works is settled here by making a call with it rather than by - * asking Composio to grade it, because a connection Composio accepts is not a connection that - * works: a wrong value comes back `ACTIVE`. - * - * `id` AND `status` READ AS `unknown`, for the reason every other vendor field in this - * projection is. The generated client declares both as required - * (`ConnectedAccountCreateResponse`, `:121-146`), which is the schema's promise about what - * Composio means to send rather than a fact about what arrived — and the id is the one field - * this whole call exists to answer, so a missing one is a refusal here rather than an - * `undefined` handed on as the account somebody is meant to be able to undo. - */ - create(body: { - auth_config: { id: string }; - /** - * The person, and the secret they typed, in the shape the vendor's own builder assembles. - * - * `state` IS `unknown` BECAUSE WHAT GOES IN IT IS THE APP'S QUESTION RATHER THAN THIS FILE'S - * ANSWER. The generated client declares it as a fourteen-member union keyed on the scheme, - * each member's `val` carrying whatever fields that app publishes plus `[k: string]: unknown` - * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7551`, `:8083-8086`) - * — so naming a shape here would be this file asserting which boxes an app asks for, which is - * exactly the thing {@link ComposioBroker.connectionFields} exists to go and ask. - */ - connection: { user_id: string; state: unknown }; - }): Promise<{ id?: unknown; status?: unknown }>; - /** - * Delete one connected account, and ask for the grant behind it to be revoked too. - * - * WITHOUT THE FLAG THIS CALL DOES NOT REVOKE ANYTHING, and that is the vendor's own description - * of it: it "soft-deletes a connected account by marking it as deleted in the database", which - * "prevents the account from being used for API calls but preserves the record" - * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:59-72`). The refresh - * token at Google or Slack survives that untouched. Every path in this deployment that claims - * to end somebody's access — a person disconnecting, an administrator removing an app, a person - * being offboarded — runs through here, so an unflagged delete made all three of those claims - * false at once and wrote `true` into the audit trail beside them. - * - * WHAT THE FLAG BUYS IS A REQUEST AND NOT A RESULT, which is the whole reason - * {@link ComposioBroker.revoke}'s answer is named the way it is. The upstream revocation runs as - * a background job; the response carries its `revoke_job_id` and the vendor documents that no - * generally available endpoint polls it (`:7447-7459`). Nothing here can say the provider tore - * the refresh token up, and nothing here pretends to. - * - * BUT THE ANSWER DOES SAY WHETHER COMPOSIO DID ITS OWN HALF, AND THAT WAS BEING DISCARDED. - * `ConnectedAccountDeleteResponse` carries a REQUIRED `success: boolean`, "indicates whether - * the connected account was successfully deleted" (`@composio/client` 0.1.0-alpha.76, - * `resources/connected-accounts.d.ts:7445-7459`). This used to be `Promise`, read for - * nothing, on the argument that no field on the answer could support a stronger claim than "we - * asked". That argument is true of the REVOCATION and false of the DELETE: a 200 carrying - * `success: false` is Composio saying it did not delete the account, so the background job the - * flag asks for was never started either — and {@link ComposioBroker.revoke} answered `true` - * over the top of it while the grant stood at the provider. Reading it is not a stronger claim - * than "we asked"; it is the difference between having asked and having been refused. - * - * DECLARED `success?: unknown` RATHER THAN AT THE VENDOR'S OWN `boolean`, for the reason every - * other field in this projection is: the generated client parses the body and hands it over, so - * "required" is the schema's promise about what Composio means to send rather than a fact about - * what arrived. {@link withdrawalDeclined} is where the three answers are told apart. - * - * `revoke_job_id` IS DELIBERATELY NOT READ, and its absence is deliberately not a refusal. The - * same declaration marks it optional and says it is present "only when `revoke_on_delete=true`" - * — which says when it CAN appear, not that it always does — so a guard on it would turn every - * withdrawal Composio accepted into a permanent failure the first time they stopped sending it, - * which is the shape of mistake this file has already made twice in the other direction. - * - * NULLABLE AND OPTIONAL THOUGH THE VENDOR'S OWN DECLARATION IS NEITHER, because the generated - * client has two answers it resolves with no document: a 204 becomes `null` and a JSON reply - * carrying `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, - * `src/internal/parse.ts:16-42`). Declaring this at the vendor's `ConnectedAccountDeleteResponse` - * would be the same assertion-over-a-wire-value every other field here refuses to make, and it - * would hide the one case {@link withdrawalDeclined} has to tell from a refusal. - */ - delete( - id: string, - params: { revoke_on_delete: true }, - ): Promise<{ success?: unknown } | null | undefined>; - }; -}; - -/** - * The suffix every auth config this deployment creates carries, so a reader can tell whose it is. - * - * An auth config is visible in Composio's own dashboard beside any that were made by hand there, - * and the two are otherwise indistinguishable. The name is the only field this deployment gets to - * choose, so it is where the provenance goes. - */ -const CONFIG_SUFFIX = "(OpenBot)"; - -/** - * How many different unrecognised statuses one refusal names before it stops naming them. - * - * The set it bounds is as large as the app's config listing, which is paged — see - * {@link everyRowOf} — so without a bound the length of an operator's refusal is decided by how - * many authorization configs somebody made. Five is past the number of distinct words this can - * plausibly be about: `AuthConfigRetrieveResponseSchema` names two, and a vendor that has invented - * five more at once is a package upgrade rather than a sentence to read. - */ -const STATUSES_NAMED = 5; - -/** - * Whether this deployment made that auth config, which is the question every decision here turns on. - * - * THE SUFFIX IS WRITTEN FOR EXACTLY THIS AND WAS NOT BEING READ. Both callers used to take the - * first row of an unordered listing, and the two consequences are of different sizes. Removing an - * app deleted whatever came back first — which can be a config an operator built by hand, with - * their own scopes and their own tool restrictions, taking every account on it down with it. - * Beginning a connection attached a person to whatever came back first — which can be a - * configuration nobody here chose and this deployment cannot see or tighten. And because the order - * is the vendor's, the two calls can resolve DIFFERENT rows, so an app could be removed while - * people kept connecting against a config the removal left behind. - * - * MATCHED ON THE END OF THE NAME rather than on the whole of it, because the rest of the name is an - * app's title as an administrator saw it at enable time and titles are edited. The suffix is the - * part this file writes. Trailing whitespace is tolerated for the same reason it is tolerated - * anywhere a human-edited string is compared: a name that picked up a space in a dashboard is the - * same config. - * - * IT TAKES A {@link CheckedAuthConfig} AND NOT A {@link VendorAuthConfig}, which is what makes this - * one line honest. A predicate cannot refuse — it answers true or false — so reading a name the - * vendor may not have sent here could only ever have meant silently answering `false`, and `false` - * from this function means "somebody else's config": untouched by a removal, and satisfying the - * check that stops a second one being created. {@link readableConfigs} is where the absence becomes a - * sentence instead, upstream of every caller. - */ -function madeHere(config: CheckedAuthConfig): boolean { - return config.name.trimEnd().endsWith(CONFIG_SUFFIX); -} - -/** - * The statuses that answer "is this person connected", which is the narrow question of the two. - * - * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never finished - * it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; counting any of - * them as connected tells a person their app is wired up and then fails every call they make with - * it. - */ -const CONNECTED: VendorAccountStatus[] = ["ACTIVE"]; - -/** - * The statuses that answer "what is there to revoke", which is a deliberately wider question. - * - * THE TWO QUESTIONS ARE NOT THE SAME ONE, AND TREATING THEM AS ONE LEFT GRANTS STANDING. This used - * to be a single ACTIVE listing shared by both, on the argument that "connected" and "there is - * something to revoke" are the same fact. They are not. A half-finished consent can already have - * been granted at the provider with the callback never delivered; an `EXPIRED` account is an access - * token that lapsed and a refresh token that did not; an `INACTIVE` one is a live grant the vendor - * has set aside. None of them should tell a person they are connected, and every one of them is - * something whose withdrawal is the entire point of pressing disconnect. - * - * `REVOKED` IS THE ONE STATUS LEFT OUT, and left out on purpose rather than forgotten. It is the - * only value that positively says the grant is already gone, so including it would have this - * deployment delete a tombstone and then record that it ended somebody's access — the one way the - * audit field can be made to lie in the direction nobody would check. - */ -const REVOCABLE: VendorAccountStatus[] = [ - "INITIALIZING", - "INITIATED", - "ACTIVE", - "FAILED", - "EXPIRED", - "INACTIVE", -]; - -/** - * How long one catalogue answer is served to everybody who asks for it. - * - * THE CATALOGUE IS READ ONCE PER SEARCH KEYSTROKE OTHERWISE, WHICH IS WHAT THIS IS ABOUT. The admin - * picker debounces its search field and then asks `/composio/apps`, and that route filters in this - * process precisely because Composio's toolkit listing takes no search term — so every distinct term - * a person types is another request for the whole directory, a few hundred rows of it, to answer a - * question about one. Typing "linear" pulls the catalogue four or five times, and enabling the app - * afterwards pulls it once more. - * - * TEN MINUTES BECAUSE OF WHAT GOES STALE IN IT. The rows are Composio's published toolkits: an app - * is added to their catalogue or its action count moves every so often, never within one - * administrator's sitting, and the worst a stale row can do here is show a description or a count - * that is a few minutes behind. Held for a working session it would be a cache nobody could explain - * to an operator whose new app is missing; held for seconds it would not survive the debounce it - * exists for. - */ -const DIRECTORY_TTL_MS = 10 * 60 * 1000; - -/** - * The held catalogue as a copy nobody else holds, which is what makes handing it out safe. - * - * WHAT WAS HANDED OUT WAS THE CACHE ITSELF. One array of one set of row objects was returned to - * every caller for ten minutes, so a route that sorted the rows in place reordered the catalogue for - * everybody, and one that edited a row — a title trimmed for display, a description truncated — - * edited what the next caller would read as Composio's answer. Nothing does that today, which is - * precisely the problem with leaving it: the first caller that does will have changed a cache it - * had no idea it was holding, and the fault will surface in the NEXT request rather than its own. - * - * `categories` AND `connection` ARE BOTH COPIED, because a shallow spread of the row would hand - * either of them on by reference. This used to copy the array and say it was "the one field here - * that is not a primitive", which is what let the other one through: {@link BrokerConnection} is an - * object in every one of its five shapes, so the row's spread carried one object out of the cache - * and into every caller for the whole ten minutes — the exact sharing this function exists to end, - * surviving in the field it is worst in. `connection` is what the app picker hides an `unsupported` - * app by and what the enable route branches on to decide whether to create an authorization config - * at all, so a caller that edited the object it was handed would be editing what those two - * decisions read of every later caller's rows. - * - * SPREAD RATHER THAN NAMED PER KIND, so that adding a member to the union — or a field to one of - * them — cannot quietly reintroduce the sharing. The spread of a union widens to the union, which - * is the one shape here a `switch` on `kind` would have to be kept in step with by hand. - * - * 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], - connection: { ...app.connection }, - })), - ); -} - -/** - * Both seams, over one vendor client. - * - * TAKING THE VENDOR OBJECT RATHER THAN A KEY IS THE SEAM. It is what lets every test of this - * file's own decisions — which limit went out, which field became which, which call was refused - * before it was made — run against an object literal and never a socket. {@link createComposioClient} - * is the one line that turns a key into a vendor, and it is deliberately too thin to have a bug in. - * - * The vendor is captured in a closure rather than stored on either returned object, so neither - * `actions` nor `broker` offers a route back to the client or to the key it holds. - * - * @param now The clock the catalogue's lifetime is measured against, injected for the same reason - * the vendor is. A test that could not move the clock could only assert the cache's hit by counting - * calls and would have to sleep ten minutes to assert its expiry, so the window would be the one - * thing here no test could reach. It is a parameter of the builder rather than of - * {@link ComposioBroker.listApps}, because the seam's callers are routes and none of them has an - * opinion about what time it is. - */ -export function buildComposioClient( - vendor: ComposioVendor, - now: () => number = Date.now, -): { - actions: ComposioActions; - broker: ConnectedAppBroker; -} { - /** - * This person's accounts for this app, in whichever states the ASKING question is about. - * - * ONE LISTING WITH THE STATUSES AS ITS ARGUMENT, rather than one listing both callers share. - * They shared one until it turned out that the shared answer was wrong for one of them: see - * {@link CONNECTED} and {@link REVOCABLE} for why "is this person connected" and "what is there - * to revoke" are different questions. What they do share is everything a drift between them - * would come from — the breadth of `accountType`, the limit, and the fact that both ask about one - * person and one app — so the difference between them is exactly the list of statuses and is - * visible at both call sites. - * - * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the - * statuses are. `configs` is which authorization configs the answer is about — every one of them - * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on - * what it finds. `enough` is when the rows in hand already settle the question, which is true of - * a boolean the moment one row arrives and never true of a set of accounts to end. Both are - * written at the call sites below, beside the statuses, so that the whole of the difference - * between the two questions is one argument list a reader can compare. - */ - const accountsFor = async ( - userId: string, - toolkit: string, - statuses: VendorAccountStatus[], - asked: { - configs?: string[]; - enough?: (rows: { id?: unknown; data?: unknown }[]) => boolean; - } = {}, - ): Promise<{ id?: unknown; data?: unknown }[]> => { - /* - * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that - * the two questions cannot drift on the one thing they do share. A truncated listing is the - * wrong answer to both of them for the same reason: `false` claims somebody has no account for - * an app when nobody looked at all of them, and a withdrawal that saw one page ends fewer - * grants than it reports. What a caller may say is that it has ENOUGH — see {@link everyRowOf} - * — which is not truncation: it is a question that has been answered. - * - * THE ROWS GO BACK AS ROWS, WHICH IS NARROWER THAN WHAT THIS USED TO HAND OVER. It read every - * id here, and the two callers do not want the same thing: `isConnected` is a COUNT — the id is - * nothing it reads — so taking ids on its behalf turned an account Composio described without - * one into a thrown refusal against a person who is, in fact, connected. Reading the ids is the - * withdrawal's business, and it is done there, where a row that cannot be named is something to - * report alongside the grants that were ended rather than something to stop them. - */ - const rows = await everyRowOf( - { - noun: `this person's ${toolkit} accounts`, - consequence: - "neither whether they are connected nor what there is to withdraw could be read", - }, - (cursor) => - askVendor( - { - outcome: `this person's ${toolkit} accounts were not read`, - app: toolkit, - }, - () => - vendor.connectedAccounts.list({ - userIds: [userId], - toolkitSlugs: [toolkit], - /* - * COPIED, BECAUSE WHAT THE CALLERS PASS IN IS {@link CONNECTED} OR {@link REVOCABLE} - * ITSELF. Every other list in this body is built here — `[userId]`, `[toolkit]`, and - * `asked.configs`, which is a fresh `map` — and this one was the adapter's own - * module-level constant handed straight over the seam to the vendor's package. A - * recipient that sorts, de-duplicates or appends to the array it was given would not - * spoil one listing; it would rewrite the constant for the life of the process, after - * which `isConnected` — the gate `./access` asks before running somebody's action — - * and the withdrawal's own filter would both be asking a question nobody wrote down, - * in the next request rather than this one. It is the same reasoning {@link copyOf} - * applies to the catalogue rows, with no expiry to bound it. - */ - statuses: [...statuses], - accountType: "ALL", - // Spread for the reason the cursor is: an explicit `undefined` reaches the vendor's - // `parse` as a key, and "about every config" is said by not naming any. - ...(asked.configs === undefined - ? {} - : { authConfigIds: asked.configs }), - limit: LISTING_LIMIT, - ...(cursor === undefined ? {} : { cursor }), - }), - ), - asked.enough, - ); - return rows; - }; - - /** - * Every auth config Composio holds for one app, ours and anybody else's alike, in one order. - * - * THE LISTING IS SCOPED TO THE PROJECT AND NOT TO THIS DEPLOYMENT, which is the correction. An - * auth config is scoped to the project the API key belongs to — so nothing here is hidden from - * this listing, and that was read as "everything it returns is ours". It is not: an operator with - * the same project open in Composio's dashboard can create configs for the same app by hand, for - * purposes this deployment knows nothing about. {@link madeHere} is the only thing that tells the - * two apart, and every caller below is about an object one of them must not touch. - * - * THE UNCLAIMED ROWS ARE RETURNED RATHER THAN DROPPED HERE, which is the part that moved. The - * filter used to live on the way out, so "no configs at all" and "configs, none of them ours" - * reached every caller as the same empty array — and telling those two apart is the whole of what - * {@link ComposioBroker.deleteAuthConfig} was missing, and then of what - * {@link ComposioBroker.revoke} was missing a wave later, on the same distinction, one function - * away. The `ours` filter is still offered, beside rather than instead of the rest, because a - * caller that can only see its own configs is a caller that cannot notice the other two states. - * - * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure - * being fixed here is two calls resolving different rows; a total order on the id makes the - * choice this file makes a stable one, whoever asks and whenever. - * - * BY CODE UNIT RATHER THAN BY `localeCompare`, WHICH IS THE WHOLE POINT OF THE SORT RATHER THAN A - * QUIBBLE WITH IT. `localeCompare` called with no locale collates in the HOST's, and the hosts - * are not one host: "ac_B" comes before "ac_a" by code unit and after it under an English - * collation, and the two callers this order exists to keep in step — a person pressing Connect - * and an administrator pressing Remove — need not be answered by the same process, the same - * container or the same build of ICU. An order that two machines can disagree about is not an - * order two callers agree on. `<` is the same total order everywhere, which is the only property - * asked of it here. - * - * THREE PARTS RATHER THAN A LIST, AND THAT IS A GUARD AGAINST THIS FILE'S OWN HISTORY. Each of - * the four callers below has to answer three different questions about one listing — what is - * ours, what is standing that is not, and what could not be read at all — and every defect this - * function has been corrected for was one caller answering one of them while its neighbour, one - * function away, answered it differently or not at all. A shape that hands over only `ours` - * lets a caller not notice the other two; this one cannot be destructured without saying so. - */ - const configsFor = async ( - toolkit: string, - ): Promise<{ - /** Every readable row, ours and anybody else's alike, deduplicated and in one order. */ - held: CheckedAuthConfig[]; - /** The subset of `held` carrying {@link CONFIG_SUFFIX}, which is what this deployment claims. */ - ours: CheckedAuthConfig[]; - /** One refusal per row that could be sorted into neither, which is never nothing. */ - unreadable: BrokerRefusalError[]; - }> => { - /* - * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two - * callers below are wrong in the two opposite directions {@link madeHere} describes: - * `ensureAuthConfig` finds none and creates the second config it exists to prevent, and - * `deleteAuthConfig` leaves one standing, reports a clean removal, and lets `removeServer` - * delete the app's row over the top of a live grant. - */ - const rows = await everyRowOf( - { - noun: `this deployment's authorization configs for ${toolkit}`, - consequence: - "whether one exists is not something this deployment can tell", - }, - (cursor) => - askVendor( - { - outcome: `this deployment's authorization configs for ${toolkit} were not read`, - app: toolkit, - }, - () => - vendor.authConfigs.list({ - toolkit, - limit: LISTING_LIMIT, - showDisabled: true, - ...(cursor === undefined ? {} : { cursor }), - }), - ), - ); - /* - * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The - * filter's question IS the name, so a row checked only once it had been kept would be a row - * sorted by a field nobody had read — see {@link madeHere} for what each of the two guesses - * costs. Every row therefore passes {@link readableConfigs} first, including the ones that turn - * out to belong to an operator's own dashboard work. - */ - const { configs, unreadable } = readableConfigs(rows, toolkit); - const held = configs.sort((one, other) => - one.id < other.id ? -1 : one.id > other.id ? 1 : 0, - ); - return { held, ours: held.filter(madeHere), unreadable }; - }; - - /** - * Ask for every one of them and answer with what refused, rather than stopping at the first. - * - * A THROW MID-LOOP ABANDONS GRANTS THAT ARE STILL LIVE. Both callers below are deleting a set of - * things that each independently hold somebody's access, and an exception out of the second of - * five leaves three untouched and unmentioned — while the caller is told only about the one that - * failed, so nothing in the answer says the loop did not finish. Attempting all of them makes the - * failure a statement about a set: this many were asked for and this many refused. - * - * SERIALLY RATHER THAN TOGETHER, for the same reason every other call here goes out one at a - * time: the vendor rate-limits, and a person with several accounts is not a reason to open - * several connections. The order is the listing's, which is sorted. - * - * EVERY REFUSAL IS ANSWERED AND NOT ONLY THE FIRST. What the callers do with this list is throw a - * COUNT — "two of three were withdrawn and the rest refused" — because a count is what a reader - * can act on, which makes the reasons the only place the detail lives. Returning them all is what - * lets {@link everyRefusal} put all of them on the failure the caller raises; the previous version - * collected them and both callers then read `refused[0]`, so the second and third reason existed - * for the length of one expression and were then dropped. - * - * EVERY FAILURE IS A REFUSAL HERE, AND THE CLASSIFICATION THAT USED TO SIT IN THIS LOOP HAS MOVED - * ONE LAYER DOWN. There was an `isOurFault` test in the catch that re-threw a `TypeError`, a - * `ReferenceError` or a `RangeError` rather than counting it, on the premise that those are what - * a program's own mistake looks like and are never "a thing Composio can reply". The premise is - * false — see the `TypeError` row in {@link vendorRefusal} for the five vendor shapes that raise - * exactly that from inside `@composio/core`'s own transformers — so its effect was inverted: a - * vendor fault escaped the loop as a bug of ours, abandoning every account after it unasked, and - * the person was handed a crash instead of a sentence. - * - * IT MOVED RATHER THAN BEING RETUNED because this loop cannot make the distinction and - * {@link askVendor} can. Every `ask` below is one `await vendor.*` wrapped by that function, so - * whether a fault came from inside the vendor's code is a fact about the call stack there, where - * here it could only ever have been guessed at from an error class. - */ - const askForEach = async ( - items: T[], - ask: (item: T) => Promise, - ): Promise => { - const refused: unknown[] = []; - for (const item of items) { - try { - await ask(item); - } catch (error) { - refused.push(error); - } - } - return refused; - }; - - /** - * The catalogue answer this process is currently serving, and the moment it was asked for. - * - * A PROMISE RATHER THAN THE ROWS, WHICH IS THE WHOLE ANSWER TO CONCURRENCY. The entry is written - * before the request is answered, so a second caller arriving while the first is still in flight - * finds it and awaits the same request. Holding the resolved rows instead would leave the window - * this cache exists to close wide open: three people opening the picker together, or one person's - * debounce firing twice, are exactly the case where nothing is cached yet, and each of them would - * start their own catalogue fetch and then overwrite each other's answer. - * - * IT IS PER BUILT CLIENT, not per module. This adapter is built once per deployment key, so in - * this process that is one cache; in a test it is one cache per {@link buildComposioClient}, which - * is what lets each test below start from nothing without an API for emptying it. - */ - let heldDirectory: { at: number; apps: Promise } | null = null; - - /** - * The catalogue as the vendor answers it, EVERY PAGE OF IT, mapped to the rows a person picks from. - * - * IT USED TO BE ONE PAGE AND A REFUSAL, AND THAT REFUSAL IS THE BUG THIS FUNCTION WAS FIXED FOR. - * It asked for {@link LISTING_LIMIT} rows, met exactly that many, and threw — because a full page - * and a truncated one are the same array and it believed no second request could tell them apart. - * Composio publishes more than {@link LISTING_LIMIT} toolkits, so the condition was true on every - * call and the app picker showed an operator nothing at all, with a sentence explaining why a - * partial directory would be worse. The reasoning was right and the premise was false: the raw - * client has a cursor for this listing and always did. See {@link ComposioVendor} and - * {@link everyRowOf}. - * - * SORTED BY USAGE, AND NO SEARCH TERM — see {@link ComposioVendor}, where both are argued now - * that neither is holding a finite page together. - * - * THROWN FROM INSIDE THE FETCH, WHICH IS WHAT KEEPS A FAILURE OUT OF THE CACHE. Every refusal - * below — a cursor that cannot be followed, a page that is not a page, a row that cannot be read - * — rejects the promise `listApps` holds, and `listApps` drops an entry whose request rejected. - * A fragment committed here would be a fragment served for ten minutes to BOTH callers: an - * administrator searching for an app past the cut is told nothing matched, and the enable route, - * which checks a slug against this same directory, tells them a real app is not one Composio - * lists. - */ - const fetchDirectory = async (): Promise => { - const listing: Listing = { - noun: "Composio's app catalogue", - consequence: "the directory was not shown", - }; - const toolkits = await everyRowOf(listing, (cursor) => - pageOf(listing, () => - askVendor( - { outcome: "the app catalogue was not read", app: null }, - () => - vendor.toolkits.list({ - limit: LISTING_LIMIT, - sort_by: "usage", - // Spread rather than an explicit undefined, for the reason `everyRowOf` gives: a - // `cursor: undefined` is a key on the wire, and "the first page" is said by omission. - ...(cursor === undefined ? {} : { cursor }), - }), - ), - ), - ); - - /* - * Each absence becomes the value that reads honestly on an administrator's screen, and each - * PRESENT field that is not what it is declared to be becomes a refusal — see {@link appOf}, - * where both halves of that and every sentence live. An empty description shows as no - * description; a null logo is the field's documented way of saying the vendor published none, - * which renders as a gap rather than as a broken image. - * - * The categories are the DISPLAY names rather than the slugs, because this list is read by a - * person choosing an app and "Productivity" is what they are choosing by. - * - * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a - * number and the shape offers no way to say "not published", so a toolkit that publishes no - * count reads as an app with no actions. It is the conservative direction — it understates - * the size of a change rather than overstating it — and Composio publishes a count for every - * toolkit measured, so this is a guard against the vendor rather than a routine case. A count - * that arrives as something other than a number is the different case and is refused. - */ - return toolkits.map(appOf); - }; - - const actions: ComposioActions = { - async listActions(toolkit, page): Promise { - /* - * A PAGE OF NOTHING IS NOT A PAGE, AND THE REASON IT IS REFUSED HAS MOVED. - * - * It used to be a fact about the wrapper: `getRawComposioTools` composed its request with - * `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a - * schema spelling the field `z.number().optional()` with no floor - * (`src/types/tool.types.ts:257`), so a zero was not sent short — it was not sent at all, and - * Composio's own page of twenty came back looking exactly like everything a small app - * publishes. The raw client passes a zero through, so what a zero means now is Composio's to - * say rather than a silent substitution. - * - * IT IS STILL REFUSED, AND THE ARGUMENT IS THE ONE THAT DID NOT DEPEND ON THE WRAPPER. The - * page is required on this seam precisely so that no layer supplies one quietly; a caller - * asking for no rows is a caller with a fault, and a fault is a thing to report rather than a - * thing to correct on their behalf. What changed with paging is that the limit is now a PAGE - * SIZE rather than the whole listing — {@link everyRowOf} reads on until the cursor stops — - * so a small one costs requests rather than actions. A zero would cost every request there - * is, or none. - */ - if (!Number.isInteger(page.limit) || page.limit < 1) { - throw new Error( - `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. The page a listing asks for is required on this seam so that no layer supplies one quietly, and a request for no rows is a fault to report rather than one to correct on a caller's behalf.`, - ); - } - - /* - * EVERY PAGE, WHICH IS WHAT THIS SEAM COULD NOT DO UNTIL THE LISTING LEFT THE WRAPPER. - * `./composio` used to meet a listing at {@link LISTING_LIMIT} and refuse it, for the reason - * the catalogue above used to: an app with exactly that many actions and one with more of - * them answer identically, and committing the second deletes every action past the cut from - * `mcp_tools` under a refresh that reported success. That refusal is gone with this, and the - * guard it cannot be confused with — `store.ts`'s empty-listing guard — stays where it is. - */ - const listing: Listing = { - noun: `${toolkit}'s actions`, - consequence: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, - }; - const tools = await everyRowOf(listing, (cursor) => - pageOf(listing, () => - askVendor( - { - outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, - app: toolkit, - }, - () => - vendor.tools.list({ - toolkit_slug: toolkit, - limit: page.limit, - // The SDK's own default, forwarded on every listing it made, and the thing that - // decides which `version` each action carries. See {@link ComposioVendor}. - toolkit_versions: "latest", - ...(cursor === undefined ? {} : { cursor }), - }), - ), - ), - ); - - return tools.map((row, position) => actionOf(row, position, toolkit)); - }, - - async execute(call, args): Promise { - /* - * THE TOOL IS RESOLVED BEFORE IT IS RUN, AND THAT COSTS A ROUND TRIP ON PURPOSE. - * - * Composio's execute takes the slug alone — its REST parameters have no toolkit field — so - * the pair the caller was gated on cannot travel on the wire, and the obligation - * {@link ComposioActions.execute} writes down has to be discharged here instead. The - * resolved tool carries the app the vendor will actually run it against, so asking for it - * first is what makes the check possible at all. - * - * `tools.execute` resolves the same tool again internally, so this is a second request - * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is - * refused before anything runs, rather than discovered in an audit row afterwards. - * - * AND IT IS TAKEN AT ITS DECLARED TYPE, WHICH IS THE ONE ANSWER IN THIS FILE MOST SAFE TO DO - * THAT WITH. `getRawComposioToolBySlug` ends in `this.transformToolCases(tool)` (`@composio/core` - * 0.18.1, `src/models/Tools.ts:719`), whose last act is `ToolSchema.parse(...)` — a throwing - * parse — so what resolves here is an object satisfying that schema or a `ZodError` that - * `./composio` recognises and answers with a package remedy. An answer that is not an object, - * and a `toolkit` that is present and not an object, both die at that parse; two refusals - * stood here for exactly those and neither could be reached. - */ - const resolved = await askVendor( - { - outcome: `${call.slug} was not resolved and nothing was run`, - app: call.toolkit, - }, - () => - vendor.tools.getRawComposioToolBySlug(call.slug, { - version: call.version, - }), - ); - - /* - * AN UNREADABLE APP IS NOT THE SAME FACT AS NO APP, AND THEIR REMEDIES DIFFER. The mismatch - * refusal below ends by telling an administrator to refresh this app's tools, which is right - * for a slug recorded against a url that has since changed and useless for an SDK that has - * begun answering a different shape. So a toolkit whose slug is not a usable name is refused - * as what it is rather than folded into "no app at all", where it would arrive wearing a - * remedy that cannot work. - * - * AND IT IS STILL READ, DESPITE `ToolkitSchema` SPELLING THE SLUG REQUIRED, for the reason - * {@link actionOf} reads the action's own: `z.string()` is satisfied by the empty string, so - * a passing parse still admits an app with no name — which would compare unequal to every - * toolkit and refuse this call as a mismatch with nothing on the other side of the sentence. - */ - /* - * `?? undefined` BECAUSE ABSENT ALREADY HAS A SENTENCE HERE AND `null` IS THE OTHER SPELLING - * OF IT. The mismatch refusal below already says "no app at all" for an action the vendor - * attributes to nothing, so absence is an answer on this path rather than a fault. Exempting - * only `undefined` read a `null` as an app that was PRESENT and then took `.slug` off it — - * `null is not an object (evaluating 'answeredApp.slug')`, thrown from outside every vendor - * `try` in this file, which `./composio` puts into a model's context and an audit row as this - * deployment's account of what happened. That is the same `undefined`-only exemption the - * three field guards in {@link actionOf} carried, failing the loud way instead of the total - * one. - */ - const answeredApp = resolved.toolkit ?? undefined; - let ran: string | undefined; - if (answeredApp !== undefined) { - /* - * `answeredSlug` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE. This - * binding was called `named` and shadowed the module helper of that name for the rest of - * the block — so {@link named} was unreachable here, and an edit reaching for it would have - * been calling a string. Nothing was wrong today; the next change to this block is what the - * rename is for. - */ - const answeredSlug = textOf(answeredApp.slug); - if (answeredSlug === null) { - throw new Error( - `Composio sent ${sent(answeredApp.slug)} where the slug of the app ${call.slug} belongs to should be, so nothing was run: a name this deployment cannot read is not one it can compare with ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, - ); - } - ran = answeredSlug; - } - - if (ran !== call.toolkit) { - /* - * REFUSED RATHER THAN FORWARDED, and both apps are named. - * - * The gate in `./access` cleared this run against the app the connection's url names - * NOW; the slug was recorded by a listing made at some earlier time. Where the two - * disagree — a url edited between a refresh and a call — forwarding runs one person's - * Gmail action under a gate that only ever examined their Slack connection. A reader - * holding only one of the two names cannot tell which of the two is the wrong one, so - * both go in the sentence. An app the vendor did not name at all is the same refusal: - * this deployment cannot show that the call is about the app it was gated on. - */ - throw new Error( - `${call.slug} was not sent to Composio: this connection is for ${call.toolkit}, and Composio resolves that action to ${ - ran ?? "no app at all" - }. Refreshing this app's tools on its Plugins page recovers it where the action was recorded against a url that has since changed.`, - ); - } - - return askVendor( - { outcome: `${call.slug} was not run`, app: call.toolkit }, - () => - vendor.tools.execute(call.slug, { - arguments: args, - userId: call.userId, - version: call.version, - /* - * SPREAD RATHER THAN PASSED AS `undefined`, which is the same care every other optional - * on this wire gets here — see the cursor on the listings above. What the vendor is - * handed for "any account of theirs" is a body with no such key, not a key holding - * nothing, so a client that distinguishes the two cannot read an absent pin as a - * request for an account called undefined. - */ - ...(call.connectedAccountId === undefined - ? {} - : { connectedAccountId: call.connectedAccountId }), - }), - ); - }, - }; - - const broker: ComposioBroker = { - /** - * The catalogue, from memory where this process asked for it less than ten minutes ago. - * - * BOTH CALLERS READ THE SAME HELD ANSWER, AND THE SECOND OF THEM IS THE INTERESTING ONE. The - * search route filters the directory in this process, so caching it is what stops a debounced - * search field from pulling a few hundred rows once per term. The enable route then reads the - * directory again to check that the slug it was handed is one Composio lists, and that read - * comes out of the same cache — which is the right answer rather than a concession, because - * the slug being checked is one this deployment handed the browser out of THIS cache moments - * earlier. The check exists to refuse a slug the catalogue never published — a request composed - * by hand, or a row left over from a url somebody edited — and a ten-minute-old catalogue - * settles that question exactly as well as a fresh one. The case it gives up is an app Composio - * withdrew within the window, whose cost is one `mcp_servers` row for an app that answers - * nothing, removable on the page that added it; the case it buys is that pressing Add does not - * re-read a catalogue the picker just read. - * - * A FAILURE IS NEVER HELD. The entry is dropped when its request rejects, so a vendor that - * refused once is asked again by the next caller rather than refusing from memory for ten - * minutes — the failures here are an unset or wrong API key and Composio being down, and the - * first two are fixed by an operator who then presses the button again, which must be allowed - * to work. The callers already sharing that one in-flight request do share its failure, which - * is the truth about their request: they asked while it was being answered. - * - * THERE IS NO INVALIDATION, AND THE DESIGN ASKED FOR ONE. It wanted the directory "refreshable - * by an explicit reload", and that is not built: the lifetime above is the whole of the - * freshness story. Nothing in this deployment reloads a catalogue today — no page, route or job - * has such a control — so the method would have no caller, and an invalidation API with no - * caller is an untested path that reads like a guarantee. The moment a reload button exists, - * this is where it attaches. - */ - async listApps(): Promise { - const held = heldDirectory; - if (held && now() - held.at < DIRECTORY_TTL_MS) return copyOf(held.apps); - - /* - * Stamped when the request goes out rather than when it comes back, so a slow catalogue is - * held for slightly less than the full window rather than for the window plus its own - * latency. Written into the slot before it is awaited, which is what a concurrent caller - * finds. - */ - const entry = { at: now(), apps: fetchDirectory() }; - heldDirectory = entry; - /* - * The drop on failure, registered here rather than written as a try/catch around an await so - * that this method hands every caller the one shared promise. `heldDirectory === entry` - * because a later request may already have replaced this one, and clearing that would throw - * away a good answer over an old failure. - */ - entry.apps.catch(() => { - if (heldDirectory === entry) heldDirectory = null; - }); - return copyOf(entry.apps); - }, - - async ensureAuthConfig({ - toolkit, - name, - connection, - }): Promise { - /* - * NOTHING AT ALL FOR AN APP THAT NEEDS NO AUTHENTICATION, AND THAT IS THE VENDOR'S RULE - * RATHER THAN A SHORTCUT. Composio refuses an auth config for such a toolkit — "Cannot - * create an auth config for toolkit hackernews because it does not require authentication. - * You can use its tools directly without creating a connected account." — so the listing - * below is not even worth making: there is nothing to find and nothing to create. - */ - if (connection.kind === "no-auth") return "not-needed"; - - /* - * AND A REFUSAL BEFORE ANY WRITE for the one kind this deployment cannot drive. The sentence - * is the derivation's own, which named the scheme and what it wants; a refusal here that - * invented a second sentence would drift from the one the picker filters on. - */ - if (connection.kind === "unsupported") { - throw new BrokerRefusalError( - `${toolkit} was not enabled: ${connection.reason}`, - ); - } - - /* - * IDEMPOTENT BY LOOKING FIRST, because a second config is not a duplicate — it is a split. - * A person's existing connection is created against one particular auth config, so creating - * another and connecting the next person to that leaves two populations of connections for - * one app, and removing "the" config later drops half of them. - * - * THE LOOK IS FOR ONE THIS DEPLOYMENT MADE, WHICH IS NARROWER THAN "ANY". It used to be any, - * and the two ways that was wrong pull in opposite directions. A disabled config of ours was - * invisible to the listing, so this created the very second config it exists to prevent — and - * then `authorize` refused, because it looked with the same blind listing and found the app - * had no config at all. A config an operator made by hand, meanwhile, satisfied the check and - * this created nothing, leaving every later decision here pointed at an object nobody here - * chose. Asking for our own answers both: the disabled one counts, and somebody else's does - * not. - * - * WHICH MEANS AN APP CAN END UP WITH TWO CONFIGS, ONE OF THEM SOMEBODY ELSE'S, and that is - * the intended outcome rather than a tolerated one. Adopting a hand-made config would have - * this deployment mint people's connections against scopes and tool restrictions it cannot - * see, and delete it when the app is removed. A config of our own, named, is the thing every - * decision in this file can actually reason about. - * - * This is a read followed by a write and therefore not atomic: two administrators pressing - * enable at the same instant can both find nothing and both create. Composio offers no - * create-if-absent, so the window is the vendor's rather than this deployment's, and the - * cost of losing that race is a spare config rather than a lost connection — spare rather - * than orphaned, because both carry the suffix and `deleteAuthConfig` takes every one of - * ours. - */ - const { ours, unreadable } = await configsFor(toolkit); - /* - * AND THE ANSWER SAYS THE CONFIG WAS REUSED RATHER THAN SAYING NOTHING, which is what stops - * the caller recording a scheme this branch did not establish. What stands here was created - * as whatever it was created as, possibly by an enable months ago against a catalogue answer - * the vendor has since changed — see {@link ComposioBroker.ensureAuthConfig}. - */ - if (ours.length > 0) return "standing"; - - /* - * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS. This is the one of the four - * callers that must NOT act on what it can name and report the rest — see - * {@link readableConfigs} for why the other three do. What it would be acting on is a - * CREATION, and a config created beside a row that is in fact ours under a name Composio sent - * unreadably is the second config this whole method exists to prevent: two populations of - * connections for one app, and a removal later that drops half of them. Finding nothing of - * ours in a listing this deployment could not read is not the same as finding nothing. - */ - if (unreadable.length > 0) { - throw new BrokerRefusalError( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of them is already its own is not something it can tell — and the app was not enabled, rather than a second config being created beside one that may already be there. ${VENDOR_SHAPE_REMEDY}`, - { cause: everyRefusal(unreadable) }, - ); - } - - /* - * THE OUTCOME NO LONGER CLAIMS NOTHING WAS CREATED, BECAUSE THIS CALL CANNOT KNOW THAT. - * - * It said "no authorization config was created for gmail", and {@link vendorRefusal} reads - * every condition out through that clause — including the `TypeError` row, which is reached - * exactly when the vendor's own transformer could not read a reply it had already received. - * So the one condition most likely to mean the config EXISTS was the one telling an - * administrator it did not, and nothing in this deployment then named the object standing at - * Composio. What is true of every condition here is the half that is said now: the app is not - * enabled, and what is at Composio is not something this call can report. - */ - const configName = `${name} ${CONFIG_SUFFIX}`; - - /* - * WHICH TYPE OF CONFIG, AND THE MANAGED ONE IS RIGHT FOR EXACTLY ONE OF THE THREE KINDS THAT - * REACH HERE. It was sent for all of them, and only one of the failures announced itself: - * Composio has no OAuth client of its own for a self-registering app, so the managed path - * answers 404 and the app is simply unconnectable — which is what made Linear's MCP app - * impossible to attach. A key app's was quiet and worse: the config was accepted, and every - * person enabled onto it was then sent to a consent screen that had nothing to ask them for. - * - * AND NEITHER CUSTOM BRANCH CARRIES A CREDENTIAL, which is the point rather than an omission. - * A self-registering app needs none by definition — the vendor registers a client of its own - * at connect time. A key app needs none HERE because the key is one person's: it belongs to - * each connection made against this config, which is per-deployment, and a key written onto - * it would be one person's secret shared by everybody the app is enabled for. - */ - const options = - connection.kind === "consent" - ? ({ type: "use_composio_managed_auth", name: configName } as const) - : ({ - type: "use_custom_auth", - authScheme: - connection.kind === "self-registering" - ? ("DCR_OAUTH" as const) - : connection.authScheme, - name: configName, - /* - * THE EMPTY RECORD IS THE VENDOR'S PRICE FOR SENDING NOTHING — see the field's own - * comment on {@link ComposioVendor}. Omitting it is a `ValidationError` raised before - * any request, which reads as a refusal to enable rather than as the secret-free - * config this branch is for. - */ - credentials: {}, - } as const); - - const created = await askVendor( - { - outcome: `the app is not enabled, and whether an authorization config for ${toolkit} now stands at Composio is not something this deployment can tell`, - app: toolkit, - }, - () => vendor.authConfigs.create(toolkit, options), - ); - - /* - * THE REPLY IS READ, WHICH IS THE HALF THAT WAS MISSING. Composio names the config it just - * made, and the answer was awaited and dropped — so an answer carrying no id at all was a - * successful enable of an app whose config this deployment could not show existed. It is not - * an id anything here needs: the next listing finds the config by its name. It is the only - * evidence in the reply that the creation this method reports actually happened, and a method - * that returns nothing has no other way to have checked. - * - * AND THE REFUSAL SAYS WHAT IS PROBABLY TRUE RATHER THAN WHAT WOULD BE TIDY. The request went - * out and Composio replied to it, so a config very likely IS standing there — saying "none - * was created" would be the same lie the outcome above stopped telling. The remedy is the - * button they just pressed, because {@link ComposioBroker.ensureAuthConfig} is idempotent - * through the name: a second enable finds the suffix and adopts what is there. - */ - if (textOf(created?.id) === null) { - throw new BrokerRefusalError( - `Composio answered the creation of an authorization config for ${toolkit} with ${sent(created?.id)} where the new config's id belongs, so this deployment cannot show that the config it just asked for exists and the app is not enabled. The request went out and Composio replied to it, so one may well be standing there: enabling ${toolkit} again finds it rather than making a second, because a config whose name ends with ${CONFIG_SUFFIX} is one this deployment claims. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - // Made here, as `connection`'s scheme, which is what lets the caller record that scheme. - return "created"; - }, - - async deleteAuthConfig(toolkit): Promise { - /* - * EVERY CONFIG OF OURS, AND NOTHING THAT IS NOT OURS. - * - * This used to delete whichever row the vendor happened to return first, on the reasoning - * that {@link ComposioBroker.ensureAuthConfig} creates at most one, so a second one must be - * somebody's dashboard work and must be left alone. The reasoning was right and the code did - * the opposite of it: with no test of the name, "the first row" is as likely to BE the - * hand-made config — deleting it, and with it every account anybody had connected against it. - * Reading the name inverts that. Anything without the suffix is untouched whatever order it - * arrives in, and everything with it goes, which is also the only way the spare config from a - * lost enable race is ever cleaned up. - * - * QUIET WHERE THERE IS NOTHING OF OURS TO DELETE, because removing an app has to be able to - * happen twice. An app can be removed, re-enabled and removed again, two administrators can - * press the button together, and an app enabled before this deployment created configs at all - * has none to drop. In every one of those the end state is the one that was asked for, so a - * throw would report a failure while the caller got exactly what they wanted. - * - * AND QUIET USED TO MEAN QUIET OVER A CONFIG THAT WAS STILL STANDING, which is the failure - * being closed here. "Nothing of ours" and "nothing at all" are not the same state, and the - * listing could not tell a caller which one it was in: rename a config in Composio's - * dashboard — drop the suffix, or edit the app's title past it — and every decision in this - * file stops recognising the object it made. `ensureAuthConfig` would create a second beside - * it, `authorize` would refuse against it, and this returned normally, after which - * `removeServer` deleted the app's row. The config and every grant made against it outlive - * the removal with nothing in this deployment naming them, and an administrator is told the - * app was withdrawn. - */ - const { held, ours, unreadable } = await configsFor(toolkit); - if (ours.length === 0 && held.length > 0) { - /* - * REPORTED, NOT DELETED AND NOT SWALLOWED, AND THE THIRD OPTION IS THE ONLY HONEST ONE. - * - * Deleting anyway is the worse half of the same guess {@link madeHere} exists to stop: a - * row with a readable name that does not carry the suffix is as likely to be an operator's - * own dashboard work — their scopes, their tool restrictions, and every account anybody - * connected against it — as it is to be ours under a new name. Nothing in the row tells - * them apart, which is why nothing here chooses. - * - * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link readableConfigs} AND NOT THE SAME - * CASE. There the ambiguity is about ADDRESSING: the field that decides ownership did not - * arrive, so no row can be sorted and the removal cannot begin. Here every name arrived and - * every row is legible; what is in doubt is whether this deployment's own config is among - * them under a title somebody edited. So the refusal is narrower than that one — it fires - * only where the removal found nothing it could claim, and stays quiet where a config of - * ours was found and dropped beside somebody else's, which is the contract - * {@link ComposioBroker.deleteAuthConfig} states. - * - * IT IS A BLOCK, AND THE BLOCK IS THE POINT. The app's row survives this throw — - * `removeServer` deletes it only after this returns — so the app stays on its Plugins page - * and stays removable, which is the one thing a silent success took away. The remedy is an - * operator's and it is one act in a dashboard: rename the config back so it ends with the - * suffix and remove the app again, which finishes the withdrawal, or satisfy yourself that - * it is your own and delete it there, which is the only way anything can tell this - * deployment that the config it made is genuinely gone. - * - * A COUNT AND THE SUFFIX, because together they are the whole of what an operator has to - * look at: how many objects are standing, and the exact string that would have claimed - * them. The names are not quoted — a config's title is an app name an administrator typed - * and this file's refusals quote no vendor field it does not have to. - * - * AND IT CLAIMS NOTHING ABOUT WHAT THIS DEPLOYMENT ONCE MADE. An app enabled before this - * deployment created configs at all never had one, which the quiet case above names, so a - * sentence opening "the config this deployment made" would be a guess in the one place a - * guess is what is being refused. What is said is only what was just read. - */ - /* - * AND AN UNREADABLE ROW IS ITS OWN CLAUSE HERE FOR THE REASON IT IS ONE BELOW, which is the - * half this refusal was missing. `readableConfigs` sorts EVERY row into one of two piles, so - * "legible and carrying nobody's suffix" and "not legible at all" are two facts about one - * listing and arrive together as readily as either arrives alone. Written as a throw that - * names only the first, the sentence closed by promising that taking the unclaimed config - * out of the dashboard leaves this app with nothing standing "after which the removal goes - * through" — and with a row in the other pile that is false: the next press meets the - * refusal at the end of this method, raised over exactly those rows. An operator sent to do - * one act and told it finishes the job is the one thing a refusal must not do, and the - * clause that would have said otherwise was suppressed by a branch about different rows. - */ - const alsoUnreadable = - unreadable.length > 0 - ? ` Composio also described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so one of those may be this deployment's own under an answer it could not read: until those are read in Composio's own dashboard too, neither reading settles what is standing and the removal does not go through on the strength of the one above alone.` - : ""; - 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.${alsoUnreadable}`, - unreadable.length > 0 - ? { cause: everyRefusal(unreadable) } - : undefined, - ); - } - - /* - * AND A LISTING WHOSE ONLY ROWS ARE UNREADABLE IS ITS OWN STATE, WHICH IS THE BRANCH THE - * OTHER THREE CALLERS HAVE AND THIS ONE DID NOT. - * - * `readableConfigs` sorts every row into one of two piles, so "nothing legible at all" is a - * listing where `held` is empty and `unreadable` is not — which the branch above cannot reach, - * because it requires `held.length > 0`. What was left to answer it was the partial-withdrawal - * throw at the end of this method, reached with an empty `ours`: "Composio removed 0 of this - * deployment's 0 authorization configs for linear and the app has not been fully withdrawn." - * Both figures are counts of a set nothing read, and "fully" asserts that some of it was - * withdrawn. Nothing was. The only finding is the rows, and their remedy is a reading in - * Composio's dashboard rather than the button that was just pressed — it will be exactly as - * unreadable next time. - * - * STILL A REFUSAL, WHICH IS THE HALF THAT DOES NOT MOVE. `removeServer` deletes the app's row - * only after this returns, so the app stays on its Plugins page and stays removable; a quiet - * return here would file the app away over rows that may be this deployment's own configs, - * holding live grants, under an answer it could not read. - */ - 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 any of them is this deployment's own is not something it can tell and nothing was deleted. 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: reading those rows in Composio's own dashboard is what says whether anything this deployment made is still standing. ${VENDOR_SHAPE_REMEDY}`, - { cause: everyRefusal(unreadable) }, - ); - } - - /* - * THE REPLY IS AWAITED AND DROPPED, AND THAT IS NOT THE ASKED-VS-DONE GAP IT LOOKS LIKE. - * `AuthConfigDeleteResponse` has no `success` field to read — see the declaration on - * {@link ComposioVendor} for the citation and for what its one optional field means. A throw - * is the whole of what this call can report, which is why `askForEach` collecting throws is - * the whole of what is checked. - */ - const refused = await askForEach(ours, (config) => - askVendor( - { - outcome: `one of this deployment's authorization configs for ${toolkit} was not removed`, - app: toolkit, - }, - () => - vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), - ), - ); - if (refused.length > 0 || unreadable.length > 0) { - /* - * LOUD, because the caller is `removeServer` and the thing it is in the middle of is taking - * an app away from everybody. A config left standing is a live grant that the removal was - * supposed to end, and the app's row is deleted after this returns — so a swallowed failure - * here is the one state nothing in this deployment can find again. The count is the whole - * message: an operator who can see that one of two configs went knows that pressing remove - * again finishes the job rather than repeating it. Every refusal the loop met travels as - * `cause` — see {@link everyRefusal} — because the count is deliberately all the sentence - * says, which leaves the reasons nowhere else to live. - * - * AND AN UNREADABLE ROW LANDS HERE RATHER THAN AHEAD OF THE DELETES, which is the half that - * moved. Every config of ours is dropped first and the rows that could not be sorted are - * reported after — {@link readableConfigs} says why at length: refusing before the first - * delete meant one unreadable row made an app permanently unremovable, with the readable - * configs of ours standing the whole time. It is still a refusal, so `removeServer` does - * not delete the app's row and the app stays on its Plugins page. - * - * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES. A config Composio refused is one a second press - * reaches. A row it described with no id or no name is not — it will be exactly as - * unreadable next time — so the only instruction that helps names the dashboard rather than - * the button the operator just pressed. - */ - const removed = ours.length - refused.length; - const left: string[] = []; - if (refused.length > 0) { - left.push("Removing it again asks only for what is left."); - } - if (unreadable.length > 0) { - left.push( - `Composio described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so nothing here can tell whether one of those is this deployment's own under an answer it could not read: reading them in Composio's own dashboard is what says whether anything this deployment made is still standing.`, - ); - } - throw new BrokerRefusalError( - `Composio removed ${removed} of this deployment's ${ours.length} authorization configs for ${toolkit} and the app has not been fully withdrawn. ${left.join(" ")}`, - { cause: everyRefusal([...refused, ...unreadable]) }, - ); - } - }, - - async authorize({ - account, - toolkit, - returnUrl, - }): Promise<{ redirectUrl: string }> { - const userId = account.vendorUserId; - /* - * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. - * - * `ensureAuthConfig` creates it when an administrator enables the app, which is what makes - * this a read. Creating one here instead would mint it at the moment somebody presses - * Connect, unnamed for this deployment and invisible in the dashboard until the first person - * happened to try — and where a config already existed for an app enabled twice, a second - * one would split one app's connections across two configs, so removing "the" config later - * would drop half of them. - * - * NONE IS A STATE WITH A REMEDY, NOT A NULL TO WORK AROUND. It is the app enabled before - * this deployment created configs at all, or a config deleted by hand in Composio's - * dashboard. Neither is something a person pressing Connect can fix, so the sentence names - * the app and the administrator's step rather than leaving them at a link that would attach - * their account to a configuration nobody here chose. - * - * AND "NONE" MEANS NONE OF OURS, which is the correction. The read used to take whichever row - * the vendor returned first, so an app whose only config was one an operator built by hand - * read as ready and this minted somebody's connection against it — scopes this deployment - * cannot see, tool restrictions it cannot read, and an object it must not delete. A - * connection is a lasting attachment to whatever config it was made against, so guessing here - * is not a guess that can be corrected later. - */ - const { ours, unreadable } = await configsFor(toolkit); - if (ours.length === 0) { - /* - * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND THE REMEDIES ARE - * DIFFERENT PEOPLE'S. The sentence below sends an administrator to remove the app and add - * it again, which is right where the listing was legible and said there is none of ours — - * and wrong here, because the row that could not be sorted may BE ours, in which case - * removing the app meets {@link ComposioBroker.deleteAuthConfig}'s own refusal and adding - * it again is refused by `ensureAuthConfig` for the same reason. Nobody should be sent - * round a loop that cannot close. - * - * WHAT IS NOT DIFFERENT IS THAT NOTHING IS MINTED. A link is a lasting attachment to one - * particular config, so a person is never sent anywhere on the strength of a listing this - * deployment could not read — see {@link readableConfigs}, where a config of OURS that was - * read is enough to go on whatever else the listing held. - */ - if (unreadable.length > 0) { - throw new BrokerRefusalError( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, - { cause: everyRefusal(unreadable) }, - ); - } - throw new BrokerRefusalError( - `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, - ); - } - - /* - * A DISABLED CONFIG IS NOT A CONFIG TO CONNECT AGAINST, and it is now visible enough to say - * so. The listing asks for disabled configs — it has to, or the creation above duplicates one - * — which means this is the first read that can meet one. A link minted against it does not - * work, so sending a person to the vendor would spend their consent and end with nothing - * attached; and nothing they can do from the page they are on changes it, because enabling a - * config happens in Composio's dashboard. - * - * THE FIRST OF SEVERAL, WHICH IS A CHOICE AND NOT AN ACCIDENT. More than one enabled config - * of ours means a lost enable race, and both are equally ours and equally valid. What - * mattered about the old "first row" was that the order was the vendor's and the next caller - * could get a different one; the listing is sorted on the id, so this is the same config for - * every person and for the removal that later drops all of them. - */ - const config = ours.find((held) => statusOf(held) === "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. - */ - /* - * `unsettled` RATHER THAN `unreadable`, WHICH IS A RENAME AND ALSO THE THIRD CLAUSE BELOW. - * - * This list was called `unreadable` and SHADOWED the one `configsFor` answers — the refusals - * for rows it could sort into neither pile — for the whole of this block. The two are not the - * same set and they are not about the same rows: this one holds configs of OURS whose status - * is a word neither ENABLED nor DISABLED, and the outer one holds rows whose id or name never - * arrived at all. So the outer set was unreachable from the only place its clause could have - * been written, and the clause is missing from this refusal while both siblings that collect - * the same list carry theirs. The identical block in - * {@link ComposioBroker.connectWithFields} already names its local `unsettled` for exactly - * this reason, and is the copy this one is now spelled like. - */ - const unsettled = ours.filter((held) => statusOf(held) !== "DISABLED"); - /* - * AND THE TWO STATES ARRIVE TOGETHER, SO NEITHER ONE TAKES THE OTHER'S TURN. These were a - * chain — report the unsettled rows, otherwise report the disabled ones — and the paragraph - * above says in its own words why that cannot hold: `ours` is a SET, and more than one - * config of ours is the ordinary outcome of a lost enable race that this method is written - * around. One DISABLED config beside one carrying a word this deployment's `@composio/core` - * has never heard of satisfies both conditions, and the chain answered with the unsettled - * sentence alone — whose only remedy is a package upgrade, which is nobody's act on the page - * the reader is standing on. The act that would actually have got them connected is enabling - * the disabled config in Composio's dashboard, and it was withheld because a different row - * said something unreadable. - * - * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES, in the shape - * {@link ComposioBroker.deleteAuthConfig} and {@link ComposioBroker.revoke} both use for the - * same reason. The refusal itself does not move: nothing here mints a link against a config - * it cannot show is enabled, whichever of the two is true. - */ - const left: string[] = []; - const disabled = ours.length - unsettled.length; - if (disabled > 0) { - left.push( - `Composio calls ${disabled} of this deployment's ${ours.length} authorization configs for ${toolkit} disabled, and an administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, - ); - } - if (unsettled.length > 0) { - /* - * EVERY STATUS THAT WAS ACTUALLY READ, AND NONE OF THEM SPEAKING FOR THE REST. The - * sentence counted the whole set and quoted `unsettled[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(unsettled.map((held) => named(held.status))), - ]; - /* - * AND THE TAIL COUNTS IN THE NUMBER'S OWN WORDS. "and 1 other words" was the unpluralised - * spelling, in a sentence whose very next clause conjugates its own verb on the same - * count — so the one refusal that has to be trusted about an authorization config could - * not get its own arithmetic to read. See {@link STATUSES_NAMED} for why there is a tail - * at all. - */ - const shown = words.slice(0, STATUSES_NAMED); - const unnamed = words.length - shown.length; - const said = - unnamed > 0 - ? `${shown.join(", ")} and ${unnamed} other word${unnamed === 1 ? "" : "s"}` - : shown.join(", "); - left.push( - `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 a connection begun against one could complete is not something this deployment can tell. ${VENDOR_SHAPE_REMEDY}`, - ); - } - /* - * AND THE ROWS NOTHING COULD SORT ARE THE THIRD CLAUSE, WHICH THE SHADOW HAD TAKEN AWAY. - * - * A row whose id or name never arrived may BE a config of this deployment's that Composio - * calls ENABLED, in which case "no config here could be shown to be enabled" is true of what - * was read and the remedy above it — go and enable the disabled one — is an act against the - * wrong object, or against nothing at all. It is a third fact about a third set of rows, so - * it is a third independent clause rather than a chain, for the reason the paragraph above - * gives: two facts about two different rows cannot take turns. - */ - if (unreadable.length > 0) { - left.push( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of THOSE is a config of this deployment's that Composio calls enabled is outside what either reading settles: reading those rows in Composio's own dashboard is what says whether there is one here to connect against at all. ${VENDOR_SHAPE_REMEDY}`, - ); - } - throw new BrokerRefusalError( - `No authorization config this deployment holds at Composio for ${toolkit} could be shown to be enabled, so no link was made: consent spent against a config that turns out to be disabled attaches nothing and cannot be spent again without sending ${whose(account)} round the loop a second time. ${left.join(" ")}`, - unreadable.length > 0 - ? { cause: everyRefusal(unreadable) } - : undefined, - ); - } - - /* - * THE RETURN ADDRESS IS THE WHOLE POINT OF THIS CALL, and it is the caller's rather than - * this file's: the adapter knows Composio, and where a person belongs afterwards is a fact - * about this deployment's own pages. It is never logged and never quoted in the refusal - * below, for the reason the url itself is not. - * - * NO `allowMultiple`, WHICH LEAVES THE VENDOR ENFORCING THE RULE THIS DEPLOYMENT ALREADY - * STATES. One person holds one account per app here, because the call that runs an action - * names the person and not the account — so with two accounts attached, which mailbox a Bot - * reads would be Composio's choice and nothing here could say which one it had been. The - * route refuses a second connection before it ever reaches this method, and that refusal is - * the sentence a person reads; this is the same rule one layer further down, where the - * vendor is the only party that can still see an account this deployment's rows have lost - * track of. `toolkits.authorize` passed `allowMultiple: true` unconditionally — the SDK - * calls it a "magic function" for exactly that — which is the opposite of what this - * deployment wants. - * - * THE ANSWER IS AN OBJECT BY CONSTRUCTION AND ITS ONE FIELD IS NOT. `link` builds what it - * returns with `createConnectionRequest(client, response.connected_account_id, INITIATED, - * response.redirect_url)` inside a try that turns anything thrown into - * `ComposioFailedToCreateConnectedAccountLink` (`@composio/core` 0.18.1, - * `src/models/ConnectedAccounts.ts:420-453`), and that builder assembles a literal - * (`src/models/ConnectionRequest.ts:39-43`). So a refusal for "Composio answered something - * that is not an object" could not be reached — the vendor either hands over its own object - * or raises a class {@link vendorRefusal} already translates. What the builder copies across - * untouched is the url, which is why that is the field still read. - */ - const request = await askVendor( - { - outcome: `this person's connection to ${toolkit} was not begun`, - app: toolkit, - }, - () => - vendor.connectedAccounts.link(userId, config.id, { - callbackUrl: returnUrl, - }), - ); - - const redirectUrl = request.redirectUrl; - /* - * PRESENT AND NOT A URL IS THE SHAPE THE ABSENCE GUARD BELOW CANNOT SEE. `!redirectUrl` is - * false for an object, a number and a list alike, so each of those would be returned as the - * `redirectUrl: string` this method promises and put in a `Location` header — a page nobody - * can visit, handed to a person as the consent screen they were sent to. - */ - if ( - redirectUrl !== undefined && - redirectUrl !== null && - typeof redirectUrl !== "string" - ) { - throw new BrokerRefusalError( - `Composio sent ${sent(redirectUrl)} where the page to send ${whose(account)} to for ${toolkit} belongs, so nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, - ); - } - /* - * AND THE URL IS THE ONE THAT WAS JUDGED, WHICH IT WAS NOT — the defect {@link textOf} is - * written against, on the one value in this file that leaves the process in a `Location` - * header. - * - * `!redirectUrl` is false for a string of blank space, so " " cleared this guard and the - * shape guard above it and was answered as the `redirectUrl: string` this method promises: a - * person who pressed Connect is redirected to nothing, having been told they were being sent - * to the app's own consent screen. And a url that arrived PADDED was handed back with its - * padding, which is not an address either — the space is percent-encoded or the redirect is - * refused outright. Every identifier on this path is read through {@link textOf} for exactly - * this reason; this was the field that was not. - * - * THE SHAPE GUARD STAYS AHEAD OF IT, because the two states have different sentences. An - * object or a number where a url belongs is a vendor change with a package remedy; no url at - * all is an ordinary fact about an auth scheme, and blank is that fact spelled the other way. - */ - const page = textOf(redirectUrl); - if (page === null) { - /* - * The SDK spells `redirectUrl` nullable because not every auth scheme has one — an API-key - * toolkit is connected by typing a secret, not by visiting a page. This deployment's - * enablement flow sends a person to a url, so no url is nothing to do rather than a - * success, and the sentence says which app it was about. The url itself is never quoted - * anywhere, here or elsewhere: whoever opens it attaches an account to this person's - * connection, so it is handed to the browser that asked and then forgotten. - */ - throw new BrokerRefusalError( - `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send ${whose(account)} to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, - ); - } - return { redirectUrl: page }; - }, - - async isConnected({ account, toolkit }): Promise { - const userId = account.vendorUserId; - /* - * A COUNT, AND NOTHING IS READ OFF A ROW TO REACH IT. An ACTIVE account Composio described - * without an id is still an ACTIVE account: this person can act through the app, which is the - * whole of what this gate asks. Reading the id here used to turn that into a refusal, so a - * field this question never looks at decided its answer. - * - * AND IT STOPS AT THE FIRST ROW, WHICH IS THE SAME CORRECTION ONE LEVEL OUT. This method - * answers a boolean, and a boolean settled by page one cannot be improved by page two — but - * reading on left the answer exposed to three faults that belong to pages nobody needed: a - * cursor Composio sent as a number, a cursor it repeated, and the fiftieth page of a listing - * that will not end. Each of those threw at a person whose ACTIVE account had already been - * found and told them the vendor's shape was wrong, and `store.ts` deletes their connection - * row on a `false` — so a question that had been answered `true` was made failable by the - * machinery that made the `false` complete. Paging is still what makes the `false` honest: - * with no row yet, the next page is the only thing that can settle it, so it is read. - * - * ASKED OF EVERY CONFIG, WHICH IS WHERE THIS DIVERGES FROM `revoke` BELOW. A person whose - * only account for the app sits on a config an operator built by hand can still act through - * the app — the call that runs an action names the person and the toolkit, not the account — - * so scoping this to configs of ours would refuse somebody who is, in fact, connected. This - * method only READS; the one that acts is the one that has to be narrow. - */ - return ( - ( - await accountsFor(userId, toolkit, CONNECTED, { - enough: (rows) => rows.length > 0, - }) - ).length > 0 - ); - }, - - async revoke({ account, toolkit }): Promise { - const userId = account.vendorUserId; - /* - * THE ANSWER IS WHAT WAS ASKED FOR, not whether the call threw. `false` here means there was - * nothing to withdraw, which is what the audit trail's `vendorRevocationRequested` is for: a - * reader has to be able to tell an account this deployment acted on from one that outlives it - * somewhere else. - * - * ASKED FOR, RATHER THAN DONE, AND THE FIELD IS NAMED FOR THAT. The delete carries - * `revoke_on_delete`, which is what turns it from a record-keeping soft-delete into an actual - * withdrawal — and what it starts is a background job the vendor gives no supported way to - * poll. So the account is gone at the broker by the time this returns and nothing here can - * call with it again; whether Google has torn up the refresh token happens afterwards. `true` - * claims exactly that much. See {@link ComposioVendor} for the two declarations this rests on. - * - * EVERY ACCOUNT, not the first, and in every state that could still be a grant. One person - * can hold more than one account for one app — two mailboxes, or a stale account beside a - * fresh one, or a shared account beside their own — and each of them is access this - * deployment's calls could run under. See {@link REVOCABLE} for why the listing here is wider - * than the one behind `isConnected`. - * - * AND EVERY ACCOUNT MEANS EVERY ACCOUNT ON A CONFIG THIS DEPLOYMENT MADE, WHICH IS NARROWER - * THAN WHAT THIS USED TO DELETE. The listing was asked by person, app and "all account - * types" and by nothing else, so it returned accounts attached to authorization configs an - * operator built by hand in Composio's dashboard — for purposes this deployment knows nothing - * about, on scopes it cannot see, and, with `accountType: "ALL"`, including the SHARED ones - * that other people are acting through. Every one of those was then deleted with - * `revoke_on_delete`, which tears the grant up at Google or Slack. One person pressing - * disconnect on their own settings page ended somebody else's integration. - * - * WHICH IS THE PRINCIPLE {@link ComposioBroker.deleteAuthConfig} ALREADY STATES, ARRIVING - * ONE LEVEL DOWN. That method refuses to delete a config it cannot show is this - * deployment's, on the reasoning that an operator's dashboard work is not ours to destroy — - * and the accounts hanging off that config are the same work. {@link madeHere} is the only - * thing that tells the two apart, so it decides both. - * - * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app Composio - * holds no configs for never had a connection begun through it — {@link - * ComposioBroker.authorize} mints every link against a config of this deployment's and - * refuses where there is none — so there is nothing here that this deployment granted. - * Answering before the listing is also what keeps the empty filter off the wire; see - * `authConfigIds` on {@link ComposioVendor} for why an empty one must never be sent. - * - * BUT "NOTHING OF OURS" IS NOT THAT STATE, AND ANSWERING `false` TO IT WAS THE SEVENTH ROUTE - * TO A REVOCATION THAT DID NOT REVOKE. The reasoning above is sound about an app with no - * configs and cannot tell that app from this one: an operator renames a config in Composio's - * dashboard — drops the suffix, or edits the app's title past it — and this deployment's own - * live grants read as somebody else's work. `false` then means "there was nothing to - * withdraw", `store.ts` writes `vendorRevocationRequested: false` into the audit trail and - * deletes the `composio_connections` row, and the person's grant stands at Google with - * nothing in this deployment naming it. The trail records that no withdrawal was even asked - * for, which is the one direction nobody thinks to check. - * - * {@link ComposioBroker.deleteAuthConfig} REFUSES IN EXACTLY THIS STATE, and two halves of - * one operation cannot disagree about one condition. Its reading is the right one and this is - * the half that moves: the app's configs are legible, none of them carries the suffix, and - * nothing in the row says whether that is an operator's own work or ours under a title - * somebody edited. A refusal leaves the connection row standing, which is what keeps the - * person's grant findable, and names the same one act in a dashboard that the removal does. - * - * `false` STILL MEANS WHAT IT SAID, and now only where it is true: Composio holds nothing for - * this app, so this deployment granted nothing through it. - */ - const { held, ours, unreadable } = await configsFor(toolkit); - if (ours.length === 0 && held.length > 0) { - /* - * THE UNREADABLE ROWS TRAVEL WITH THIS SENTENCE RATHER THAN WAITING FOR A PRESS THAT NEVER - * GETS TO THEM. The refusal below is written for `ours.length === 0 && unreadable.length > - * 0`, and as a second consecutive throw it is reachable only where `held.length === 0` — - * so a listing carrying one renamed config AND one row nothing could sort reported the - * rename alone, and closed by telling this person that renaming it "lets disconnecting - * again withdraw them". It does not: the account listing is scoped to the configs this - * method can name, and a row in the other pile is one it never was. The same clause is - * appended independently to the partial-withdrawal sentence further down, which is the - * shape this should always have had. - */ - const alsoUnreadable = - unreadable.length > 0 - ? ` Composio also described ${unreadable.length} of its ${toolkit} authorization configs in a way this deployment cannot read, so any grant of theirs on one of those is outside the question either reading settles, and renaming the config above is not on its own enough to end everything they hold: reading those rows in Composio's own dashboard is what says whether anything is left.` - : ""; - 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 ${whose(account)}'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, ${whose(account)}'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.${alsoUnreadable}`, - unreadable.length > 0 - ? { cause: everyRefusal(unreadable) } - : undefined, - ); - } - /* - * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS — the same distinction one - * field further in. A config whose id or name Composio sent unreadably cannot be put in the - * account listing's filter, so any grant of this person's sitting on it is invisible to - * everything below and a `false` would be the same false trail entry as the rename above. - * Where there IS something of ours it is withdrawn first and this is reported afterwards, for - * the reason {@link readableConfigs} gives; here there is nothing to withdraw first. - */ - if (ours.length === 0 && unreadable.length > 0) { - throw new BrokerRefusalError( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so whether ${whose(account)} holds a grant on one of this deployment's own could not be told and nothing was withdrawn. Their access has not been shown to end. ${VENDOR_SHAPE_REMEDY}`, - { cause: everyRefusal(unreadable) }, - ); - } - if (ours.length === 0) return false; - const accounts = await accountsFor(userId, toolkit, REVOCABLE, { - configs: ours.map((config) => config.id), - }); - /* - * THE READABLE ONES GO FIRST AND THE UNREADABLE ONES ARE REPORTED AFTERWARDS. Reading the ids - * of all of them before sending any delete is what made one unnameable row a permanent block - * on a person's disconnect — see {@link withdrawableAccounts}. Partitioning puts the withdrawal - * back in front of the report, which is the order a person's grants actually need. - */ - const { ids, nameless } = withdrawableAccounts(accounts, toolkit); - - /* - * AND NOTHING TO WITHDRAW IS ITS OWN STATE, WHICH IS THE BRANCH EVERY SIBLING OF THIS METHOD - * HAS AND THIS ONE DID NOT. - * - * A config of ours that IS readable, no account of this person's on it, and a row nothing - * could sort beside it: the partial-withdrawal throw below was what answered, and what it - * said was "Composio withdrew 0 of this person's 0 accounts for gmail". Both figures are - * counts of a set nothing measured, in the one sentence a reader is meant to act on, and - * "withdrew" asserts that a withdrawal happened. None did, and none was there to happen. - * {@link ComposioBroker.deleteAuthConfig} was corrected for the identical sentence about - * configs — "removed 0 of this deployment's 0" — and this is that count one listing further - * in, reached from the other end. - * - * STILL A REFUSAL, for the reason that method's branch is one. A grant of this person's may - * sit on the config behind the row nothing could sort, and the account listing was never - * scoped to it — so `store.ts` must not delete the row naming which app they connected on the - * strength of a question nothing asked. - * - * THE OTHER HALF OF "NOTHING TO WITHDRAW" IS NOT A REFUSAL AND IS ANSWERED BELOW. Every row - * legible, none of this person's accounts on any of them, is the ordinary state of a person - * who is not connected — `false`, which is what the ending of this method already says. - */ - if (ids.length === 0 && nameless.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 ${whose(account)} holds no account on any of the ones it could read, so nothing was withdrawn and their access has not been shown to end. Whether they hold a grant on one of THOSE is outside what either reading settles and disconnecting again meets the same answer: reading those rows in Composio's own dashboard is what says whether anything is left. ${VENDOR_SHAPE_REMEDY}`, - { cause: everyRefusal(unreadable) }, - ); - } - - /* - * WHAT CAME BACK IS READ, WHICH IS THE HALF THAT USED TO BE MISSING. Not throwing is not the - * same as having been done: Composio answers a delete with a `success` saying whether it - * performed one, and a `false` there means the account is still attached and no revocation - * job was started. It is counted as a refusal — into the same list a thrown vendor error - * lands in — so that the sentence below reports it as an account that was not withdrawn, - * which is exactly what it is. See {@link withdrawalDeclined}. - * - * INSIDE THE LOOP AND OUTSIDE {@link askVendor}, deliberately. `askVendor` wraps the - * `await vendor.*` and nothing else, because everything it translates is a fault raised - * inside the vendor's package; the refusal below is this file's own reading of a reply that - * arrived intact, and it already carries an authored sentence. - */ - const refused = await askForEach(ids, async (id) => { - const answer = await askVendor( - { - outcome: `one of this person's ${toolkit} accounts was not withdrawn`, - app: toolkit, - }, - () => vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), - ); - const declined = withdrawalDeclined(answer, toolkit); - if (declined !== null) throw declined; - }); - - if (refused.length > 0 || nameless.length > 0 || unreadable.length > 0) { - /* - * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw - * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which - * is the only thing in this deployment that names which app this person connected. Answer - * `true` here on a partial and that row is deleted, the trail records a disconnection, and - * the account this call could not end is left live with nothing pointing at it — the exact - * state the store's revoke-before-delete order exists to make impossible. Throwing leaves - * the row standing, so pressing disconnect again is a second attempt with everything the - * first one had, and the accounts already gone are no longer in the listing, so the retry - * converges rather than repeating. - * - * WHICH IS ALSO WHY IT IS NOT A `true` WITH A GRUMBLE. Nothing was disconnected in the - * sense the person asked about: their app still answers. The count is in the sentence - * because "some of your accounts were withdrawn" is the one thing a reader cannot work out - * for themselves, and EVERY refusal the loop met is kept as `cause` for whoever is reading - * a log rather than a page — see {@link everyRefusal} for why all of them rather than the - * first, which is what this used to keep. - * - * AND THE TWO WAYS A GRANT SURVIVES ARE NOT THE SAME ADVICE. An account Composio refused is - * one a second press reaches, which is what "disconnecting again" is worth saying about. An - * account it described with no id is not: the row will be as unnameable next time, so the - * only honest instruction is the one that does not run through this page at all. - * - * WHICH IS WHY THEY ARE TWO INDEPENDENT CLAUSES AND NOT A CHAIN. They were an `if`/`else - * if`, so one person holding both kinds — a nameless row beside a refused one, which is one - * listing away from either on its own — was told only that "disconnecting again meets them - * unchanged". That sentence is true of the row with no id and false of the refused account - * sitting next to it, and it is the sentence deciding whether they press the button again: - * the one remedy that would actually have ended the refused grant was withheld by the - * presence of a row it says nothing about. Two facts about two different accounts cannot - * take turns. {@link ComposioBroker.deleteAuthConfig} writes the identical pair as two - * independent `if`s one method up, and the paragraph above already described these as two - * remedies — the chain was the only thing disagreeing. - * - * THE REACHABLE REMEDY GOES FIRST, matching that method's order, because it is the one the - * reader can act on from the page they are standing on. - * - * AND THE DENOMINATOR IS THE ACCOUNTS, NOT THE ROWS. It was `accounts.length`, which is how - * many rows the listing handed over, and a listing that named one account twice is a - * listing with more rows than accounts — see {@link withdrawableAccounts}. "Withdrew 1 of - * this person's 2 accounts" over one account that is gone is a count nothing measured, in - * the sentence a reader is meant to act on. The two halves this call actually holds are the - * accounts it could name and the ones it could not, and their sum is the set. - */ - const accountsHeld = ids.length + nameless.length; - const left: string[] = []; - if (refused.length > 0) { - left.push( - "Disconnecting again asks only for the accounts that are left.", - ); - } - 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.`, - ); - } - /* - * 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 ${whose(account)}'s ${accountsHeld} accounts for ${toolkit} and their access to it has not been shown to end. ${left.join(" ")}`, - { cause: everyRefusal([...refused, ...nameless, ...unreadable]) }, - ); - } - - /* - * THE ACCOUNTS WITHDRAWN, WHICH IS THE SAME NUMBER BY A HONESTER ROUTE. Reaching this line - * means nothing refused and nothing was nameless, so the rows and the accounts differ only - * where the listing repeated one — and `true` is a claim about having asked the vendor to - * withdraw something, which is exactly what `ids` counts. - */ - return ids.length > 0; - }, - - /** - * What the app itself says it wants typed in, mapped onto the boxes a form can draw. - * - * READ A STEP AT A TIME OFF `unknown`, for the reason every other vendor read here is: the - * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list and a - * field row that is not an object are both shapes the wire can send. - * - * AND EVERY ONE OF THOSE STEPS REFUSES RATHER THAN ANSWERING `[]`, WHICH IS WHAT - * {@link unreadableForm} IS FOR AND WHY IT IS ONE SENTENCE FOR ALL OF THEM. `[]` here says one - * thing and one thing only: this app's scheme publishes nothing to fill in, which is a true - * thing about a real app. Every step of this read used to answer it for a shape it could not - * make sense of as well — a `fields` that is not an object, an initiation block that arrived as - * the rows that used to sit inside it, a `required` that stopped being a list — so an app that - * asks for nothing and an answer nothing here can open were the same empty form in the browser, - * and the argument two paragraphs down for why that form must never be drawn applied to both. - * - * AND A SCHEME THIS APP NO LONGER PUBLISHES IS A THIRD ANSWER AGAIN, WITH A REMEDY OF ITS OWN. - * The scheme is the RECORDED one and is never re-derived — that is the whole point of the - * column, and the store's own column comment 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 — an administrator's remedy, - * because the recorded scheme is only theirs to rewrite — while a mode that exists and - * publishes nothing visible still answers `[]`, and a mode whose published shape cannot be read - * refuses with the upgrade that is the only thing which fixes a vendor changing shape. - * - * 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), - ); - - /* - * THE ANSWER IS AN OBJECT BEFORE ANYTHING IS READ OFF IT, and the guard is not ceremony. - * `("perplexityai").auth_config_details` is `undefined` rather than a throw, so a retrieve - * that answered a bare string reached the refusal below wearing the sentence about a mode the - * app has stopped publishing — about an answer that had no app in it at all. - */ - if ( - typeof detail !== "object" || - detail === null || - Array.isArray(detail) - ) { - throw unreadableForm( - detail, - `Composio's own description of ${toolkit}`, - ); - } - - /* - * AND THE LIST OF MODES IS THE SAME TWO ANSWERS ONE FIELD IN. An `auth_config_details` that - * is not a list became no modes at all, and no modes means the recorded one is not among - * them — so an answer this deployment could not read arrived as the drift the refusal below - * is written about, with a remedy that cannot work on it. - */ - const listed = detail.auth_config_details ?? []; - if (!Array.isArray(listed)) { - throw unreadableForm( - detail.auth_config_details, - `the connection modes ${toolkit} publishes`, - ); - } - - /* - * AND A MODE THAT IS NOT A DOCUMENT IS THE SAME TWO ANSWERS ONE STEP FURTHER IN. `("API_KEY") - * .mode` is `undefined` rather than a throw, so an entry that stopped being an object compared - * unequal to the recorded scheme exactly as an app that had dropped it would, and the refusal - * below sent an administrator to remove the app and add it again — against a list whose next - * reading is the same unreadable shape, so the re-add records the same word and the app comes - * back refusing identically. - * - * AN ENTRY THAT IS ABSENT IS NOT THAT AND IS NOT REFUSED: a hole in the list is a mode the - * vendor left out, which is simply not the mode being looked for, and the sentence below is - * the true one about it. - * - * AND THE UNREADABLE ONES ARE COLLECTED RATHER THAN THROWN, WHICH IS THE PARTITION-RATHER- - * THAN-THROW DISCIPLINE THIS FILE ALREADY APPLIES AT {@link readableConfigs} AND - * {@link withdrawableAccounts}, ARRIVING AT THE ONE READER THAT WAS STILL EAGER. This was a - * `.map()` that threw, so EVERY entry was validated before `.find()` ever selected the mode - * being asked for — and one drifted entry anywhere in the list took down a mode that was - * perfectly readable. An app enabled as `API_KEY` whose retrieve answers - * `[{ mode: "API_KEY", fields: {…complete…} }, "OAUTH2"]` has an intact mode at index 0 and a - * bare string at index 1, and everybody pressing Connect on that app was blocked permanently - * — "no form was drawn … upgrading @composio/core is what fixes it" — over a mode nobody - * asked about. Nothing they can reach changes the next reading, which is the same shape. - * - * SO THE WANTED MODE IS LOOKED FOR FIRST AND THE REST ARE REPORTED ONLY IF IT IS NOT FOUND. - * Where it is found, an entry this deployment could not read is a mode it was not asked to - * draw. Where it is NOT found, the unreadable entries are the reason the refusal must not be - * the drift sentence below: one of them may BE the mode, and telling an administrator the app - * has dropped it would send them to re-add against a list that reads identically next time. - */ - const unreadableModes: unknown[] = []; - let mode: { mode?: unknown; fields?: unknown } | undefined; - for (const [index, candidate] of listed.entries()) { - if (candidate === undefined || candidate === null) continue; - if (typeof candidate !== "object" || Array.isArray(candidate)) { - unreadableModes.push({ index, candidate }); - continue; - } - const entry = candidate as { mode?: unknown; fields?: unknown }; - /* - * AND AN ENTRY WHOSE OWN MODE CANNOT BE READ IS UNREADABLE TOO, WHICH THE SHAPE TEST ABOVE - * DOES NOT CATCH. `{ mode: null, fields: {…} }` is an object and not an array, so it passes - * that guard, and `textOf` then answers null — which never equals a scheme name, so the - * loop simply moved on and the entry was never collected. With the wanted mode absent from - * the rest of the list, `unreadableModes` was empty, the vendor-shape refusal below was - * skipped, and control reached the ADMINISTRATOR'S refusal: Composio no longer publishes a - * connection of this scheme for this app. That is precisely the sentence this walk exists - * to keep off an entry that MAY BE the mode — it sends an administrator to remove and - * re-add against a list that reads identically next time. - * - * THE NAME IS WHAT MAKES AN ENTRY CLASSIFIABLE, so an entry without a readable one sits - * exactly where an entry that is not a document sits: this deployment cannot say whether it - * is the mode being looked for. A mode the vendor genuinely left out is a HOLE in the list, - * which is the `undefined`/`null` candidate skipped above, and is a different fact with a - * different true sentence. - */ - if (textOf(entry.mode) === null) { - unreadableModes.push({ index, candidate }); - continue; - } - /* - * THE MODE IS A SCHEME NAME, SO IT IS THE TRIMMED ONE — the rule {@link labelsOf} states - * for every other scheme this file reads and the one site that was comparing the raw value. - * A scheme is the NAME of a flow: it is matched against the word recorded on the app's row - * when somebody enabled it, so a padded `" API_KEY "` is the app's own mode wearing whatever - * the wire wrapped it in. Read raw it compared unequal to the recorded word exactly as a - * mode the app had DROPPED would, and the refusal below then sent an administrator to remove - * the app and add it again — which records the same padded word and comes back refusing - * identically, which is a loop that cannot close. - */ - if (textOf(entry.mode) === authScheme) { - mode = entry; - break; - } - } - - /* - * AND A MODE THAT WAS NOT FOUND BESIDE ENTRIES THAT COULD NOT BE READ IS NOT "THE APP DROPPED - * IT". This is the refusal an eager walk would have raised, reached only where it is the true - * thing to say: the wanted mode is not among what this deployment CAN read, and what it cannot - * read is where the answer may be. It is the vendor's shape that changed, so it carries the - * vendor's remedy rather than the administrator's. - */ - if (mode === undefined && unreadableModes.length > 0) { - throw unreadableForm( - unreadableModes[0], - `${unreadableModes.length} of the ways of connecting ${toolkit}, one of which may be the ${authScheme} one`, - ); - } - /* - * 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 || mode === null) { - 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.`, - ); - } - /* - * EVERY REMAINING STEP TELLS AN ABSENCE FROM AN ANSWER IT CANNOT READ, which is the whole of - * what {@link unreadableForm} is for. A mode that publishes no `fields`, no initiation block - * or an empty list of rows is saying it asks a person for nothing, and `[]` is the true - * reading of that. A `fields` that is not an object, an initiation block that arrived as the - * list of rows that used to sit inside it, and a `required` that stopped being a list are - * three different vendor changes, none of which is that sentence, and all three used to be - * written down as it. - */ - const asked = mode.fields ?? {}; - if (typeof asked !== "object" || asked === null || Array.isArray(asked)) { - throw unreadableForm( - mode.fields, - `the fields ${toolkit}'s ${authScheme} connection publishes`, - ); - } - const initiation = - (asked as { connected_account_initiation?: unknown }) - .connected_account_initiation ?? {}; - if ( - typeof initiation !== "object" || - initiation === null || - Array.isArray(initiation) - ) { - throw unreadableForm( - (asked as { connected_account_initiation?: unknown }) - .connected_account_initiation, - `what ${toolkit} asks for when a connection to it is made`, - ); - } - - /* - * THE REQUIRED LIST AND THE OPTIONAL ONE ARE ASKED SEPARATELY, because they are separately - * published and a guard on one says nothing about the other — and the required one is the one - * that stops a form, so a required list read as empty is a form a person can submit blank. - */ - const published = initiation as { - required?: unknown; - optional?: unknown; - }; - const rowsOf = (value: unknown, which: "required" | "optional") => { - const asRows = value ?? []; - if (!Array.isArray(asRows)) { - throw unreadableForm( - value, - `the ${which} fields ${toolkit} asks a person to fill in`, - ); - } - return asRows; - }; - /* - * REQUIRED FIRST, WHICH IS AN ORDER RATHER THAN A CONVENIENCE — see the deduplication below, - * where it is what decides which of two rows sharing a name survives. - */ - const rows = [ - ...rowsOf(published.required, "required"), - ...rowsOf(published.optional, "optional"), - ]; - - const drawn = rows - .filter((row, index) => { - /* - * THE ROW IS AN OBJECT BEFORE A FIELD IS READ OFF IT, WHICH IS THE ONE CONTAINER IN THIS - * FILE THAT WAS NOT ASKED. - * - * Every other one is: the catalogue row, its `meta`, each category, the action row, the - * toolkit detail, the list of modes, the mode itself, the `fields` object and the - * initiation block inside it. The rows hanging off that block were not, and nothing here - * throws for it — `("generic_api_key").user_visible` is `undefined`, so a row that arrived - * as a string passes the visibility read as an ordinary shown field and reaches the type - * guard below, which refuses it with "Connecting this app is not something this deployment - * can offer yet". That is a verdict about the APP, it carries no remedy at all, and the - * thing it describes is a package whose shape moved. The remedy for that is the one - * {@link unreadableForm} ends every other step of this read with. - * - * ABSENT IS NOT EXEMPTED HERE, WHICH IS WHERE THIS DIFFERS FROM THE MODE LIST ABOVE. A - * hole in THAT list is simply a mode the vendor left out and the `find` below it says the - * true thing about it; a hole in THIS one is a field the form would silently be one box - * short of, and there is no reading of `null` under which a person is being asked for - * nothing in particular. - */ - if (typeof row !== "object" || row === null || Array.isArray(row)) { - throw unreadableForm( - row, - `field ${index + 1} of what ${toolkit}'s ${authScheme} connection asks a person to fill in`, - ); - } - - /* - * A FIELD COMPOSIO HIDES IS ONE COMPOSIO FILLS IN, AND `!== false` HID THE WRONG STATE. - * An absent `user_visible` means show it, which is most of the catalogue and stays. A - * present `"false"` is Composio saying the opposite and reading as "show it" — a box - * drawn for a tenant id or an instance name the person has no way to know, which they - * then leave blank and submit as an empty value under a key the app does read. - */ - const visible = flagOf(row?.user_visible, true); - if (visible === null) { - throw new BrokerRefusalError( - `${toolkit} describes ${textOf(row?.displayName) ?? textOf(row?.name) ?? "one of the values it asks for"} with ${sent(row?.user_visible)} where the flag saying whether that field is shown to the person filling the form in belongs. A field Composio hides is one Composio fills in itself, so a flag this deployment cannot read is the difference between a form and a form with a box nobody can answer. ${VENDOR_SHAPE_REMEDY}`, - ); - } - return visible; - }) - .map((row) => { - /* - * A TYPE THIS DEPLOYMENT CANNOT DRAW IS A REFUSAL RATHER THAN A TEXT BOX. Every required - * field measured across the catalogue is a plain string, so this is a guard against the - * vendor rather than a routine case — and the failure it prevents is somebody typing a - * path into a box labelled Certificate and being told they are connected. - * - * AND THE NAME IS IN THE SAME GUARD, because it is the least guarded field here and the - * only one that travels. `label`, `help` and `default` all pass through {@link textOf} - * and are read by a person; the name is sent back to Composio verbatim and is the key - * {@link ComposioBroker.connectWithFields} spreads into the connection's `val`. Coerced - * with `String(...)`, a row carrying `type: "string"` and no name drew a box literally - * called "undefined" and then submitted whatever was typed in it under that key — a value - * no app reads, in a connection Composio accepts. - */ - /* - * AND THE TYPE IS READ THE WAY THE NAME BESIDE IT IS, which is the half this guard did - * not have. `row.type !== "string"` was the RAW value while the name one line up goes - * through {@link textOf}, so a padded `" string "` was refused with "a box this - * deployment can draw is a string" — about a field whose type IS a string, in a sentence - * whose own words say so. A type is a keyword and not prose: the value is the word, and - * the padding is whatever the wire wrapped it in. - */ - const name = textOf(row?.name); - if (textOf(row?.type) !== "string" || name === null) { - throw new BrokerRefusalError( - `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)} under the name ${sent(row?.name)}, which cannot be filled in here: a box this deployment can draw is a string, and a box whose answer can be sent back has a name. Connecting this app is not something this deployment can offer yet.`, - ); - } - - /* - * AND ONE NAME IS THE PROTOCOL'S RATHER THAN THE APP'S, WHICH IS THE SAME GUARD ONE STEP - * FURTHER ON: a box whose answer cannot be sent must not be drawn. - * - * {@link ComposioBroker.connectWithFields} puts what somebody types into the `val` of the - * connection state, beside the `status` that says what is being created — the shape the - * vendor's own `AuthScheme` builder assembles (`@composio/core` 0.18.1, - * `src/models/AuthScheme.ts:84-94`). `status` is therefore a word the call itself owns, - * and BOTH answers to a field published under that name are wrong. Sent, it replaces the - * state this deployment is asking Composio to create, from a request that looks like an - * ordinary connection. Withheld — which is what that call now does, writing its own word - * last — it is a box somebody filled in whose value no app will ever read, the very - * failure the name guard directly above exists to prevent. - * - * SO THE APP IS REFUSED RATHER THAN PART OF ITS FORM, and the sentence names the field, - * which is the vendor's own text read off the list just fetched and the only thing an - * administrator can act on. - */ - if (name === "status") { - throw new BrokerRefusalError( - `${toolkit} publishes a field called ${name}, which is the word this deployment uses to tell Composio what state a connection is being created in, so a value typed into it either overwrites that word or is never sent at all. Neither is a box worth drawing. ${VENDOR_SHAPE_REMEDY}`, - ); - } - /* - * 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); - - /* - * THE TWO FLAGS THAT DESCRIBE THE BOX ARE READ THE SAME WAY THE TYPE AND THE NAME ARE, - * AND FOR THE SAME REASON. `row.required === true` and `row.is_secret === true` answered - * "no" to an absent flag, which is right and is most of the catalogue, and answered "no" - * to a PRESENT `"true"` as well — the vendor's yes and the vendor's silence collapsed - * into one value at the point either is acted on. - * - * `required` IS THE SHARPER OF THE TWO BECAUSE THE CONNECT ROUTE NOW ENFORCES IT. The - * guard that refuses a submission omitting a required field reads this boolean and no - * other, so a vendor publishing `"true"` would make that guard wave through the exact - * submission it exists to refuse: an account created with the credential missing out of - * it, which Composio answers `ACTIVE` for because it does not grade what it is given, and - * which the first tool call is the first thing to notice. - * - * `is_secret` IS THE QUIETER ONE AND NOT THE SMALLER ONE. Read as a no, the box for - * somebody's API key is drawn as ordinary text: typed in plain sight, left on the screen, - * and offered to whatever fills fields in. - */ - const required = flagOf(row.required, false); - if (required === null) { - throw new BrokerRefusalError( - `${toolkit} describes ${name} with ${sent(row.required)} where the flag saying whether that field has to be filled in belongs. Read as a no — which is what a value this deployment cannot read would otherwise become — a field ${toolkit} requires is drawn as one a person may leave blank, and what they submit is an account Composio accepts with the credential missing out of it. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - const secret = flagOf(row.is_secret, false); - if (secret === null) { - throw new BrokerRefusalError( - `${toolkit} describes ${name} with ${sent(row.is_secret)} where the flag saying whether that field holds a secret belongs. Read as a no, the box is drawn as ordinary text, so whatever goes in it — a key, a token — is typed in plain sight and left on the screen. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - return { - name, - label: textOf(row.displayName) ?? name, - help: textOf(row.description) ?? "", - required, - secret, - ...(suggested === null ? {} : { default: suggested }), - }; - }); - - /* - * ONE NAME IS ONE BOX, HOWEVER MANY OF COMPOSIO'S LISTS PUBLISHED IT — the half - * {@link readableConfigs} and {@link withdrawableAccounts} already carry, arriving here a - * wave late and for a sharper reason than either. - * - * THE NAME IS A KEY AND NOT A LABEL. `required` and `optional` are published separately, so - * nothing at Composio stops one field appearing in both, and concatenated as they arrive that - * app draws TWO boxes carrying the same name — with different labels, different help and - * different defaults, because the two rows are different rows. A person fills both in; the - * form collects what they typed under the field's name; the second box silently overwrites - * the first, and which of the two values reached the vendor is not recorded anywhere. The - * same name twice also passes straight through the connect route's published-names check, so - * nothing downstream is in a position to notice. - * - * THE FIRST SIGHTING KEEPS ITS PLACE, as it does for configs and accounts — and here that is - * a decision as well as a convention, because required is spread first: a name published in - * both lists is drawn as the REQUIRED one. That is the safe direction. Read as optional, a - * credential the app cannot do without becomes a box the connect route lets somebody leave - * blank, and what that makes is the credential-less account that route's required guard - * exists to refuse. - */ - /* - * `drawnNames` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE — for the - * second time in this file, and this one came back through a merge after the first had been - * diagnosed. This binding shadowed the module helper {@link named} for the whole of this - * method, so the function every refusal in the file quotes a vendor enum through was - * unreachable from the one place a new refusal here would reach for it, and an edit that did - * would have been calling a Set. `resolved.toolkit`'s reader says the same thing about the - * same word one method up; nothing is wrong today, and the next change to this block is what - * both renames are for. - */ - const drawnNames = new Set(); - return drawn.filter((field) => { - if (drawnNames.has(field.name)) return false; - drawnNames.add(field.name); - return true; - }); - }, - - /** - * 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({ - account, - toolkit, - authScheme, - values, - }): Promise<{ accountId: string }> { - const userId = account.vendorUserId; - /* - * THIS DEPLOYMENT'S OWN CONFIG, FOR THE REASON {@link ComposioBroker.authorize} READS ONE: an - * account is a lasting attachment to whatever config it was made against, so attaching - * somebody to an operator's hand-made config — scopes this deployment cannot see, tool - * restrictions it cannot read, an object it must not delete — is not a guess that can be - * corrected afterwards. - */ - const { ours, unreadable } = await configsFor(toolkit); - if (ours.length === 0) { - /* - * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND IT IS THE SAME TWO - * REMEDIES {@link ComposioBroker.authorize} TELLS APART. "Remove the app and add it again" - * is right where the listing was legible and said none of these is ours, and wrong here: - * the row that could not be sorted may BE ours, in which case the removal meets - * {@link ComposioBroker.deleteAuthConfig}'s own refusal and the re-enable meets - * `ensureAuthConfig`'s. That is an administrator sent round a loop that cannot close, and - * this method used to hand them exactly that sentence. - * - * NO `cause` ON ANY OF THE REFUSALS IN THIS METHOD, which is the local rule rather than the - * file's. Not one of the four refusals this config read can raise carries a vendor object at - * all — they are this file's own reading of a listing — but attaching - * `everyRefusal(unreadable)` here, as `authorize` correctly does, would put an error chain - * on the one method whose call frame holds somebody's API key, and the whole of this - * method's doc comment is about not doing that. The count is the finding, and the count is - * in the sentence. - */ - if (unreadable.length > 0) { - throw new BrokerRefusalError( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and what was typed into the form was not sent anywhere. ${VENDOR_SHAPE_REMEDY}`, - ); - } - throw new BrokerRefusalError( - `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against and nothing was sent. An administrator removing the app on its Plugins page and adding it again creates one.`, - ); - } - - /* - * THE ENABLED ONE, WHICH IS THE SAME CHOICE {@link ComposioBroker.authorize} MAKES AND FOR ONE - * REASON MORE. `configsFor` lists with `showDisabled: true` — it has to, or `ensureAuthConfig` - * creates a second config beside one it cannot see — so `ours[0]` could perfectly well be a - * DISABLED config, and an account created against one cannot work. Reading that state only - * after the create is the wrong order on this path more than on any other: the body of that - * request is the key somebody just pasted in, so the refusal has to arrive BEFORE the - * credential leaves this process rather than after Composio has been handed it and declined. - * - * AND IT IS THE SAME CONFIG THE CONSENT PATH WOULD HAVE PICKED, which is the other half. - * Two configs from a lost enable race, the first of them disabled, and `ours[0]` against - * `find(ENABLED)` attach one app's accounts to two different configs depending on which door - * a person came through — after which removing "the" config drops half of them. - */ - const config = ours.find((held) => statusOf(held) === "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. - */ - /* - * AND TWO INDEPENDENT CLAUSES FOR THE REASON {@link ComposioBroker.authorize} GIVES AT - * LENGTH, which is worth repeating here only because this block is a COPY of that one rather - * than a call to it: the chain lived in both methods, so the suppression did too. A person - * typing their key into an app holding one DISABLED config beside one unsettled row was told - * to upgrade a package — while the act that would have got the key accepted, enabling the - * disabled config in Composio's dashboard, went unsaid. It is not even that person's act, - * which is exactly why the sentence has to carry it rather than choose. - */ - const unsettled = ours.filter((held) => statusOf(held) !== "DISABLED"); - const left: string[] = []; - const disabled = ours.length - unsettled.length; - if (disabled > 0) { - left.push( - `Composio calls ${disabled} of this deployment's ${ours.length} authorization configs for ${toolkit} disabled, and an administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, - ); - } - if (unsettled.length > 0) { - const words = [ - ...new Set(unsettled.map((held) => named(held.status))), - ]; - /* - * AND THE TAIL COUNTS IN THE NUMBER'S OWN WORDS. "and 1 other words" was the unpluralised - * spelling, in a sentence whose very next clause conjugates its own verb on the same - * count — so the one refusal that has to be trusted about an authorization config could - * not get its own arithmetic to read. See {@link STATUSES_NAMED} for why there is a tail - * at all. - */ - const shown = words.slice(0, STATUSES_NAMED); - const unnamed = words.length - shown.length; - const said = - unnamed > 0 - ? `${shown.join(", ")} and ${unnamed} other word${unnamed === 1 ? "" : "s"}` - : shown.join(", "); - left.push( - `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. ${VENDOR_SHAPE_REMEDY}`, - ); - } - /* - * AND THE ROWS NOTHING COULD SORT ARE THE THIRD CLAUSE, for the reason - * {@link ComposioBroker.authorize} gives beside the identical one: a row whose id or name - * never arrived may be a config of this deployment's that Composio calls ENABLED, so the - * dashboard reading is a finding this sentence has and the disabled clause does not carry. - * No `cause`, which is this method's own standing rule — the count is the finding and the - * count is in the sentence, because the call frame this refusal is raised in holds somebody's - * API key. - */ - if (unreadable.length > 0) { - left.push( - `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of THOSE is a config of this deployment's that Composio calls enabled is outside what either reading settles: reading those rows in Composio's own dashboard is what says whether there is one here to connect against at all. ${VENDOR_SHAPE_REMEDY}`, - ); - } - throw new BrokerRefusalError( - `No authorization config this deployment holds at Composio for ${toolkit} could be shown to be enabled, so nothing was sent and what was typed into the form did not leave this deployment. ${left.join(" ")}`, - ); - } - - 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. - * - * AND `status` IS THE PROTOCOL'S WORD, SO IT IS WRITTEN LAST. The vendor's builder - * spreads the fields OVER it, which is safe only while no field is called `status` — - * and the names in `values` are the vendor's own, so that is a fact about somebody - * else's catalogue rather than an invariant of this call. Spread first and set after, - * the one word saying what this request is asking Composio to create cannot be - * displaced by a value arriving under the same name, whatever published it. - * - * WHICH IS THE SECOND OF TWO GUARDS AND NOT THE ONLY ONE. {@link - * ComposioBroker.connectionFields} refuses to draw a box named `status` at all, so a - * value under that name cannot come from a form, and the connect route sends only names - * that list published. This method is callable without either, and what it protects is - * the one field here that cannot be recovered from anything else in the request. - */ - state: { - authScheme, - val: { ...values, status: "ACTIVE" }, - }, - }, - }); - } catch (error) { - /* - * ONLY THE SENTENCE LEAVES THIS BLOCK. `vendorSentence` is the one door in this deployment - * for reading a vendor's own words, and reading it is the whole of what `error` is used - * for: it is not attached, not rethrown and not named below this line. - */ - const said = vendorSentence(error); - throw new BrokerRefusalError( - said === null - ? `Composio did not accept the connection to ${toolkit} and said nothing this deployment can pass on. The failure arrived through this deployment's @composio/core with no sentence of Composio's on it, which is what an outage, a cancelled request and a reply the package could not read all look like from here — and the failure itself was dropped rather than recorded, because on this one call the object carrying it also carries what was typed into the form. Nothing was attached. Composio's own dashboard logs the attempt, and asking again is what settles whether the request ever landed.` - : `Composio refused the connection to ${toolkit}: ${said} Nothing was attached. Nothing further about this attempt is kept here, because what the failure carried was the value typed into the form — the request id in Composio's own words above is what their dashboard searches on.`, - ); - } - - /* - * NO ID IS NOT A CONNECTION, HOWEVER THE REPLY READS. The id is the whole of what this method - * answers and the only thing a caller can undo its own work with, so handing back an - * `undefined` cast to a string would leave an account standing at Composio that nothing on - * this deployment can name, made from a credential somebody typed a moment ago. The dashboard - * is named because it is the only place that account can now be seen and removed. - */ - const accountId = textOf(created?.id); - if (accountId === null) { - throw new BrokerRefusalError( - `Composio answered the connection to ${toolkit} with no account id, so this deployment cannot name the account it just asked for and cannot take it back. An account may be standing at Composio over this: ${toolkit} in Composio's own dashboard is where it can be seen and removed. ${VENDOR_SHAPE_REMEDY}`, - ); - } - - return { accountId }; - }, - - /** - * ONE account ended by id, and NOT {@link ComposioBroker.revoke}, which is the whole decision. - * - * `revoke` ends every account a person holds for an app. That is right for what it serves — a - * person ending their access, where any account left behind is access that still answers — and - * it is wrong for a verification undoing what it just made. The two read identically right up - * until the local row and Composio have drifted apart, and that is precisely the state a failed - * verification stands in: a connection that was working, a second account just created from a - * key that does not work, and a sweep that takes down both. So this method is handed the id and - * nothing else — nothing is listed, nothing is matched, and no account it was not given can be - * reached from here. - * - * WITH `revoke_on_delete`, FOR THE REASON {@link ComposioVendor}'s `delete` GIVES AND ONE MORE - * OF ITS OWN. Without the flag the account stops being visible to this deployment and the - * credential at the far end stands; here that credential is one somebody typed into a form - * minutes ago, into a page that is about to tell them the connection was not kept. - * - * NOTHING IS ANSWERED AND NOTHING IS SWALLOWED. There is no count to report — the id names one - * account that existed moments ago — so a failure is a failure, and it leaves through - * {@link askVendor} like every other vendor call in this file. - * - * AND "I DID NOT DELETE IT" IS ONE OF THOSE FAILURES, WHICH THE AWAIT USED TO DISCARD. The - * delete answers `{ success?: unknown }` for the reason {@link ComposioVendor}'s declaration - * gives: a 200 carrying `success: false` is Composio saying it did NOT delete the account, so - * no revocation was started and nothing was asked of the provider — the same lie - * {@link withdrawalDeclined} exists to stop one method away. It matters MORE here than there, - * because of what this call's caller does with a clean return. The verification step withdraws - * the account behind a key that failed, and where the withdrawal ITSELF fails it writes the row - * unverified so the account stays reachable and disconnectable. A `success: false` resolving - * normally takes the other branch: no row is written, and what is left standing is a live - * account holding a working-or-not credential that nothing on any screen names and nobody can - * press disconnect on. - * - * ITS OWN SENTENCES RATHER THAN {@link withdrawalDeclined}'s, because that function's two are - * written around a toolkit and around pressing disconnect again, and this method has neither: it - * was handed an id, the app is the caller's to name, and the second press it would invite is a - * button that is not on any page for an account no row points at. What carries across unchanged - * is the null/undefined exemption — a 204 or a body of content-length zero is the vendor saying - * it DID delete and having nothing to add, and reading those as a refusal is the same lie - * pointing the other way. - */ - async revokeAccount(accountId): Promise { - const answer = await askVendor( - { - outcome: - "the one account this call was handed was not withdrawn and may be standing at Composio", - /* - * NO APP IN THE QUESTION, which is what the id being the whole of it means. The caller - * holds the toolkit it connected and can say it; this call was given an account. - */ - app: null, - }, - () => - vendor.connectedAccounts.delete(accountId, { - revoke_on_delete: true, - }), - ); - - if (answer === null || answer === undefined) return; - /* - * AND A REPLY THAT IS NOT A DOCUMENT IS NOT A DOCUMENT WITH ITS VERDICT MISSING, which is the - * distinction {@link withdrawalDeclined} draws one method away and for the same reason. The - * generated client parses the body and hands it over, so a bare string or a list reaches this - * read; `("deleted").success` is `undefined`, and the last refusal below then reported a reply - * with one field absent about an answer that held no reply at all. - */ - if (typeof answer !== "object" || Array.isArray(answer)) { - throw new BrokerRefusalError( - `Composio sent ${sent(answer)} where its reply to the withdrawal of the account this connection just made belongs, so there is no field in it saying 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}`, - ); - } - const verdict = (answer as { success?: unknown }).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}`, - ); - }, - - async accountName({ account, toolkit }) { - const rows = await accountsFor(account.vendorUserId, toolkit, CONNECTED, { - enough: (found) => found.length > 0, - }); - /* - * BY NAME, AND NOTHING ELSE. `data` is the vendor's whole connection blob — access, refresh - * and id tokens, and for some apps a client secret — and the one field a page may show is the - * display name the vendor chose to publish. Nothing is decoded out of a token to make up for a - * missing one: a name we had to dig for is a name the vendor did not publish. - */ - for (const row of rows) { - const data = row.data; - if (data && typeof data === "object" && "displayName" in data) { - const name = textOf((data as { displayName?: unknown }).displayName); - if (name) return name; - } - } - return null; - }, - }; - return { actions, broker }; } diff --git a/server/src/plugins/composio-client/accounts.ts b/server/src/plugins/composio-client/accounts.ts new file mode 100644 index 000000000..234696f7d --- /dev/null +++ b/server/src/plugins/composio-client/accounts.ts @@ -0,0 +1,118 @@ +import { BrokerRefusalError, type ComposioBroker } from "../broker"; +import { + askVendor, + sent, + textOf, + VENDOR_SHAPE_REMEDY, +} from "../composio-vendor"; +import type { ClientContext } from "./context"; +import { CONNECTED } from "./model"; + +/** One connected account: revoking it, and the name it is shown by. */ +export function accountMethods(context: ClientContext) { + const { vendor, accountsFor } = context; + return { + /** + * ONE account ended by id, and NOT {@link ComposioBroker.revoke}, which is the whole decision. + * + * `revoke` ends every account a person holds for an app. That is right for what it serves — a + * person ending their access, where any account left behind is access that still answers — and + * it is wrong for a verification undoing what it just made. The two read identically right up + * until the local row and Composio have drifted apart, and that is precisely the state a failed + * verification stands in: a connection that was working, a second account just created from a + * key that does not work, and a sweep that takes down both. So this method is handed the id and + * nothing else — nothing is listed, nothing is matched, and no account it was not given can be + * reached from here. + * + * WITH `revoke_on_delete`, FOR THE REASON {@link ComposioVendor}'s `delete` GIVES AND ONE MORE + * OF ITS OWN. Without the flag the account stops being visible to this deployment and the + * credential at the far end stands; here that credential is one somebody typed into a form + * minutes ago, into a page that is about to tell them the connection was not kept. + * + * NOTHING IS ANSWERED AND NOTHING IS SWALLOWED. There is no count to report — the id names one + * account that existed moments ago — so a failure is a failure, and it leaves through + * {@link askVendor} like every other vendor call in this file. + * + * AND "I DID NOT DELETE IT" IS ONE OF THOSE FAILURES, WHICH THE AWAIT USED TO DISCARD. The + * delete answers `{ success?: unknown }` for the reason {@link ComposioVendor}'s declaration + * gives: a 200 carrying `success: false` is Composio saying it did NOT delete the account, so + * no revocation was started and nothing was asked of the provider — the same lie + * {@link withdrawalDeclined} exists to stop one method away. It matters MORE here than there, + * because of what this call's caller does with a clean return. The verification step withdraws + * the account behind a key that failed, and where the withdrawal ITSELF fails it writes the row + * unverified so the account stays reachable and disconnectable. A `success: false` resolving + * normally takes the other branch: no row is written, and what is left standing is a live + * account holding a working-or-not credential that nothing on any screen names and nobody can + * press disconnect on. + * + * ITS OWN SENTENCES RATHER THAN {@link withdrawalDeclined}'s, because that function's two are + * written around a toolkit and around pressing disconnect again, and this method has neither: it + * was handed an id, the app is the caller's to name, and the second press it would invite is a + * button that is not on any page for an account no row points at. What carries across unchanged + * is the null/undefined exemption — a 204 or a body of content-length zero is the vendor saying + * it DID delete and having nothing to add, and reading those as a refusal is the same lie + * pointing the other way. + */ + async revokeAccount(accountId): Promise { + const answer = await askVendor( + { + outcome: + "the one account this call was handed was not withdrawn and may be standing at Composio", + /* + * NO APP IN THE QUESTION, which is what the id being the whole of it means. The caller + * holds the toolkit it connected and can say it; this call was given an account. + */ + app: null, + }, + () => + vendor.connectedAccounts.delete(accountId, { + revoke_on_delete: true, + }), + ); + + if (answer === null || answer === undefined) return; + /* + * AND A REPLY THAT IS NOT A DOCUMENT IS NOT A DOCUMENT WITH ITS VERDICT MISSING, which is the + * distinction {@link withdrawalDeclined} draws one method away and for the same reason. The + * generated client parses the body and hands it over, so a bare string or a list reaches this + * read; `("deleted").success` is `undefined`, and the last refusal below then reported a reply + * with one field absent about an answer that held no reply at all. + */ + if (typeof answer !== "object" || Array.isArray(answer)) { + throw new BrokerRefusalError( + `Composio sent ${sent(answer)} where its reply to the withdrawal of the account this connection just made belongs, so there is no field in it saying 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}`, + ); + } + const verdict = (answer as { success?: unknown }).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}`, + ); + }, + + async accountName({ account, toolkit }) { + const rows = await accountsFor(account.vendorUserId, toolkit, CONNECTED, { + enough: (found) => found.length > 0, + }); + /* + * BY NAME, AND NOTHING ELSE. `data` is the vendor's whole connection blob — access, refresh + * and id tokens, and for some apps a client secret — and the one field a page may show is the + * display name the vendor chose to publish. Nothing is decoded out of a token to make up for a + * missing one: a name we had to dig for is a name the vendor did not publish. + */ + for (const row of rows) { + const data = row.data; + if (data && typeof data === "object" && "displayName" in data) { + const name = textOf((data as { displayName?: unknown }).displayName); + if (name) return name; + } + } + return null; + }, + } satisfies Pick; +} diff --git a/server/src/plugins/composio-client/actions.ts b/server/src/plugins/composio-client/actions.ts new file mode 100644 index 000000000..8b0845449 --- /dev/null +++ b/server/src/plugins/composio-client/actions.ts @@ -0,0 +1,202 @@ +import type { + ComposioAction, + ComposioActions, + ComposioResult, +} from "../composio"; +import { + actionOf, + askVendor, + everyRowOf, + type Listing, + pageOf, + sent, + textOf, + VENDOR_SHAPE_REMEDY, + type VendorToolRow, +} from "../composio-vendor"; +import type { ClientContext } from "./context"; + +/** Listing an app's actions and calling one, as ./composio describes them. */ +export function actionsFor(context: ClientContext): ComposioActions { + const { vendor } = context; + + const actions: ComposioActions = { + async listActions(toolkit, page): Promise { + /* + * A PAGE OF NOTHING IS NOT A PAGE, AND THE REASON IT IS REFUSED HAS MOVED. + * + * It used to be a fact about the wrapper: `getRawComposioTools` composed its request with + * `...(limit ? { limit } : {})` (`@composio/core` 0.18.1, `src/models/Tools.ts:536`) over a + * schema spelling the field `z.number().optional()` with no floor + * (`src/types/tool.types.ts:257`), so a zero was not sent short — it was not sent at all, and + * Composio's own page of twenty came back looking exactly like everything a small app + * publishes. The raw client passes a zero through, so what a zero means now is Composio's to + * say rather than a silent substitution. + * + * IT IS STILL REFUSED, AND THE ARGUMENT IS THE ONE THAT DID NOT DEPEND ON THE WRAPPER. The + * page is required on this seam precisely so that no layer supplies one quietly; a caller + * asking for no rows is a caller with a fault, and a fault is a thing to report rather than a + * thing to correct on their behalf. What changed with paging is that the limit is now a PAGE + * SIZE rather than the whole listing — {@link everyRowOf} reads on until the cursor stops — + * so a small one costs requests rather than actions. A zero would cost every request there + * is, or none. + */ + if (!Number.isInteger(page.limit) || page.limit < 1) { + throw new Error( + `A page of ${page.limit} rows is not a page Composio can be asked for, so ${toolkit}'s action list was not refreshed and the tools already held are untouched. The page a listing asks for is required on this seam so that no layer supplies one quietly, and a request for no rows is a fault to report rather than one to correct on a caller's behalf.`, + ); + } + + /* + * EVERY PAGE, WHICH IS WHAT THIS SEAM COULD NOT DO UNTIL THE LISTING LEFT THE WRAPPER. + * `./composio` used to meet a listing at {@link LISTING_LIMIT} and refuse it, for the reason + * the catalogue above used to: an app with exactly that many actions and one with more of + * them answer identically, and committing the second deletes every action past the cut from + * `mcp_tools` under a refresh that reported success. That refusal is gone with this, and the + * guard it cannot be confused with — `store.ts`'s empty-listing guard — stays where it is. + */ + const listing: Listing = { + noun: `${toolkit}'s actions`, + consequence: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + }; + const tools = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { + outcome: `${toolkit}'s action list was not refreshed and the tools already held are untouched`, + app: toolkit, + }, + () => + vendor.tools.list({ + toolkit_slug: toolkit, + limit: page.limit, + // The SDK's own default, forwarded on every listing it made, and the thing that + // decides which `version` each action carries. See {@link ComposioVendor}. + toolkit_versions: "latest", + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), + ); + + return tools.map((row, position) => actionOf(row, position, toolkit)); + }, + + async execute(call, args): Promise { + /* + * THE TOOL IS RESOLVED BEFORE IT IS RUN, AND THAT COSTS A ROUND TRIP ON PURPOSE. + * + * Composio's execute takes the slug alone — its REST parameters have no toolkit field — so + * the pair the caller was gated on cannot travel on the wire, and the obligation + * {@link ComposioActions.execute} writes down has to be discharged here instead. The + * resolved tool carries the app the vendor will actually run it against, so asking for it + * first is what makes the check possible at all. + * + * `tools.execute` resolves the same tool again internally, so this is a second request + * rather than a saved one. It buys the one thing a single request cannot: a mismatch that is + * refused before anything runs, rather than discovered in an audit row afterwards. + * + * AND IT IS TAKEN AT ITS DECLARED TYPE, WHICH IS THE ONE ANSWER IN THIS FILE MOST SAFE TO DO + * THAT WITH. `getRawComposioToolBySlug` ends in `this.transformToolCases(tool)` (`@composio/core` + * 0.18.1, `src/models/Tools.ts:719`), whose last act is `ToolSchema.parse(...)` — a throwing + * parse — so what resolves here is an object satisfying that schema or a `ZodError` that + * `./composio` recognises and answers with a package remedy. An answer that is not an object, + * and a `toolkit` that is present and not an object, both die at that parse; two refusals + * stood here for exactly those and neither could be reached. + */ + const resolved = await askVendor( + { + outcome: `${call.slug} was not resolved and nothing was run`, + app: call.toolkit, + }, + () => + vendor.tools.getRawComposioToolBySlug(call.slug, { + version: call.version, + }), + ); + + /* + * AN UNREADABLE APP IS NOT THE SAME FACT AS NO APP, AND THEIR REMEDIES DIFFER. The mismatch + * refusal below ends by telling an administrator to refresh this app's tools, which is right + * for a slug recorded against a url that has since changed and useless for an SDK that has + * begun answering a different shape. So a toolkit whose slug is not a usable name is refused + * as what it is rather than folded into "no app at all", where it would arrive wearing a + * remedy that cannot work. + * + * AND IT IS STILL READ, DESPITE `ToolkitSchema` SPELLING THE SLUG REQUIRED, for the reason + * {@link actionOf} reads the action's own: `z.string()` is satisfied by the empty string, so + * a passing parse still admits an app with no name — which would compare unequal to every + * toolkit and refuse this call as a mismatch with nothing on the other side of the sentence. + */ + /* + * `?? undefined` BECAUSE ABSENT ALREADY HAS A SENTENCE HERE AND `null` IS THE OTHER SPELLING + * OF IT. The mismatch refusal below already says "no app at all" for an action the vendor + * attributes to nothing, so absence is an answer on this path rather than a fault. Exempting + * only `undefined` read a `null` as an app that was PRESENT and then took `.slug` off it — + * `null is not an object (evaluating 'answeredApp.slug')`, thrown from outside every vendor + * `try` in this file, which `./composio` puts into a model's context and an audit row as this + * deployment's account of what happened. That is the same `undefined`-only exemption the + * three field guards in {@link actionOf} carried, failing the loud way instead of the total + * one. + */ + const answeredApp = resolved.toolkit ?? undefined; + let ran: string | undefined; + if (answeredApp !== undefined) { + /* + * `answeredSlug` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE. This + * binding was called `named` and shadowed the module helper of that name for the rest of + * the block — so {@link named} was unreachable here, and an edit reaching for it would have + * been calling a string. Nothing was wrong today; the next change to this block is what the + * rename is for. + */ + const answeredSlug = textOf(answeredApp.slug); + if (answeredSlug === null) { + throw new Error( + `Composio sent ${sent(answeredApp.slug)} where the slug of the app ${call.slug} belongs to should be, so nothing was run: a name this deployment cannot read is not one it can compare with ${call.toolkit}. ${VENDOR_SHAPE_REMEDY}`, + ); + } + ran = answeredSlug; + } + + if (ran !== call.toolkit) { + /* + * REFUSED RATHER THAN FORWARDED, and both apps are named. + * + * The gate in `./access` cleared this run against the app the connection's url names + * NOW; the slug was recorded by a listing made at some earlier time. Where the two + * disagree — a url edited between a refresh and a call — forwarding runs one person's + * Gmail action under a gate that only ever examined their Slack connection. A reader + * holding only one of the two names cannot tell which of the two is the wrong one, so + * both go in the sentence. An app the vendor did not name at all is the same refusal: + * this deployment cannot show that the call is about the app it was gated on. + */ + throw new Error( + `${call.slug} was not sent to Composio: this connection is for ${call.toolkit}, and Composio resolves that action to ${ + ran ?? "no app at all" + }. Refreshing this app's tools on its Plugins page recovers it where the action was recorded against a url that has since changed.`, + ); + } + + return askVendor( + { outcome: `${call.slug} was not run`, app: call.toolkit }, + () => + vendor.tools.execute(call.slug, { + arguments: args, + userId: call.userId, + version: call.version, + /* + * SPREAD RATHER THAN PASSED AS `undefined`, which is the same care every other optional + * on this wire gets here — see the cursor on the listings above. What the vendor is + * handed for "any account of theirs" is a body with no such key, not a key holding + * nothing, so a client that distinguishes the two cannot read an absent pin as a + * request for an account called undefined. + */ + ...(call.connectedAccountId === undefined + ? {} + : { connectedAccountId: call.connectedAccountId }), + }), + ); + }, + }; + return actions; +} diff --git a/server/src/plugins/composio-client/consent.ts b/server/src/plugins/composio-client/consent.ts new file mode 100644 index 000000000..eba5bb582 --- /dev/null +++ b/server/src/plugins/composio-client/consent.ts @@ -0,0 +1,582 @@ +import { BrokerRefusalError, type ComposioBroker } from "../broker"; +import { + askVendor, + everyRefusal, + named, + sent, + statusOf, + textOf, + VENDOR_SHAPE_REMEDY, + whose, + withdrawableAccounts, + withdrawalDeclined, +} from "../composio-vendor"; +import type { ClientContext } from "./context"; +import { CONFIG_SUFFIX, CONNECTED, REVOCABLE, STATUSES_NAMED } from "./model"; + +/** Sending a person to consent, asking whether they are connected, and revoking. */ +export function consentMethods(context: ClientContext) { + const { vendor, accountsFor, configsFor, askForEach } = context; + return { + async authorize({ + account, + toolkit, + returnUrl, + }): Promise<{ redirectUrl: string }> { + const userId = account.vendorUserId; + /* + * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. + * + * `ensureAuthConfig` creates it when an administrator enables the app, which is what makes + * this a read. Creating one here instead would mint it at the moment somebody presses + * Connect, unnamed for this deployment and invisible in the dashboard until the first person + * happened to try — and where a config already existed for an app enabled twice, a second + * one would split one app's connections across two configs, so removing "the" config later + * would drop half of them. + * + * NONE IS A STATE WITH A REMEDY, NOT A NULL TO WORK AROUND. It is the app enabled before + * this deployment created configs at all, or a config deleted by hand in Composio's + * dashboard. Neither is something a person pressing Connect can fix, so the sentence names + * the app and the administrator's step rather than leaving them at a link that would attach + * their account to a configuration nobody here chose. + * + * AND "NONE" MEANS NONE OF OURS, which is the correction. The read used to take whichever row + * the vendor returned first, so an app whose only config was one an operator built by hand + * read as ready and this minted somebody's connection against it — scopes this deployment + * cannot see, tool restrictions it cannot read, and an object it must not delete. A + * connection is a lasting attachment to whatever config it was made against, so guessing here + * is not a guess that can be corrected later. + */ + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND THE REMEDIES ARE + * DIFFERENT PEOPLE'S. The sentence below sends an administrator to remove the app and add + * it again, which is right where the listing was legible and said there is none of ours — + * and wrong here, because the row that could not be sorted may BE ours, in which case + * removing the app meets {@link ComposioBroker.deleteAuthConfig}'s own refusal and adding + * it again is refused by `ensureAuthConfig` for the same reason. Nobody should be sent + * round a loop that cannot close. + * + * WHAT IS NOT DIFFERENT IS THAT NOTHING IS MINTED. A link is a lasting attachment to one + * particular config, so a person is never sent anywhere on the strength of a listing this + * deployment could not read — see {@link readableConfigs}, where a config of OURS that was + * read is enough to go on whatever else the listing held. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + throw new BrokerRefusalError( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + /* + * A DISABLED CONFIG IS NOT A CONFIG TO CONNECT AGAINST, and it is now visible enough to say + * so. The listing asks for disabled configs — it has to, or the creation above duplicates one + * — which means this is the first read that can meet one. A link minted against it does not + * work, so sending a person to the vendor would spend their consent and end with nothing + * attached; and nothing they can do from the page they are on changes it, because enabling a + * config happens in Composio's dashboard. + * + * THE FIRST OF SEVERAL, WHICH IS A CHOICE AND NOT AN ACCIDENT. More than one enabled config + * of ours means a lost enable race, and both are equally ours and equally valid. What + * mattered about the old "first row" was that the order was the vendor's and the next caller + * could get a different one; the listing is sorted on the id, so this is the same config for + * every person and for the removal that later drops all of them. + */ + const config = ours.find((held) => statusOf(held) === "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. + */ + /* + * `unsettled` RATHER THAN `unreadable`, WHICH IS A RENAME AND ALSO THE THIRD CLAUSE BELOW. + * + * This list was called `unreadable` and SHADOWED the one `configsFor` answers — the refusals + * for rows it could sort into neither pile — for the whole of this block. The two are not the + * same set and they are not about the same rows: this one holds configs of OURS whose status + * is a word neither ENABLED nor DISABLED, and the outer one holds rows whose id or name never + * arrived at all. So the outer set was unreachable from the only place its clause could have + * been written, and the clause is missing from this refusal while both siblings that collect + * the same list carry theirs. The identical block in + * {@link ComposioBroker.connectWithFields} already names its local `unsettled` for exactly + * this reason, and is the copy this one is now spelled like. + */ + const unsettled = ours.filter((held) => statusOf(held) !== "DISABLED"); + /* + * AND THE TWO STATES ARRIVE TOGETHER, SO NEITHER ONE TAKES THE OTHER'S TURN. These were a + * chain — report the unsettled rows, otherwise report the disabled ones — and the paragraph + * above says in its own words why that cannot hold: `ours` is a SET, and more than one + * config of ours is the ordinary outcome of a lost enable race that this method is written + * around. One DISABLED config beside one carrying a word this deployment's `@composio/core` + * has never heard of satisfies both conditions, and the chain answered with the unsettled + * sentence alone — whose only remedy is a package upgrade, which is nobody's act on the page + * the reader is standing on. The act that would actually have got them connected is enabling + * the disabled config in Composio's dashboard, and it was withheld because a different row + * said something unreadable. + * + * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES, in the shape + * {@link ComposioBroker.deleteAuthConfig} and {@link ComposioBroker.revoke} both use for the + * same reason. The refusal itself does not move: nothing here mints a link against a config + * it cannot show is enabled, whichever of the two is true. + */ + const left: string[] = []; + const disabled = ours.length - unsettled.length; + if (disabled > 0) { + left.push( + `Composio calls ${disabled} of this deployment's ${ours.length} authorization configs for ${toolkit} disabled, and an administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + if (unsettled.length > 0) { + /* + * EVERY STATUS THAT WAS ACTUALLY READ, AND NONE OF THEM SPEAKING FOR THE REST. The + * sentence counted the whole set and quoted `unsettled[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(unsettled.map((held) => named(held.status))), + ]; + /* + * AND THE TAIL COUNTS IN THE NUMBER'S OWN WORDS. "and 1 other words" was the unpluralised + * spelling, in a sentence whose very next clause conjugates its own verb on the same + * count — so the one refusal that has to be trusted about an authorization config could + * not get its own arithmetic to read. See {@link STATUSES_NAMED} for why there is a tail + * at all. + */ + const shown = words.slice(0, STATUSES_NAMED); + const unnamed = words.length - shown.length; + const said = + unnamed > 0 + ? `${shown.join(", ")} and ${unnamed} other word${unnamed === 1 ? "" : "s"}` + : shown.join(", "); + left.push( + `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 a connection begun against one could complete is not something this deployment can tell. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* + * AND THE ROWS NOTHING COULD SORT ARE THE THIRD CLAUSE, WHICH THE SHADOW HAD TAKEN AWAY. + * + * A row whose id or name never arrived may BE a config of this deployment's that Composio + * calls ENABLED, in which case "no config here could be shown to be enabled" is true of what + * was read and the remedy above it — go and enable the disabled one — is an act against the + * wrong object, or against nothing at all. It is a third fact about a third set of rows, so + * it is a third independent clause rather than a chain, for the reason the paragraph above + * gives: two facts about two different rows cannot take turns. + */ + if (unreadable.length > 0) { + left.push( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of THOSE is a config of this deployment's that Composio calls enabled is outside what either reading settles: reading those rows in Composio's own dashboard is what says whether there is one here to connect against at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `No authorization config this deployment holds at Composio for ${toolkit} could be shown to be enabled, so no link was made: consent spent against a config that turns out to be disabled attaches nothing and cannot be spent again without sending ${whose(account)} round the loop a second time. ${left.join(" ")}`, + unreadable.length > 0 + ? { cause: everyRefusal(unreadable) } + : undefined, + ); + } + + /* + * THE RETURN ADDRESS IS THE WHOLE POINT OF THIS CALL, and it is the caller's rather than + * this file's: the adapter knows Composio, and where a person belongs afterwards is a fact + * about this deployment's own pages. It is never logged and never quoted in the refusal + * below, for the reason the url itself is not. + * + * NO `allowMultiple`, WHICH LEAVES THE VENDOR ENFORCING THE RULE THIS DEPLOYMENT ALREADY + * STATES. One person holds one account per app here, because the call that runs an action + * names the person and not the account — so with two accounts attached, which mailbox a Bot + * reads would be Composio's choice and nothing here could say which one it had been. The + * route refuses a second connection before it ever reaches this method, and that refusal is + * the sentence a person reads; this is the same rule one layer further down, where the + * vendor is the only party that can still see an account this deployment's rows have lost + * track of. `toolkits.authorize` passed `allowMultiple: true` unconditionally — the SDK + * calls it a "magic function" for exactly that — which is the opposite of what this + * deployment wants. + * + * THE ANSWER IS AN OBJECT BY CONSTRUCTION AND ITS ONE FIELD IS NOT. `link` builds what it + * returns with `createConnectionRequest(client, response.connected_account_id, INITIATED, + * response.redirect_url)` inside a try that turns anything thrown into + * `ComposioFailedToCreateConnectedAccountLink` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:420-453`), and that builder assembles a literal + * (`src/models/ConnectionRequest.ts:39-43`). So a refusal for "Composio answered something + * that is not an object" could not be reached — the vendor either hands over its own object + * or raises a class {@link vendorRefusal} already translates. What the builder copies across + * untouched is the url, which is why that is the field still read. + */ + const request = await askVendor( + { + outcome: `this person's connection to ${toolkit} was not begun`, + app: toolkit, + }, + () => + vendor.connectedAccounts.link(userId, config.id, { + callbackUrl: returnUrl, + }), + ); + + const redirectUrl = request.redirectUrl; + /* + * PRESENT AND NOT A URL IS THE SHAPE THE ABSENCE GUARD BELOW CANNOT SEE. `!redirectUrl` is + * false for an object, a number and a list alike, so each of those would be returned as the + * `redirectUrl: string` this method promises and put in a `Location` header — a page nobody + * can visit, handed to a person as the consent screen they were sent to. + */ + if ( + redirectUrl !== undefined && + redirectUrl !== null && + typeof redirectUrl !== "string" + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(redirectUrl)} where the page to send ${whose(account)} to for ${toolkit} belongs, so nobody was sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* + * AND THE URL IS THE ONE THAT WAS JUDGED, WHICH IT WAS NOT — the defect {@link textOf} is + * written against, on the one value in this file that leaves the process in a `Location` + * header. + * + * `!redirectUrl` is false for a string of blank space, so " " cleared this guard and the + * shape guard above it and was answered as the `redirectUrl: string` this method promises: a + * person who pressed Connect is redirected to nothing, having been told they were being sent + * to the app's own consent screen. And a url that arrived PADDED was handed back with its + * padding, which is not an address either — the space is percent-encoded or the redirect is + * refused outright. Every identifier on this path is read through {@link textOf} for exactly + * this reason; this was the field that was not. + * + * THE SHAPE GUARD STAYS AHEAD OF IT, because the two states have different sentences. An + * object or a number where a url belongs is a vendor change with a package remedy; no url at + * all is an ordinary fact about an auth scheme, and blank is that fact spelled the other way. + */ + const page = textOf(redirectUrl); + if (page === null) { + /* + * The SDK spells `redirectUrl` nullable because not every auth scheme has one — an API-key + * toolkit is connected by typing a secret, not by visiting a page. This deployment's + * enablement flow sends a person to a url, so no url is nothing to do rather than a + * success, and the sentence says which app it was about. The url itself is never quoted + * anywhere, here or elsewhere: whoever opens it attaches an account to this person's + * connection, so it is handed to the browser that asked and then forgotten. + */ + throw new BrokerRefusalError( + `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send ${whose(account)} to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, + ); + } + return { redirectUrl: page }; + }, + + async isConnected({ account, toolkit }): Promise { + const userId = account.vendorUserId; + /* + * A COUNT, AND NOTHING IS READ OFF A ROW TO REACH IT. An ACTIVE account Composio described + * without an id is still an ACTIVE account: this person can act through the app, which is the + * whole of what this gate asks. Reading the id here used to turn that into a refusal, so a + * field this question never looks at decided its answer. + * + * AND IT STOPS AT THE FIRST ROW, WHICH IS THE SAME CORRECTION ONE LEVEL OUT. This method + * answers a boolean, and a boolean settled by page one cannot be improved by page two — but + * reading on left the answer exposed to three faults that belong to pages nobody needed: a + * cursor Composio sent as a number, a cursor it repeated, and the fiftieth page of a listing + * that will not end. Each of those threw at a person whose ACTIVE account had already been + * found and told them the vendor's shape was wrong, and `store.ts` deletes their connection + * row on a `false` — so a question that had been answered `true` was made failable by the + * machinery that made the `false` complete. Paging is still what makes the `false` honest: + * with no row yet, the next page is the only thing that can settle it, so it is read. + * + * ASKED OF EVERY CONFIG, WHICH IS WHERE THIS DIVERGES FROM `revoke` BELOW. A person whose + * only account for the app sits on a config an operator built by hand can still act through + * the app — the call that runs an action names the person and the toolkit, not the account — + * so scoping this to configs of ours would refuse somebody who is, in fact, connected. This + * method only READS; the one that acts is the one that has to be narrow. + */ + return ( + ( + await accountsFor(userId, toolkit, CONNECTED, { + enough: (rows) => rows.length > 0, + }) + ).length > 0 + ); + }, + + async revoke({ account, toolkit }): Promise { + const userId = account.vendorUserId; + /* + * THE ANSWER IS WHAT WAS ASKED FOR, not whether the call threw. `false` here means there was + * nothing to withdraw, which is what the audit trail's `vendorRevocationRequested` is for: a + * reader has to be able to tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * ASKED FOR, RATHER THAN DONE, AND THE FIELD IS NAMED FOR THAT. The delete carries + * `revoke_on_delete`, which is what turns it from a record-keeping soft-delete into an actual + * withdrawal — and what it starts is a background job the vendor gives no supported way to + * poll. So the account is gone at the broker by the time this returns and nothing here can + * call with it again; whether Google has torn up the refresh token happens afterwards. `true` + * claims exactly that much. See {@link ComposioVendor} for the two declarations this rests on. + * + * EVERY ACCOUNT, not the first, and in every state that could still be a grant. One person + * can hold more than one account for one app — two mailboxes, or a stale account beside a + * fresh one, or a shared account beside their own — and each of them is access this + * deployment's calls could run under. See {@link REVOCABLE} for why the listing here is wider + * than the one behind `isConnected`. + * + * AND EVERY ACCOUNT MEANS EVERY ACCOUNT ON A CONFIG THIS DEPLOYMENT MADE, WHICH IS NARROWER + * THAN WHAT THIS USED TO DELETE. The listing was asked by person, app and "all account + * types" and by nothing else, so it returned accounts attached to authorization configs an + * operator built by hand in Composio's dashboard — for purposes this deployment knows nothing + * about, on scopes it cannot see, and, with `accountType: "ALL"`, including the SHARED ones + * that other people are acting through. Every one of those was then deleted with + * `revoke_on_delete`, which tears the grant up at Google or Slack. One person pressing + * disconnect on their own settings page ended somebody else's integration. + * + * WHICH IS THE PRINCIPLE {@link ComposioBroker.deleteAuthConfig} ALREADY STATES, ARRIVING + * ONE LEVEL DOWN. That method refuses to delete a config it cannot show is this + * deployment's, on the reasoning that an operator's dashboard work is not ours to destroy — + * and the accounts hanging off that config are the same work. {@link madeHere} is the only + * thing that tells the two apart, so it decides both. + * + * NOTHING AT ALL IS NOTHING TO WITHDRAW, AND IT IS ANSWERED WITHOUT ASKING. An app Composio + * holds no configs for never had a connection begun through it — {@link + * ComposioBroker.authorize} mints every link against a config of this deployment's and + * refuses where there is none — so there is nothing here that this deployment granted. + * Answering before the listing is also what keeps the empty filter off the wire; see + * `authConfigIds` on {@link ComposioVendor} for why an empty one must never be sent. + * + * BUT "NOTHING OF OURS" IS NOT THAT STATE, AND ANSWERING `false` TO IT WAS THE SEVENTH ROUTE + * TO A REVOCATION THAT DID NOT REVOKE. The reasoning above is sound about an app with no + * configs and cannot tell that app from this one: an operator renames a config in Composio's + * dashboard — drops the suffix, or edits the app's title past it — and this deployment's own + * live grants read as somebody else's work. `false` then means "there was nothing to + * withdraw", `store.ts` writes `vendorRevocationRequested: false` into the audit trail and + * deletes the `composio_connections` row, and the person's grant stands at Google with + * nothing in this deployment naming it. The trail records that no withdrawal was even asked + * for, which is the one direction nobody thinks to check. + * + * {@link ComposioBroker.deleteAuthConfig} REFUSES IN EXACTLY THIS STATE, and two halves of + * one operation cannot disagree about one condition. Its reading is the right one and this is + * the half that moves: the app's configs are legible, none of them carries the suffix, and + * nothing in the row says whether that is an operator's own work or ours under a title + * somebody edited. A refusal leaves the connection row standing, which is what keeps the + * person's grant findable, and names the same one act in a dashboard that the removal does. + * + * `false` STILL MEANS WHAT IT SAID, and now only where it is true: Composio holds nothing for + * this app, so this deployment granted nothing through it. + */ + const { held, ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0 && held.length > 0) { + /* + * THE UNREADABLE ROWS TRAVEL WITH THIS SENTENCE RATHER THAN WAITING FOR A PRESS THAT NEVER + * GETS TO THEM. The refusal below is written for `ours.length === 0 && unreadable.length > + * 0`, and as a second consecutive throw it is reachable only where `held.length === 0` — + * so a listing carrying one renamed config AND one row nothing could sort reported the + * rename alone, and closed by telling this person that renaming it "lets disconnecting + * again withdraw them". It does not: the account listing is scoped to the configs this + * method can name, and a row in the other pile is one it never was. The same clause is + * appended independently to the partial-withdrawal sentence further down, which is the + * shape this should always have had. + */ + const alsoUnreadable = + unreadable.length > 0 + ? ` Composio also described ${unreadable.length} of its ${toolkit} authorization configs in a way this deployment cannot read, so any grant of theirs on one of those is outside the question either reading settles, and renaming the config above is not on its own enough to end everything they hold: reading those rows in Composio's own dashboard is what says whether anything is left.` + : ""; + 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 ${whose(account)}'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, ${whose(account)}'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.${alsoUnreadable}`, + unreadable.length > 0 + ? { cause: everyRefusal(unreadable) } + : undefined, + ); + } + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS — the same distinction one + * field further in. A config whose id or name Composio sent unreadably cannot be put in the + * account listing's filter, so any grant of this person's sitting on it is invisible to + * everything below and a `false` would be the same false trail entry as the rename above. + * Where there IS something of ours it is withdrawn first and this is reported afterwards, for + * the reason {@link readableConfigs} gives; here there is nothing to withdraw first. + */ + if (ours.length === 0 && unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so whether ${whose(account)} holds a grant on one of this deployment's own could not be told and nothing was withdrawn. Their access has not been shown to end. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + if (ours.length === 0) return false; + const accounts = await accountsFor(userId, toolkit, REVOCABLE, { + configs: ours.map((config) => config.id), + }); + /* + * THE READABLE ONES GO FIRST AND THE UNREADABLE ONES ARE REPORTED AFTERWARDS. Reading the ids + * of all of them before sending any delete is what made one unnameable row a permanent block + * on a person's disconnect — see {@link withdrawableAccounts}. Partitioning puts the withdrawal + * back in front of the report, which is the order a person's grants actually need. + */ + const { ids, nameless } = withdrawableAccounts(accounts, toolkit); + + /* + * AND NOTHING TO WITHDRAW IS ITS OWN STATE, WHICH IS THE BRANCH EVERY SIBLING OF THIS METHOD + * HAS AND THIS ONE DID NOT. + * + * A config of ours that IS readable, no account of this person's on it, and a row nothing + * could sort beside it: the partial-withdrawal throw below was what answered, and what it + * said was "Composio withdrew 0 of this person's 0 accounts for gmail". Both figures are + * counts of a set nothing measured, in the one sentence a reader is meant to act on, and + * "withdrew" asserts that a withdrawal happened. None did, and none was there to happen. + * {@link ComposioBroker.deleteAuthConfig} was corrected for the identical sentence about + * configs — "removed 0 of this deployment's 0" — and this is that count one listing further + * in, reached from the other end. + * + * STILL A REFUSAL, for the reason that method's branch is one. A grant of this person's may + * sit on the config behind the row nothing could sort, and the account listing was never + * scoped to it — so `store.ts` must not delete the row naming which app they connected on the + * strength of a question nothing asked. + * + * THE OTHER HALF OF "NOTHING TO WITHDRAW" IS NOT A REFUSAL AND IS ANSWERED BELOW. Every row + * legible, none of this person's accounts on any of them, is the ordinary state of a person + * who is not connected — `false`, which is what the ending of this method already says. + */ + if (ids.length === 0 && nameless.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 ${whose(account)} holds no account on any of the ones it could read, so nothing was withdrawn and their access has not been shown to end. Whether they hold a grant on one of THOSE is outside what either reading settles and disconnecting again meets the same answer: reading those rows in Composio's own dashboard is what says whether anything is left. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + + /* + * WHAT CAME BACK IS READ, WHICH IS THE HALF THAT USED TO BE MISSING. Not throwing is not the + * same as having been done: Composio answers a delete with a `success` saying whether it + * performed one, and a `false` there means the account is still attached and no revocation + * job was started. It is counted as a refusal — into the same list a thrown vendor error + * lands in — so that the sentence below reports it as an account that was not withdrawn, + * which is exactly what it is. See {@link withdrawalDeclined}. + * + * INSIDE THE LOOP AND OUTSIDE {@link askVendor}, deliberately. `askVendor` wraps the + * `await vendor.*` and nothing else, because everything it translates is a fault raised + * inside the vendor's package; the refusal below is this file's own reading of a reply that + * arrived intact, and it already carries an authored sentence. + */ + const refused = await askForEach(ids, async (id) => { + const answer = await askVendor( + { + outcome: `one of this person's ${toolkit} accounts was not withdrawn`, + app: toolkit, + }, + () => vendor.connectedAccounts.delete(id, { revoke_on_delete: true }), + ); + const declined = withdrawalDeclined(answer, toolkit); + if (declined !== null) throw declined; + }); + + if (refused.length > 0 || nameless.length > 0 || unreadable.length > 0) { + /* + * A PARTIAL WITHDRAWAL IS A FAILURE AND NOT A `true`, and the reason is the row this throw + * protects. `store.ts` revokes and only then deletes the `composio_connections` row, which + * is the only thing in this deployment that names which app this person connected. Answer + * `true` here on a partial and that row is deleted, the trail records a disconnection, and + * the account this call could not end is left live with nothing pointing at it — the exact + * state the store's revoke-before-delete order exists to make impossible. Throwing leaves + * the row standing, so pressing disconnect again is a second attempt with everything the + * first one had, and the accounts already gone are no longer in the listing, so the retry + * converges rather than repeating. + * + * WHICH IS ALSO WHY IT IS NOT A `true` WITH A GRUMBLE. Nothing was disconnected in the + * sense the person asked about: their app still answers. The count is in the sentence + * because "some of your accounts were withdrawn" is the one thing a reader cannot work out + * for themselves, and EVERY refusal the loop met is kept as `cause` for whoever is reading + * a log rather than a page — see {@link everyRefusal} for why all of them rather than the + * first, which is what this used to keep. + * + * AND THE TWO WAYS A GRANT SURVIVES ARE NOT THE SAME ADVICE. An account Composio refused is + * one a second press reaches, which is what "disconnecting again" is worth saying about. An + * account it described with no id is not: the row will be as unnameable next time, so the + * only honest instruction is the one that does not run through this page at all. + * + * WHICH IS WHY THEY ARE TWO INDEPENDENT CLAUSES AND NOT A CHAIN. They were an `if`/`else + * if`, so one person holding both kinds — a nameless row beside a refused one, which is one + * listing away from either on its own — was told only that "disconnecting again meets them + * unchanged". That sentence is true of the row with no id and false of the refused account + * sitting next to it, and it is the sentence deciding whether they press the button again: + * the one remedy that would actually have ended the refused grant was withheld by the + * presence of a row it says nothing about. Two facts about two different accounts cannot + * take turns. {@link ComposioBroker.deleteAuthConfig} writes the identical pair as two + * independent `if`s one method up, and the paragraph above already described these as two + * remedies — the chain was the only thing disagreeing. + * + * THE REACHABLE REMEDY GOES FIRST, matching that method's order, because it is the one the + * reader can act on from the page they are standing on. + * + * AND THE DENOMINATOR IS THE ACCOUNTS, NOT THE ROWS. It was `accounts.length`, which is how + * many rows the listing handed over, and a listing that named one account twice is a + * listing with more rows than accounts — see {@link withdrawableAccounts}. "Withdrew 1 of + * this person's 2 accounts" over one account that is gone is a count nothing measured, in + * the sentence a reader is meant to act on. The two halves this call actually holds are the + * accounts it could name and the ones it could not, and their sum is the set. + */ + const accountsHeld = ids.length + nameless.length; + const left: string[] = []; + if (refused.length > 0) { + left.push( + "Disconnecting again asks only for the accounts that are left.", + ); + } + 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.`, + ); + } + /* + * 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 ${whose(account)}'s ${accountsHeld} accounts for ${toolkit} and their access to it has not been shown to end. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...nameless, ...unreadable]) }, + ); + } + + /* + * THE ACCOUNTS WITHDRAWN, WHICH IS THE SAME NUMBER BY A HONESTER ROUTE. Reaching this line + * means nothing refused and nothing was nameless, so the rows and the accounts differ only + * where the listing repeated one — and `true` is a claim about having asked the vendor to + * withdraw something, which is exactly what `ids` counts. + */ + return ids.length > 0; + }, + } satisfies Pick; +} diff --git a/server/src/plugins/composio-client/context.ts b/server/src/plugins/composio-client/context.ts new file mode 100644 index 000000000..9953919a3 --- /dev/null +++ b/server/src/plugins/composio-client/context.ts @@ -0,0 +1,324 @@ +import type { BrokerApp, BrokerRefusalError } from "../broker"; +import { LISTING_LIMIT } from "../composio"; +import { + appOf, + askVendor, + type CheckedAuthConfig, + everyRowOf, + type Listing, + pageOf, + readableConfigs, + type VendorAccountStatus, + type VendorToolkit, +} from "../composio-vendor"; +import { type ComposioVendor, madeHere } from "./model"; + +/** + * What the Composio client's methods share: the vendor it calls, the clock, and the listings every + * group asks through. Made once per client by `buildComposioClient`. + */ +export function createClientContext( + vendor: ComposioVendor, + now: () => number = Date.now, +) { + /** + * This person's accounts for this app, in whichever states the ASKING question is about. + * + * ONE LISTING WITH THE STATUSES AS ITS ARGUMENT, rather than one listing both callers share. + * They shared one until it turned out that the shared answer was wrong for one of them: see + * {@link CONNECTED} and {@link REVOCABLE} for why "is this person connected" and "what is there + * to revoke" are different questions. What they do share is everything a drift between them + * would come from — the breadth of `accountType`, the limit, and the fact that both ask about one + * person and one app — so the difference between them is exactly the list of statuses and is + * visible at both call sites. + * + * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the + * statuses are. `configs` is which authorization configs the answer is about — every one of them + * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on + * what it finds. `enough` is when the rows in hand already settle the question, which is true of + * a boolean the moment one row arrives and never true of a set of accounts to end. Both are + * written at the call sites below, beside the statuses, so that the whole of the difference + * between the two questions is one argument list a reader can compare. + */ + const accountsFor = async ( + userId: string, + toolkit: string, + statuses: VendorAccountStatus[], + asked: { + configs?: string[]; + enough?: (rows: { id?: unknown; data?: unknown }[]) => boolean; + } = {}, + ): Promise<{ id?: unknown; data?: unknown }[]> => { + /* + * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that + * the two questions cannot drift on the one thing they do share. A truncated listing is the + * wrong answer to both of them for the same reason: `false` claims somebody has no account for + * an app when nobody looked at all of them, and a withdrawal that saw one page ends fewer + * grants than it reports. What a caller may say is that it has ENOUGH — see {@link everyRowOf} + * — which is not truncation: it is a question that has been answered. + * + * THE ROWS GO BACK AS ROWS, WHICH IS NARROWER THAN WHAT THIS USED TO HAND OVER. It read every + * id here, and the two callers do not want the same thing: `isConnected` is a COUNT — the id is + * nothing it reads — so taking ids on its behalf turned an account Composio described without + * one into a thrown refusal against a person who is, in fact, connected. Reading the ids is the + * withdrawal's business, and it is done there, where a row that cannot be named is something to + * report alongside the grants that were ended rather than something to stop them. + */ + const rows = await everyRowOf( + { + noun: `this person's ${toolkit} accounts`, + consequence: + "neither whether they are connected nor what there is to withdraw could be read", + }, + (cursor) => + askVendor( + { + outcome: `this person's ${toolkit} accounts were not read`, + app: toolkit, + }, + () => + vendor.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + /* + * COPIED, BECAUSE WHAT THE CALLERS PASS IN IS {@link CONNECTED} OR {@link REVOCABLE} + * ITSELF. Every other list in this body is built here — `[userId]`, `[toolkit]`, and + * `asked.configs`, which is a fresh `map` — and this one was the adapter's own + * module-level constant handed straight over the seam to the vendor's package. A + * recipient that sorts, de-duplicates or appends to the array it was given would not + * spoil one listing; it would rewrite the constant for the life of the process, after + * which `isConnected` — the gate `./access` asks before running somebody's action — + * and the withdrawal's own filter would both be asking a question nobody wrote down, + * in the next request rather than this one. It is the same reasoning {@link copyOf} + * applies to the catalogue rows, with no expiry to bound it. + */ + statuses: [...statuses], + accountType: "ALL", + // Spread for the reason the cursor is: an explicit `undefined` reaches the vendor's + // `parse` as a key, and "about every config" is said by not naming any. + ...(asked.configs === undefined + ? {} + : { authConfigIds: asked.configs }), + limit: LISTING_LIMIT, + ...(cursor === undefined ? {} : { cursor }), + }), + ), + asked.enough, + ); + return rows; + }; + + /** + * Every auth config Composio holds for one app, ours and anybody else's alike, in one order. + * + * THE LISTING IS SCOPED TO THE PROJECT AND NOT TO THIS DEPLOYMENT, which is the correction. An + * auth config is scoped to the project the API key belongs to — so nothing here is hidden from + * this listing, and that was read as "everything it returns is ours". It is not: an operator with + * the same project open in Composio's dashboard can create configs for the same app by hand, for + * purposes this deployment knows nothing about. {@link madeHere} is the only thing that tells the + * two apart, and every caller below is about an object one of them must not touch. + * + * THE UNCLAIMED ROWS ARE RETURNED RATHER THAN DROPPED HERE, which is the part that moved. The + * filter used to live on the way out, so "no configs at all" and "configs, none of them ours" + * reached every caller as the same empty array — and telling those two apart is the whole of what + * {@link ComposioBroker.deleteAuthConfig} was missing, and then of what + * {@link ComposioBroker.revoke} was missing a wave later, on the same distinction, one function + * away. The `ours` filter is still offered, beside rather than instead of the rest, because a + * caller that can only see its own configs is a caller that cannot notice the other two states. + * + * SORTED SO THAT TWO CALLERS AGREE. The vendor's order is not documented, and the whole failure + * being fixed here is two calls resolving different rows; a total order on the id makes the + * choice this file makes a stable one, whoever asks and whenever. + * + * BY CODE UNIT RATHER THAN BY `localeCompare`, WHICH IS THE WHOLE POINT OF THE SORT RATHER THAN A + * QUIBBLE WITH IT. `localeCompare` called with no locale collates in the HOST's, and the hosts + * are not one host: "ac_B" comes before "ac_a" by code unit and after it under an English + * collation, and the two callers this order exists to keep in step — a person pressing Connect + * and an administrator pressing Remove — need not be answered by the same process, the same + * container or the same build of ICU. An order that two machines can disagree about is not an + * order two callers agree on. `<` is the same total order everywhere, which is the only property + * asked of it here. + * + * THREE PARTS RATHER THAN A LIST, AND THAT IS A GUARD AGAINST THIS FILE'S OWN HISTORY. Each of + * the four callers below has to answer three different questions about one listing — what is + * ours, what is standing that is not, and what could not be read at all — and every defect this + * function has been corrected for was one caller answering one of them while its neighbour, one + * function away, answered it differently or not at all. A shape that hands over only `ours` + * lets a caller not notice the other two; this one cannot be destructured without saying so. + */ + const configsFor = async ( + toolkit: string, + ): Promise<{ + /** Every readable row, ours and anybody else's alike, deduplicated and in one order. */ + held: CheckedAuthConfig[]; + /** The subset of `held` carrying {@link CONFIG_SUFFIX}, which is what this deployment claims. */ + ours: CheckedAuthConfig[]; + /** One refusal per row that could be sorted into neither, which is never nothing. */ + unreadable: BrokerRefusalError[]; + }> => { + /* + * EVERY PAGE, BECAUSE A CONFIG ON THE SECOND ONE IS STILL OURS. Read one page and the two + * callers below are wrong in the two opposite directions {@link madeHere} describes: + * `ensureAuthConfig` finds none and creates the second config it exists to prevent, and + * `deleteAuthConfig` leaves one standing, reports a clean removal, and lets `removeServer` + * delete the app's row over the top of a live grant. + */ + const rows = await everyRowOf( + { + noun: `this deployment's authorization configs for ${toolkit}`, + consequence: + "whether one exists is not something this deployment can tell", + }, + (cursor) => + askVendor( + { + outcome: `this deployment's authorization configs for ${toolkit} were not read`, + app: toolkit, + }, + () => + vendor.authConfigs.list({ + toolkit, + limit: LISTING_LIMIT, + showDisabled: true, + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ); + /* + * CHECKED BEFORE THE FILTER AND NOT AFTER IT, which is the order the whole guard turns on. The + * filter's question IS the name, so a row checked only once it had been kept would be a row + * sorted by a field nobody had read — see {@link madeHere} for what each of the two guesses + * costs. Every row therefore passes {@link readableConfigs} first, including the ones that turn + * out to belong to an operator's own dashboard work. + */ + const { configs, unreadable } = readableConfigs(rows, toolkit); + const held = configs.sort((one, other) => + one.id < other.id ? -1 : one.id > other.id ? 1 : 0, + ); + return { held, ours: held.filter(madeHere), unreadable }; + }; + + /** + * Ask for every one of them and answer with what refused, rather than stopping at the first. + * + * A THROW MID-LOOP ABANDONS GRANTS THAT ARE STILL LIVE. Both callers below are deleting a set of + * things that each independently hold somebody's access, and an exception out of the second of + * five leaves three untouched and unmentioned — while the caller is told only about the one that + * failed, so nothing in the answer says the loop did not finish. Attempting all of them makes the + * failure a statement about a set: this many were asked for and this many refused. + * + * SERIALLY RATHER THAN TOGETHER, for the same reason every other call here goes out one at a + * time: the vendor rate-limits, and a person with several accounts is not a reason to open + * several connections. The order is the listing's, which is sorted. + * + * EVERY REFUSAL IS ANSWERED AND NOT ONLY THE FIRST. What the callers do with this list is throw a + * COUNT — "two of three were withdrawn and the rest refused" — because a count is what a reader + * can act on, which makes the reasons the only place the detail lives. Returning them all is what + * lets {@link everyRefusal} put all of them on the failure the caller raises; the previous version + * collected them and both callers then read `refused[0]`, so the second and third reason existed + * for the length of one expression and were then dropped. + * + * EVERY FAILURE IS A REFUSAL HERE, AND THE CLASSIFICATION THAT USED TO SIT IN THIS LOOP HAS MOVED + * ONE LAYER DOWN. There was an `isOurFault` test in the catch that re-threw a `TypeError`, a + * `ReferenceError` or a `RangeError` rather than counting it, on the premise that those are what + * a program's own mistake looks like and are never "a thing Composio can reply". The premise is + * false — see the `TypeError` row in {@link vendorRefusal} for the five vendor shapes that raise + * exactly that from inside `@composio/core`'s own transformers — so its effect was inverted: a + * vendor fault escaped the loop as a bug of ours, abandoning every account after it unasked, and + * the person was handed a crash instead of a sentence. + * + * IT MOVED RATHER THAN BEING RETUNED because this loop cannot make the distinction and + * {@link askVendor} can. Every `ask` below is one `await vendor.*` wrapped by that function, so + * whether a fault came from inside the vendor's code is a fact about the call stack there, where + * here it could only ever have been guessed at from an error class. + */ + const askForEach = async ( + items: T[], + ask: (item: T) => Promise, + ): Promise => { + const refused: unknown[] = []; + for (const item of items) { + try { + await ask(item); + } catch (error) { + refused.push(error); + } + } + return refused; + }; + + /** + * The catalogue as the vendor answers it, EVERY PAGE OF IT, mapped to the rows a person picks from. + * + * IT USED TO BE ONE PAGE AND A REFUSAL, AND THAT REFUSAL IS THE BUG THIS FUNCTION WAS FIXED FOR. + * It asked for {@link LISTING_LIMIT} rows, met exactly that many, and threw — because a full page + * and a truncated one are the same array and it believed no second request could tell them apart. + * Composio publishes more than {@link LISTING_LIMIT} toolkits, so the condition was true on every + * call and the app picker showed an operator nothing at all, with a sentence explaining why a + * partial directory would be worse. The reasoning was right and the premise was false: the raw + * client has a cursor for this listing and always did. See {@link ComposioVendor} and + * {@link everyRowOf}. + * + * SORTED BY USAGE, AND NO SEARCH TERM — see {@link ComposioVendor}, where both are argued now + * that neither is holding a finite page together. + * + * THROWN FROM INSIDE THE FETCH, WHICH IS WHAT KEEPS A FAILURE OUT OF THE CACHE. Every refusal + * below — a cursor that cannot be followed, a page that is not a page, a row that cannot be read + * — rejects the promise `listApps` holds, and `listApps` drops an entry whose request rejected. + * A fragment committed here would be a fragment served for ten minutes to BOTH callers: an + * administrator searching for an app past the cut is told nothing matched, and the enable route, + * which checks a slug against this same directory, tells them a real app is not one Composio + * lists. + */ + const fetchDirectory = async (): Promise => { + const listing: Listing = { + noun: "Composio's app catalogue", + consequence: "the directory was not shown", + }; + const toolkits = await everyRowOf(listing, (cursor) => + pageOf(listing, () => + askVendor( + { outcome: "the app catalogue was not read", app: null }, + () => + vendor.toolkits.list({ + limit: LISTING_LIMIT, + sort_by: "usage", + // Spread rather than an explicit undefined, for the reason `everyRowOf` gives: a + // `cursor: undefined` is a key on the wire, and "the first page" is said by omission. + ...(cursor === undefined ? {} : { cursor }), + }), + ), + ), + ); + + /* + * Each absence becomes the value that reads honestly on an administrator's screen, and each + * PRESENT field that is not what it is declared to be becomes a refusal — see {@link appOf}, + * where both halves of that and every sentence live. An empty description shows as no + * description; a null logo is the field's documented way of saying the vendor published none, + * which renders as a gap rather than as a broken image. + * + * The categories are the DISPLAY names rather than the slugs, because this list is read by a + * person choosing an app and "Productivity" is what they are choosing by. + * + * A MISSING COUNT BECOMES ZERO, WHICH IS THE ONE IMPERFECT ANSWER HERE. `actionCount` is a + * number and the shape offers no way to say "not published", so a toolkit that publishes no + * count reads as an app with no actions. It is the conservative direction — it understates + * the size of a change rather than overstating it — and Composio publishes a count for every + * toolkit measured, so this is a guard against the vendor rather than a routine case. A count + * that arrives as something other than a number is the different case and is refused. + */ + return toolkits.map(appOf); + }; + + return { + vendor, + now, + accountsFor, + configsFor, + askForEach, + fetchDirectory, + }; +} + +export type ClientContext = ReturnType; diff --git a/server/src/plugins/composio-client/directory.ts b/server/src/plugins/composio-client/directory.ts new file mode 100644 index 000000000..76ecb4bec --- /dev/null +++ b/server/src/plugins/composio-client/directory.ts @@ -0,0 +1,424 @@ +import { + type AuthConfigOutcome, + type BrokerApp, + BrokerRefusalError, + type ComposioBroker, +} from "../broker"; +import { + askVendor, + everyRefusal, + sent, + textOf, + VENDOR_SHAPE_REMEDY, +} from "../composio-vendor"; +import type { ClientContext } from "./context"; +import { CONFIG_SUFFIX, copyOf, DIRECTORY_TTL_MS } from "./model"; + +/** Composio's app directory, and the authorization configs this deployment keeps there. */ +export function directoryMethods(context: ClientContext) { + const { vendor, now, configsFor, askForEach, fetchDirectory } = context; + + /** + * 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; + return { + /** + * The catalogue, from memory where this process asked for it less than ten minutes ago. + * + * BOTH CALLERS READ THE SAME HELD ANSWER, AND THE SECOND OF THEM IS THE INTERESTING ONE. The + * search route filters the directory in this process, so caching it is what stops a debounced + * search field from pulling a few hundred rows once per term. The enable route then reads the + * directory again to check that the slug it was handed is one Composio lists, and that read + * comes out of the same cache — which is the right answer rather than a concession, because + * the slug being checked is one this deployment handed the browser out of THIS cache moments + * earlier. The check exists to refuse a slug the catalogue never published — a request composed + * by hand, or a row left over from a url somebody edited — and a ten-minute-old catalogue + * settles that question exactly as well as a fresh one. The case it gives up is an app Composio + * withdrew within the window, whose cost is one `mcp_servers` row for an app that answers + * nothing, removable on the page that added it; the case it buys is that pressing Add does not + * re-read a catalogue the picker just read. + * + * A FAILURE IS NEVER HELD. The entry is dropped when its request rejects, so a vendor that + * refused once is asked again by the next caller rather than refusing from memory for ten + * minutes — the failures here are an unset or wrong API key and Composio being down, and the + * first two are fixed by an operator who then presses the button again, which must be allowed + * to work. The callers already sharing that one in-flight request do share its failure, which + * is the truth about their request: they asked while it was being answered. + * + * THERE IS NO INVALIDATION, AND THE DESIGN ASKED FOR ONE. It wanted the directory "refreshable + * by an explicit reload", and that is not built: the lifetime above is the whole of the + * freshness story. Nothing in this deployment reloads a catalogue today — no page, route or job + * has such a control — so the method would have no caller, and an invalidation API with no + * caller is an untested path that reads like a guarantee. The moment a reload button exists, + * this is where it attaches. + */ + async listApps(): Promise { + const held = heldDirectory; + if (held && now() - held.at < DIRECTORY_TTL_MS) return copyOf(held.apps); + + /* + * Stamped when the request goes out rather than when it comes back, so a slow catalogue is + * held for slightly less than the full window rather than for the window plus its own + * latency. Written into the slot before it is awaited, which is what a concurrent caller + * finds. + */ + const entry = { at: now(), apps: fetchDirectory() }; + heldDirectory = entry; + /* + * The drop on failure, registered here rather than written as a try/catch around an await so + * that this method hands every caller the one shared promise. `heldDirectory === entry` + * because a later request may already have replaced this one, and clearing that would throw + * away a good answer over an old failure. + */ + entry.apps.catch(() => { + if (heldDirectory === entry) heldDirectory = null; + }); + return copyOf(entry.apps); + }, + + async ensureAuthConfig({ + toolkit, + name, + connection, + }): Promise { + /* + * NOTHING AT ALL FOR AN APP THAT NEEDS NO AUTHENTICATION, AND THAT IS THE VENDOR'S RULE + * RATHER THAN A SHORTCUT. Composio refuses an auth config for such a toolkit — "Cannot + * create an auth config for toolkit hackernews because it does not require authentication. + * You can use its tools directly without creating a connected account." — so the listing + * below is not even worth making: there is nothing to find and nothing to create. + */ + if (connection.kind === "no-auth") return "not-needed"; + + /* + * AND A REFUSAL BEFORE ANY WRITE for the one kind this deployment cannot drive. The sentence + * is the derivation's own, which named the scheme and what it wants; a refusal here that + * invented a second sentence would drift from the one the picker filters on. + */ + if (connection.kind === "unsupported") { + throw new BrokerRefusalError( + `${toolkit} was not enabled: ${connection.reason}`, + ); + } + + /* + * IDEMPOTENT BY LOOKING FIRST, because a second config is not a duplicate — it is a split. + * A person's existing connection is created against one particular auth config, so creating + * another and connecting the next person to that leaves two populations of connections for + * one app, and removing "the" config later drops half of them. + * + * THE LOOK IS FOR ONE THIS DEPLOYMENT MADE, WHICH IS NARROWER THAN "ANY". It used to be any, + * and the two ways that was wrong pull in opposite directions. A disabled config of ours was + * invisible to the listing, so this created the very second config it exists to prevent — and + * then `authorize` refused, because it looked with the same blind listing and found the app + * had no config at all. A config an operator made by hand, meanwhile, satisfied the check and + * this created nothing, leaving every later decision here pointed at an object nobody here + * chose. Asking for our own answers both: the disabled one counts, and somebody else's does + * not. + * + * WHICH MEANS AN APP CAN END UP WITH TWO CONFIGS, ONE OF THEM SOMEBODY ELSE'S, and that is + * the intended outcome rather than a tolerated one. Adopting a hand-made config would have + * this deployment mint people's connections against scopes and tool restrictions it cannot + * see, and delete it when the app is removed. A config of our own, named, is the thing every + * decision in this file can actually reason about. + * + * This is a read followed by a write and therefore not atomic: two administrators pressing + * enable at the same instant can both find nothing and both create. Composio offers no + * create-if-absent, so the window is the vendor's rather than this deployment's, and the + * cost of losing that race is a spare config rather than a lost connection — spare rather + * than orphaned, because both carry the suffix and `deleteAuthConfig` takes every one of + * ours. + */ + const { ours, unreadable } = await configsFor(toolkit); + /* + * AND THE ANSWER SAYS THE CONFIG WAS REUSED RATHER THAN SAYING NOTHING, which is what stops + * the caller recording a scheme this branch did not establish. What stands here was created + * as whatever it was created as, possibly by an enable months ago against a catalogue answer + * the vendor has since changed — see {@link ComposioBroker.ensureAuthConfig}. + */ + if (ours.length > 0) return "standing"; + + /* + * AND A ROW THAT COULD NOT BE READ IS NOT A ROW THAT IS NOT OURS. This is the one of the four + * callers that must NOT act on what it can name and report the rest — see + * {@link readableConfigs} for why the other three do. What it would be acting on is a + * CREATION, and a config created beside a row that is in fact ours under a name Composio sent + * unreadably is the second config this whole method exists to prevent: two populations of + * connections for one app, and a removal later that drops half of them. Finding nothing of + * ours in a listing this deployment could not read is not the same as finding nothing. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of them is already its own is not something it can tell — and the app was not enabled, rather than a second config being created beside one that may already be there. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + + /* + * THE OUTCOME NO LONGER CLAIMS NOTHING WAS CREATED, BECAUSE THIS CALL CANNOT KNOW THAT. + * + * It said "no authorization config was created for gmail", and {@link vendorRefusal} reads + * every condition out through that clause — including the `TypeError` row, which is reached + * exactly when the vendor's own transformer could not read a reply it had already received. + * So the one condition most likely to mean the config EXISTS was the one telling an + * administrator it did not, and nothing in this deployment then named the object standing at + * Composio. What is true of every condition here is the half that is said now: the app is not + * enabled, and what is at Composio is not something this call can report. + */ + const configName = `${name} ${CONFIG_SUFFIX}`; + + /* + * WHICH TYPE OF CONFIG, AND THE MANAGED ONE IS RIGHT FOR EXACTLY ONE OF THE THREE KINDS THAT + * REACH HERE. It was sent for all of them, and only one of the failures announced itself: + * Composio has no OAuth client of its own for a self-registering app, so the managed path + * answers 404 and the app is simply unconnectable — which is what made Linear's MCP app + * impossible to attach. A key app's was quiet and worse: the config was accepted, and every + * person enabled onto it was then sent to a consent screen that had nothing to ask them for. + * + * AND NEITHER CUSTOM BRANCH CARRIES A CREDENTIAL, which is the point rather than an omission. + * A self-registering app needs none by definition — the vendor registers a client of its own + * at connect time. A key app needs none HERE because the key is one person's: it belongs to + * each connection made against this config, which is per-deployment, and a key written onto + * it would be one person's secret shared by everybody the app is enabled for. + */ + const options = + connection.kind === "consent" + ? ({ type: "use_composio_managed_auth", name: configName } as const) + : ({ + type: "use_custom_auth", + authScheme: + connection.kind === "self-registering" + ? ("DCR_OAUTH" as const) + : connection.authScheme, + name: configName, + /* + * THE EMPTY RECORD IS THE VENDOR'S PRICE FOR SENDING NOTHING — see the field's own + * comment on {@link ComposioVendor}. Omitting it is a `ValidationError` raised before + * any request, which reads as a refusal to enable rather than as the secret-free + * config this branch is for. + */ + credentials: {}, + } as const); + + const created = await askVendor( + { + outcome: `the app is not enabled, and whether an authorization config for ${toolkit} now stands at Composio is not something this deployment can tell`, + app: toolkit, + }, + () => vendor.authConfigs.create(toolkit, options), + ); + + /* + * THE REPLY IS READ, WHICH IS THE HALF THAT WAS MISSING. Composio names the config it just + * made, and the answer was awaited and dropped — so an answer carrying no id at all was a + * successful enable of an app whose config this deployment could not show existed. It is not + * an id anything here needs: the next listing finds the config by its name. It is the only + * evidence in the reply that the creation this method reports actually happened, and a method + * that returns nothing has no other way to have checked. + * + * AND THE REFUSAL SAYS WHAT IS PROBABLY TRUE RATHER THAN WHAT WOULD BE TIDY. The request went + * out and Composio replied to it, so a config very likely IS standing there — saying "none + * was created" would be the same lie the outcome above stopped telling. The remedy is the + * button they just pressed, because {@link ComposioBroker.ensureAuthConfig} is idempotent + * through the name: a second enable finds the suffix and adopts what is there. + */ + if (textOf(created?.id) === null) { + throw new BrokerRefusalError( + `Composio answered the creation of an authorization config for ${toolkit} with ${sent(created?.id)} where the new config's id belongs, so this deployment cannot show that the config it just asked for exists and the app is not enabled. The request went out and Composio replied to it, so one may well be standing there: enabling ${toolkit} again finds it rather than making a second, because a config whose name ends with ${CONFIG_SUFFIX} is one this deployment claims. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + // Made here, as `connection`'s scheme, which is what lets the caller record that scheme. + return "created"; + }, + + async deleteAuthConfig(toolkit): Promise { + /* + * EVERY CONFIG OF OURS, AND NOTHING THAT IS NOT OURS. + * + * This used to delete whichever row the vendor happened to return first, on the reasoning + * that {@link ComposioBroker.ensureAuthConfig} creates at most one, so a second one must be + * somebody's dashboard work and must be left alone. The reasoning was right and the code did + * the opposite of it: with no test of the name, "the first row" is as likely to BE the + * hand-made config — deleting it, and with it every account anybody had connected against it. + * Reading the name inverts that. Anything without the suffix is untouched whatever order it + * arrives in, and everything with it goes, which is also the only way the spare config from a + * lost enable race is ever cleaned up. + * + * QUIET WHERE THERE IS NOTHING OF OURS TO DELETE, because removing an app has to be able to + * happen twice. An app can be removed, re-enabled and removed again, two administrators can + * press the button together, and an app enabled before this deployment created configs at all + * has none to drop. In every one of those the end state is the one that was asked for, so a + * throw would report a failure while the caller got exactly what they wanted. + * + * AND QUIET USED TO MEAN QUIET OVER A CONFIG THAT WAS STILL STANDING, which is the failure + * being closed here. "Nothing of ours" and "nothing at all" are not the same state, and the + * listing could not tell a caller which one it was in: rename a config in Composio's + * dashboard — drop the suffix, or edit the app's title past it — and every decision in this + * file stops recognising the object it made. `ensureAuthConfig` would create a second beside + * it, `authorize` would refuse against it, and this returned normally, after which + * `removeServer` deleted the app's row. The config and every grant made against it outlive + * the removal with nothing in this deployment naming them, and an administrator is told the + * app was withdrawn. + */ + const { held, ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0 && held.length > 0) { + /* + * REPORTED, NOT DELETED AND NOT SWALLOWED, AND THE THIRD OPTION IS THE ONLY HONEST ONE. + * + * Deleting anyway is the worse half of the same guess {@link madeHere} exists to stop: a + * row with a readable name that does not carry the suffix is as likely to be an operator's + * own dashboard work — their scopes, their tool restrictions, and every account anybody + * connected against it — as it is to be ours under a new name. Nothing in the row tells + * them apart, which is why nothing here chooses. + * + * WHICH IS THE SAME REASONING AS THE UNREADABLE NAME IN {@link readableConfigs} AND NOT THE SAME + * CASE. There the ambiguity is about ADDRESSING: the field that decides ownership did not + * arrive, so no row can be sorted and the removal cannot begin. Here every name arrived and + * every row is legible; what is in doubt is whether this deployment's own config is among + * them under a title somebody edited. So the refusal is narrower than that one — it fires + * only where the removal found nothing it could claim, and stays quiet where a config of + * ours was found and dropped beside somebody else's, which is the contract + * {@link ComposioBroker.deleteAuthConfig} states. + * + * IT IS A BLOCK, AND THE BLOCK IS THE POINT. The app's row survives this throw — + * `removeServer` deletes it only after this returns — so the app stays on its Plugins page + * and stays removable, which is the one thing a silent success took away. The remedy is an + * operator's and it is one act in a dashboard: rename the config back so it ends with the + * suffix and remove the app again, which finishes the withdrawal, or satisfy yourself that + * it is your own and delete it there, which is the only way anything can tell this + * deployment that the config it made is genuinely gone. + * + * A COUNT AND THE SUFFIX, because together they are the whole of what an operator has to + * look at: how many objects are standing, and the exact string that would have claimed + * them. The names are not quoted — a config's title is an app name an administrator typed + * and this file's refusals quote no vendor field it does not have to. + * + * AND IT CLAIMS NOTHING ABOUT WHAT THIS DEPLOYMENT ONCE MADE. An app enabled before this + * deployment created configs at all never had one, which the quiet case above names, so a + * sentence opening "the config this deployment made" would be a guess in the one place a + * guess is what is being refused. What is said is only what was just read. + */ + /* + * AND AN UNREADABLE ROW IS ITS OWN CLAUSE HERE FOR THE REASON IT IS ONE BELOW, which is the + * half this refusal was missing. `readableConfigs` sorts EVERY row into one of two piles, so + * "legible and carrying nobody's suffix" and "not legible at all" are two facts about one + * listing and arrive together as readily as either arrives alone. Written as a throw that + * names only the first, the sentence closed by promising that taking the unclaimed config + * out of the dashboard leaves this app with nothing standing "after which the removal goes + * through" — and with a row in the other pile that is false: the next press meets the + * refusal at the end of this method, raised over exactly those rows. An operator sent to do + * one act and told it finishes the job is the one thing a refusal must not do, and the + * clause that would have said otherwise was suppressed by a branch about different rows. + */ + const alsoUnreadable = + unreadable.length > 0 + ? ` Composio also described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so one of those may be this deployment's own under an answer it could not read: until those are read in Composio's own dashboard too, neither reading settles what is standing and the removal does not go through on the strength of the one above alone.` + : ""; + 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.${alsoUnreadable}`, + unreadable.length > 0 + ? { cause: everyRefusal(unreadable) } + : undefined, + ); + } + + /* + * AND A LISTING WHOSE ONLY ROWS ARE UNREADABLE IS ITS OWN STATE, WHICH IS THE BRANCH THE + * OTHER THREE CALLERS HAVE AND THIS ONE DID NOT. + * + * `readableConfigs` sorts every row into one of two piles, so "nothing legible at all" is a + * listing where `held` is empty and `unreadable` is not — which the branch above cannot reach, + * because it requires `held.length > 0`. What was left to answer it was the partial-withdrawal + * throw at the end of this method, reached with an empty `ours`: "Composio removed 0 of this + * deployment's 0 authorization configs for linear and the app has not been fully withdrawn." + * Both figures are counts of a set nothing read, and "fully" asserts that some of it was + * withdrawn. Nothing was. The only finding is the rows, and their remedy is a reading in + * Composio's dashboard rather than the button that was just pressed — it will be exactly as + * unreadable next time. + * + * STILL A REFUSAL, WHICH IS THE HALF THAT DOES NOT MOVE. `removeServer` deletes the app's row + * only after this returns, so the app stays on its Plugins page and stays removable; a quiet + * return here would file the app away over rows that may be this deployment's own configs, + * holding live grants, under an answer it could not read. + */ + 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 any of them is this deployment's own is not something it can tell and nothing was deleted. 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: reading those rows in Composio's own dashboard is what says whether anything this deployment made is still standing. ${VENDOR_SHAPE_REMEDY}`, + { cause: everyRefusal(unreadable) }, + ); + } + + /* + * THE REPLY IS AWAITED AND DROPPED, AND THAT IS NOT THE ASKED-VS-DONE GAP IT LOOKS LIKE. + * `AuthConfigDeleteResponse` has no `success` field to read — see the declaration on + * {@link ComposioVendor} for the citation and for what its one optional field means. A throw + * is the whole of what this call can report, which is why `askForEach` collecting throws is + * the whole of what is checked. + */ + const refused = await askForEach(ours, (config) => + askVendor( + { + outcome: `one of this deployment's authorization configs for ${toolkit} was not removed`, + app: toolkit, + }, + () => + vendor.authConfigs.delete(config.id, { revoke_on_delete: true }), + ), + ); + if (refused.length > 0 || unreadable.length > 0) { + /* + * LOUD, because the caller is `removeServer` and the thing it is in the middle of is taking + * an app away from everybody. A config left standing is a live grant that the removal was + * supposed to end, and the app's row is deleted after this returns — so a swallowed failure + * here is the one state nothing in this deployment can find again. The count is the whole + * message: an operator who can see that one of two configs went knows that pressing remove + * again finishes the job rather than repeating it. Every refusal the loop met travels as + * `cause` — see {@link everyRefusal} — because the count is deliberately all the sentence + * says, which leaves the reasons nowhere else to live. + * + * AND AN UNREADABLE ROW LANDS HERE RATHER THAN AHEAD OF THE DELETES, which is the half that + * moved. Every config of ours is dropped first and the rows that could not be sorted are + * reported after — {@link readableConfigs} says why at length: refusing before the first + * delete meant one unreadable row made an app permanently unremovable, with the readable + * configs of ours standing the whole time. It is still a refusal, so `removeServer` does + * not delete the app's row and the app stays on its Plugins page. + * + * TWO CLAUSES BECAUSE THEY ARE TWO REMEDIES. A config Composio refused is one a second press + * reaches. A row it described with no id or no name is not — it will be exactly as + * unreadable next time — so the only instruction that helps names the dashboard rather than + * the button the operator just pressed. + */ + const removed = ours.length - refused.length; + const left: string[] = []; + if (refused.length > 0) { + left.push("Removing it again asks only for what is left."); + } + if (unreadable.length > 0) { + left.push( + `Composio described ${unreadable.length} more of its ${toolkit} authorization configs with no id or no name, so nothing here can tell whether one of those is this deployment's own under an answer it could not read: reading them in Composio's own dashboard is what says whether anything this deployment made is still standing.`, + ); + } + throw new BrokerRefusalError( + `Composio removed ${removed} of this deployment's ${ours.length} authorization configs for ${toolkit} and the app has not been fully withdrawn. ${left.join(" ")}`, + { cause: everyRefusal([...refused, ...unreadable]) }, + ); + } + }, + } satisfies Pick< + ComposioBroker, + "listApps" | "ensureAuthConfig" | "deleteAuthConfig" + >; +} diff --git a/server/src/plugins/composio-client/fields.ts b/server/src/plugins/composio-client/fields.ts new file mode 100644 index 000000000..befa0d621 --- /dev/null +++ b/server/src/plugins/composio-client/fields.ts @@ -0,0 +1,665 @@ +import { + type BrokerField, + BrokerRefusalError, + type ComposioBroker, + flagOf, +} from "../broker"; +import { vendorSentence } from "../composio"; +import { + askVendor, + named, + sent, + statusOf, + textOf, + unreadableForm, + VENDOR_SHAPE_REMEDY, +} from "../composio-vendor"; +import type { ClientContext } from "./context"; +import { STATUSES_NAMED } from "./model"; + +/** Apps connected by fields a person types in rather than by a redirect. */ +export function fieldMethods(context: ClientContext) { + const { vendor, configsFor } = context; + return { + /** + * What the app itself says it wants typed in, mapped onto the boxes a form can draw. + * + * READ A STEP AT A TIME OFF `unknown`, for the reason every other vendor read here is: the + * detail's `auth_config_details` is copied across verbatim, so a mode that is not a list and a + * field row that is not an object are both shapes the wire can send. + * + * AND EVERY ONE OF THOSE STEPS REFUSES RATHER THAN ANSWERING `[]`, WHICH IS WHAT + * {@link unreadableForm} IS FOR AND WHY IT IS ONE SENTENCE FOR ALL OF THEM. `[]` here says one + * thing and one thing only: this app's scheme publishes nothing to fill in, which is a true + * thing about a real app. Every step of this read used to answer it for a shape it could not + * make sense of as well — a `fields` that is not an object, an initiation block that arrived as + * the rows that used to sit inside it, a `required` that stopped being a list — so an app that + * asks for nothing and an answer nothing here can open were the same empty form in the browser, + * and the argument two paragraphs down for why that form must never be drawn applied to both. + * + * AND A SCHEME THIS APP NO LONGER PUBLISHES IS A THIRD ANSWER AGAIN, WITH A REMEDY OF ITS OWN. + * The scheme is the RECORDED one and is never re-derived — that is the whole point of the + * column, and the store's own column comment 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 — an administrator's remedy, + * because the recorded scheme is only theirs to rewrite — while a mode that exists and + * publishes nothing visible still answers `[]`, and a mode whose published shape cannot be read + * refuses with the upgrade that is the only thing which fixes a vendor changing shape. + * + * 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), + ); + + /* + * THE ANSWER IS AN OBJECT BEFORE ANYTHING IS READ OFF IT, and the guard is not ceremony. + * `("perplexityai").auth_config_details` is `undefined` rather than a throw, so a retrieve + * that answered a bare string reached the refusal below wearing the sentence about a mode the + * app has stopped publishing — about an answer that had no app in it at all. + */ + if ( + typeof detail !== "object" || + detail === null || + Array.isArray(detail) + ) { + throw unreadableForm( + detail, + `Composio's own description of ${toolkit}`, + ); + } + + /* + * AND THE LIST OF MODES IS THE SAME TWO ANSWERS ONE FIELD IN. An `auth_config_details` that + * is not a list became no modes at all, and no modes means the recorded one is not among + * them — so an answer this deployment could not read arrived as the drift the refusal below + * is written about, with a remedy that cannot work on it. + */ + const listed = detail.auth_config_details ?? []; + if (!Array.isArray(listed)) { + throw unreadableForm( + detail.auth_config_details, + `the connection modes ${toolkit} publishes`, + ); + } + + /* + * AND A MODE THAT IS NOT A DOCUMENT IS THE SAME TWO ANSWERS ONE STEP FURTHER IN. `("API_KEY") + * .mode` is `undefined` rather than a throw, so an entry that stopped being an object compared + * unequal to the recorded scheme exactly as an app that had dropped it would, and the refusal + * below sent an administrator to remove the app and add it again — against a list whose next + * reading is the same unreadable shape, so the re-add records the same word and the app comes + * back refusing identically. + * + * AN ENTRY THAT IS ABSENT IS NOT THAT AND IS NOT REFUSED: a hole in the list is a mode the + * vendor left out, which is simply not the mode being looked for, and the sentence below is + * the true one about it. + * + * AND THE UNREADABLE ONES ARE COLLECTED RATHER THAN THROWN, WHICH IS THE PARTITION-RATHER- + * THAN-THROW DISCIPLINE THIS FILE ALREADY APPLIES AT {@link readableConfigs} AND + * {@link withdrawableAccounts}, ARRIVING AT THE ONE READER THAT WAS STILL EAGER. This was a + * `.map()` that threw, so EVERY entry was validated before `.find()` ever selected the mode + * being asked for — and one drifted entry anywhere in the list took down a mode that was + * perfectly readable. An app enabled as `API_KEY` whose retrieve answers + * `[{ mode: "API_KEY", fields: {…complete…} }, "OAUTH2"]` has an intact mode at index 0 and a + * bare string at index 1, and everybody pressing Connect on that app was blocked permanently + * — "no form was drawn … upgrading @composio/core is what fixes it" — over a mode nobody + * asked about. Nothing they can reach changes the next reading, which is the same shape. + * + * SO THE WANTED MODE IS LOOKED FOR FIRST AND THE REST ARE REPORTED ONLY IF IT IS NOT FOUND. + * Where it is found, an entry this deployment could not read is a mode it was not asked to + * draw. Where it is NOT found, the unreadable entries are the reason the refusal must not be + * the drift sentence below: one of them may BE the mode, and telling an administrator the app + * has dropped it would send them to re-add against a list that reads identically next time. + */ + const unreadableModes: unknown[] = []; + let mode: { mode?: unknown; fields?: unknown } | undefined; + for (const [index, candidate] of listed.entries()) { + if (candidate === undefined || candidate === null) continue; + if (typeof candidate !== "object" || Array.isArray(candidate)) { + unreadableModes.push({ index, candidate }); + continue; + } + const entry = candidate as { mode?: unknown; fields?: unknown }; + /* + * AND AN ENTRY WHOSE OWN MODE CANNOT BE READ IS UNREADABLE TOO, WHICH THE SHAPE TEST ABOVE + * DOES NOT CATCH. `{ mode: null, fields: {…} }` is an object and not an array, so it passes + * that guard, and `textOf` then answers null — which never equals a scheme name, so the + * loop simply moved on and the entry was never collected. With the wanted mode absent from + * the rest of the list, `unreadableModes` was empty, the vendor-shape refusal below was + * skipped, and control reached the ADMINISTRATOR'S refusal: Composio no longer publishes a + * connection of this scheme for this app. That is precisely the sentence this walk exists + * to keep off an entry that MAY BE the mode — it sends an administrator to remove and + * re-add against a list that reads identically next time. + * + * THE NAME IS WHAT MAKES AN ENTRY CLASSIFIABLE, so an entry without a readable one sits + * exactly where an entry that is not a document sits: this deployment cannot say whether it + * is the mode being looked for. A mode the vendor genuinely left out is a HOLE in the list, + * which is the `undefined`/`null` candidate skipped above, and is a different fact with a + * different true sentence. + */ + if (textOf(entry.mode) === null) { + unreadableModes.push({ index, candidate }); + continue; + } + /* + * THE MODE IS A SCHEME NAME, SO IT IS THE TRIMMED ONE — the rule {@link labelsOf} states + * for every other scheme this file reads and the one site that was comparing the raw value. + * A scheme is the NAME of a flow: it is matched against the word recorded on the app's row + * when somebody enabled it, so a padded `" API_KEY "` is the app's own mode wearing whatever + * the wire wrapped it in. Read raw it compared unequal to the recorded word exactly as a + * mode the app had DROPPED would, and the refusal below then sent an administrator to remove + * the app and add it again — which records the same padded word and comes back refusing + * identically, which is a loop that cannot close. + */ + if (textOf(entry.mode) === authScheme) { + mode = entry; + break; + } + } + + /* + * AND A MODE THAT WAS NOT FOUND BESIDE ENTRIES THAT COULD NOT BE READ IS NOT "THE APP DROPPED + * IT". This is the refusal an eager walk would have raised, reached only where it is the true + * thing to say: the wanted mode is not among what this deployment CAN read, and what it cannot + * read is where the answer may be. It is the vendor's shape that changed, so it carries the + * vendor's remedy rather than the administrator's. + */ + if (mode === undefined && unreadableModes.length > 0) { + throw unreadableForm( + unreadableModes[0], + `${unreadableModes.length} of the ways of connecting ${toolkit}, one of which may be the ${authScheme} one`, + ); + } + /* + * 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 || mode === null) { + 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.`, + ); + } + /* + * EVERY REMAINING STEP TELLS AN ABSENCE FROM AN ANSWER IT CANNOT READ, which is the whole of + * what {@link unreadableForm} is for. A mode that publishes no `fields`, no initiation block + * or an empty list of rows is saying it asks a person for nothing, and `[]` is the true + * reading of that. A `fields` that is not an object, an initiation block that arrived as the + * list of rows that used to sit inside it, and a `required` that stopped being a list are + * three different vendor changes, none of which is that sentence, and all three used to be + * written down as it. + */ + const asked = mode.fields ?? {}; + if (typeof asked !== "object" || asked === null || Array.isArray(asked)) { + throw unreadableForm( + mode.fields, + `the fields ${toolkit}'s ${authScheme} connection publishes`, + ); + } + const initiation = + (asked as { connected_account_initiation?: unknown }) + .connected_account_initiation ?? {}; + if ( + typeof initiation !== "object" || + initiation === null || + Array.isArray(initiation) + ) { + throw unreadableForm( + (asked as { connected_account_initiation?: unknown }) + .connected_account_initiation, + `what ${toolkit} asks for when a connection to it is made`, + ); + } + + /* + * THE REQUIRED LIST AND THE OPTIONAL ONE ARE ASKED SEPARATELY, because they are separately + * published and a guard on one says nothing about the other — and the required one is the one + * that stops a form, so a required list read as empty is a form a person can submit blank. + */ + const published = initiation as { + required?: unknown; + optional?: unknown; + }; + const rowsOf = (value: unknown, which: "required" | "optional") => { + const asRows = value ?? []; + if (!Array.isArray(asRows)) { + throw unreadableForm( + value, + `the ${which} fields ${toolkit} asks a person to fill in`, + ); + } + return asRows; + }; + /* + * REQUIRED FIRST, WHICH IS AN ORDER RATHER THAN A CONVENIENCE — see the deduplication below, + * where it is what decides which of two rows sharing a name survives. + */ + const rows = [ + ...rowsOf(published.required, "required"), + ...rowsOf(published.optional, "optional"), + ]; + + const drawn = rows + .filter((row, index) => { + /* + * THE ROW IS AN OBJECT BEFORE A FIELD IS READ OFF IT, WHICH IS THE ONE CONTAINER IN THIS + * FILE THAT WAS NOT ASKED. + * + * Every other one is: the catalogue row, its `meta`, each category, the action row, the + * toolkit detail, the list of modes, the mode itself, the `fields` object and the + * initiation block inside it. The rows hanging off that block were not, and nothing here + * throws for it — `("generic_api_key").user_visible` is `undefined`, so a row that arrived + * as a string passes the visibility read as an ordinary shown field and reaches the type + * guard below, which refuses it with "Connecting this app is not something this deployment + * can offer yet". That is a verdict about the APP, it carries no remedy at all, and the + * thing it describes is a package whose shape moved. The remedy for that is the one + * {@link unreadableForm} ends every other step of this read with. + * + * ABSENT IS NOT EXEMPTED HERE, WHICH IS WHERE THIS DIFFERS FROM THE MODE LIST ABOVE. A + * hole in THAT list is simply a mode the vendor left out and the `find` below it says the + * true thing about it; a hole in THIS one is a field the form would silently be one box + * short of, and there is no reading of `null` under which a person is being asked for + * nothing in particular. + */ + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw unreadableForm( + row, + `field ${index + 1} of what ${toolkit}'s ${authScheme} connection asks a person to fill in`, + ); + } + + /* + * A FIELD COMPOSIO HIDES IS ONE COMPOSIO FILLS IN, AND `!== false` HID THE WRONG STATE. + * An absent `user_visible` means show it, which is most of the catalogue and stays. A + * present `"false"` is Composio saying the opposite and reading as "show it" — a box + * drawn for a tenant id or an instance name the person has no way to know, which they + * then leave blank and submit as an empty value under a key the app does read. + */ + const visible = flagOf(row?.user_visible, true); + if (visible === null) { + throw new BrokerRefusalError( + `${toolkit} describes ${textOf(row?.displayName) ?? textOf(row?.name) ?? "one of the values it asks for"} with ${sent(row?.user_visible)} where the flag saying whether that field is shown to the person filling the form in belongs. A field Composio hides is one Composio fills in itself, so a flag this deployment cannot read is the difference between a form and a form with a box nobody can answer. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return visible; + }) + .map((row) => { + /* + * A TYPE THIS DEPLOYMENT CANNOT DRAW IS A REFUSAL RATHER THAN A TEXT BOX. Every required + * field measured across the catalogue is a plain string, so this is a guard against the + * vendor rather than a routine case — and the failure it prevents is somebody typing a + * path into a box labelled Certificate and being told they are connected. + * + * AND THE NAME IS IN THE SAME GUARD, because it is the least guarded field here and the + * only one that travels. `label`, `help` and `default` all pass through {@link textOf} + * and are read by a person; the name is sent back to Composio verbatim and is the key + * {@link ComposioBroker.connectWithFields} spreads into the connection's `val`. Coerced + * with `String(...)`, a row carrying `type: "string"` and no name drew a box literally + * called "undefined" and then submitted whatever was typed in it under that key — a value + * no app reads, in a connection Composio accepts. + */ + /* + * AND THE TYPE IS READ THE WAY THE NAME BESIDE IT IS, which is the half this guard did + * not have. `row.type !== "string"` was the RAW value while the name one line up goes + * through {@link textOf}, so a padded `" string "` was refused with "a box this + * deployment can draw is a string" — about a field whose type IS a string, in a sentence + * whose own words say so. A type is a keyword and not prose: the value is the word, and + * the padding is whatever the wire wrapped it in. + */ + const name = textOf(row?.name); + if (textOf(row?.type) !== "string" || name === null) { + throw new BrokerRefusalError( + `${toolkit} asks for ${textOf(row?.displayName) ?? "a value"} as ${sent(row?.type)} under the name ${sent(row?.name)}, which cannot be filled in here: a box this deployment can draw is a string, and a box whose answer can be sent back has a name. Connecting this app is not something this deployment can offer yet.`, + ); + } + + /* + * AND ONE NAME IS THE PROTOCOL'S RATHER THAN THE APP'S, WHICH IS THE SAME GUARD ONE STEP + * FURTHER ON: a box whose answer cannot be sent must not be drawn. + * + * {@link ComposioBroker.connectWithFields} puts what somebody types into the `val` of the + * connection state, beside the `status` that says what is being created — the shape the + * vendor's own `AuthScheme` builder assembles (`@composio/core` 0.18.1, + * `src/models/AuthScheme.ts:84-94`). `status` is therefore a word the call itself owns, + * and BOTH answers to a field published under that name are wrong. Sent, it replaces the + * state this deployment is asking Composio to create, from a request that looks like an + * ordinary connection. Withheld — which is what that call now does, writing its own word + * last — it is a box somebody filled in whose value no app will ever read, the very + * failure the name guard directly above exists to prevent. + * + * SO THE APP IS REFUSED RATHER THAN PART OF ITS FORM, and the sentence names the field, + * which is the vendor's own text read off the list just fetched and the only thing an + * administrator can act on. + */ + if (name === "status") { + throw new BrokerRefusalError( + `${toolkit} publishes a field called ${name}, which is the word this deployment uses to tell Composio what state a connection is being created in, so a value typed into it either overwrites that word or is never sent at all. Neither is a box worth drawing. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* + * 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); + + /* + * THE TWO FLAGS THAT DESCRIBE THE BOX ARE READ THE SAME WAY THE TYPE AND THE NAME ARE, + * AND FOR THE SAME REASON. `row.required === true` and `row.is_secret === true` answered + * "no" to an absent flag, which is right and is most of the catalogue, and answered "no" + * to a PRESENT `"true"` as well — the vendor's yes and the vendor's silence collapsed + * into one value at the point either is acted on. + * + * `required` IS THE SHARPER OF THE TWO BECAUSE THE CONNECT ROUTE NOW ENFORCES IT. The + * guard that refuses a submission omitting a required field reads this boolean and no + * other, so a vendor publishing `"true"` would make that guard wave through the exact + * submission it exists to refuse: an account created with the credential missing out of + * it, which Composio answers `ACTIVE` for because it does not grade what it is given, and + * which the first tool call is the first thing to notice. + * + * `is_secret` IS THE QUIETER ONE AND NOT THE SMALLER ONE. Read as a no, the box for + * somebody's API key is drawn as ordinary text: typed in plain sight, left on the screen, + * and offered to whatever fills fields in. + */ + const required = flagOf(row.required, false); + if (required === null) { + throw new BrokerRefusalError( + `${toolkit} describes ${name} with ${sent(row.required)} where the flag saying whether that field has to be filled in belongs. Read as a no — which is what a value this deployment cannot read would otherwise become — a field ${toolkit} requires is drawn as one a person may leave blank, and what they submit is an account Composio accepts with the credential missing out of it. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const secret = flagOf(row.is_secret, false); + if (secret === null) { + throw new BrokerRefusalError( + `${toolkit} describes ${name} with ${sent(row.is_secret)} where the flag saying whether that field holds a secret belongs. Read as a no, the box is drawn as ordinary text, so whatever goes in it — a key, a token — is typed in plain sight and left on the screen. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { + name, + label: textOf(row.displayName) ?? name, + help: textOf(row.description) ?? "", + required, + secret, + ...(suggested === null ? {} : { default: suggested }), + }; + }); + + /* + * ONE NAME IS ONE BOX, HOWEVER MANY OF COMPOSIO'S LISTS PUBLISHED IT — the half + * {@link readableConfigs} and {@link withdrawableAccounts} already carry, arriving here a + * wave late and for a sharper reason than either. + * + * THE NAME IS A KEY AND NOT A LABEL. `required` and `optional` are published separately, so + * nothing at Composio stops one field appearing in both, and concatenated as they arrive that + * app draws TWO boxes carrying the same name — with different labels, different help and + * different defaults, because the two rows are different rows. A person fills both in; the + * form collects what they typed under the field's name; the second box silently overwrites + * the first, and which of the two values reached the vendor is not recorded anywhere. The + * same name twice also passes straight through the connect route's published-names check, so + * nothing downstream is in a position to notice. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, as it does for configs and accounts — and here that is + * a decision as well as a convention, because required is spread first: a name published in + * both lists is drawn as the REQUIRED one. That is the safe direction. Read as optional, a + * credential the app cannot do without becomes a box the connect route lets somebody leave + * blank, and what that makes is the credential-less account that route's required guard + * exists to refuse. + */ + /* + * `drawnNames` RATHER THAN `named`, WHICH IS ONLY A RENAME AND IS WORTH ONE LINE — for the + * second time in this file, and this one came back through a merge after the first had been + * diagnosed. This binding shadowed the module helper {@link named} for the whole of this + * method, so the function every refusal in the file quotes a vendor enum through was + * unreachable from the one place a new refusal here would reach for it, and an edit that did + * would have been calling a Set. `resolved.toolkit`'s reader says the same thing about the + * same word one method up; nothing is wrong today, and the next change to this block is what + * both renames are for. + */ + const drawnNames = new Set(); + return drawn.filter((field) => { + if (drawnNames.has(field.name)) return false; + drawnNames.add(field.name); + return true; + }); + }, + + /** + * 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({ + account, + toolkit, + authScheme, + values, + }): Promise<{ accountId: string }> { + const userId = account.vendorUserId; + /* + * THIS DEPLOYMENT'S OWN CONFIG, FOR THE REASON {@link ComposioBroker.authorize} READS ONE: an + * account is a lasting attachment to whatever config it was made against, so attaching + * somebody to an operator's hand-made config — scopes this deployment cannot see, tool + * restrictions it cannot read, an object it must not delete — is not a guess that can be + * corrected afterwards. + */ + const { ours, unreadable } = await configsFor(toolkit); + if (ours.length === 0) { + /* + * A ROW THIS FILE COULD NOT READ IS NOT AN APP WITH NO CONFIG, AND IT IS THE SAME TWO + * REMEDIES {@link ComposioBroker.authorize} TELLS APART. "Remove the app and add it again" + * is right where the listing was legible and said none of these is ours, and wrong here: + * the row that could not be sorted may BE ours, in which case the removal meets + * {@link ComposioBroker.deleteAuthConfig}'s own refusal and the re-enable meets + * `ensureAuthConfig`'s. That is an administrator sent round a loop that cannot close, and + * this method used to hand them exactly that sentence. + * + * NO `cause` ON ANY OF THE REFUSALS IN THIS METHOD, which is the local rule rather than the + * file's. Not one of the four refusals this config read can raise carries a vendor object at + * all — they are this file's own reading of a listing — but attaching + * `everyRefusal(unreadable)` here, as `authorize` correctly does, would put an error chain + * on the one method whose call frame holds somebody's API key, and the whole of this + * method's doc comment is about not doing that. The count is the finding, and the count is + * in the sentence. + */ + if (unreadable.length > 0) { + throw new BrokerRefusalError( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read and none of the rest is one it made, so there is nothing it can show is its own to connect an account against and what was typed into the form was not sent anywhere. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `This deployment has no authorization config at Composio for ${toolkit}, so there is nothing to connect an account against and nothing was sent. An administrator removing the app on its Plugins page and adding it again creates one.`, + ); + } + + /* + * THE ENABLED ONE, WHICH IS THE SAME CHOICE {@link ComposioBroker.authorize} MAKES AND FOR ONE + * REASON MORE. `configsFor` lists with `showDisabled: true` — it has to, or `ensureAuthConfig` + * creates a second config beside one it cannot see — so `ours[0]` could perfectly well be a + * DISABLED config, and an account created against one cannot work. Reading that state only + * after the create is the wrong order on this path more than on any other: the body of that + * request is the key somebody just pasted in, so the refusal has to arrive BEFORE the + * credential leaves this process rather than after Composio has been handed it and declined. + * + * AND IT IS THE SAME CONFIG THE CONSENT PATH WOULD HAVE PICKED, which is the other half. + * Two configs from a lost enable race, the first of them disabled, and `ours[0]` against + * `find(ENABLED)` attach one app's accounts to two different configs depending on which door + * a person came through — after which removing "the" config drops half of them. + */ + const config = ours.find((held) => statusOf(held) === "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. + */ + /* + * AND TWO INDEPENDENT CLAUSES FOR THE REASON {@link ComposioBroker.authorize} GIVES AT + * LENGTH, which is worth repeating here only because this block is a COPY of that one rather + * than a call to it: the chain lived in both methods, so the suppression did too. A person + * typing their key into an app holding one DISABLED config beside one unsettled row was told + * to upgrade a package — while the act that would have got the key accepted, enabling the + * disabled config in Composio's dashboard, went unsaid. It is not even that person's act, + * which is exactly why the sentence has to carry it rather than choose. + */ + const unsettled = ours.filter((held) => statusOf(held) !== "DISABLED"); + const left: string[] = []; + const disabled = ours.length - unsettled.length; + if (disabled > 0) { + left.push( + `Composio calls ${disabled} of this deployment's ${ours.length} authorization configs for ${toolkit} disabled, and an administrator can enable it in Composio's dashboard, or remove the app on its Plugins page and add it again.`, + ); + } + if (unsettled.length > 0) { + const words = [ + ...new Set(unsettled.map((held) => named(held.status))), + ]; + /* + * AND THE TAIL COUNTS IN THE NUMBER'S OWN WORDS. "and 1 other words" was the unpluralised + * spelling, in a sentence whose very next clause conjugates its own verb on the same + * count — so the one refusal that has to be trusted about an authorization config could + * not get its own arithmetic to read. See {@link STATUSES_NAMED} for why there is a tail + * at all. + */ + const shown = words.slice(0, STATUSES_NAMED); + const unnamed = words.length - shown.length; + const said = + unnamed > 0 + ? `${shown.join(", ")} and ${unnamed} other word${unnamed === 1 ? "" : "s"}` + : shown.join(", "); + left.push( + `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. ${VENDOR_SHAPE_REMEDY}`, + ); + } + /* + * AND THE ROWS NOTHING COULD SORT ARE THE THIRD CLAUSE, for the reason + * {@link ComposioBroker.authorize} gives beside the identical one: a row whose id or name + * never arrived may be a config of this deployment's that Composio calls ENABLED, so the + * dashboard reading is a finding this sentence has and the disabled clause does not carry. + * No `cause`, which is this method's own standing rule — the count is the finding and the + * count is in the sentence, because the call frame this refusal is raised in holds somebody's + * API key. + */ + if (unreadable.length > 0) { + left.push( + `Composio described ${unreadable.length} of its authorization configs for ${toolkit} in a way this deployment cannot read, so whether one of THOSE is a config of this deployment's that Composio calls enabled is outside what either reading settles: reading those rows in Composio's own dashboard is what says whether there is one here to connect against at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + throw new BrokerRefusalError( + `No authorization config this deployment holds at Composio for ${toolkit} could be shown to be enabled, so nothing was sent and what was typed into the form did not leave this deployment. ${left.join(" ")}`, + ); + } + + 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. + * + * AND `status` IS THE PROTOCOL'S WORD, SO IT IS WRITTEN LAST. The vendor's builder + * spreads the fields OVER it, which is safe only while no field is called `status` — + * and the names in `values` are the vendor's own, so that is a fact about somebody + * else's catalogue rather than an invariant of this call. Spread first and set after, + * the one word saying what this request is asking Composio to create cannot be + * displaced by a value arriving under the same name, whatever published it. + * + * WHICH IS THE SECOND OF TWO GUARDS AND NOT THE ONLY ONE. {@link + * ComposioBroker.connectionFields} refuses to draw a box named `status` at all, so a + * value under that name cannot come from a form, and the connect route sends only names + * that list published. This method is callable without either, and what it protects is + * the one field here that cannot be recovered from anything else in the request. + */ + state: { + authScheme, + val: { ...values, status: "ACTIVE" }, + }, + }, + }); + } 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 }; + }, + } satisfies Pick; +} diff --git a/server/src/plugins/composio-client/model.ts b/server/src/plugins/composio-client/model.ts new file mode 100644 index 000000000..d2619357a --- /dev/null +++ b/server/src/plugins/composio-client/model.ts @@ -0,0 +1,699 @@ +import type { BrokerApp, FieldScheme } from "../broker"; +import type { ComposioResult } from "../composio"; +import type { + CheckedAuthConfig, + VendorAccountStatus, + VendorAuthConfig, + VendorTool, + VendorToolkitDetail, +} from "../composio-vendor"; + +/** + * The Composio client's shapes and constants: the vendor seam it is built over, and the names and + * limits its methods share. Split out of `../composio-adapter.ts`, which re-exports what it always + * exported. No `@composio/core` here; the adapter is still the one file that imports it. + */ + +/** + * The one file in `server/src` that imports `@composio/core`, and what it owes the rest of them. + * + * `./composio` describes calling an action and `./broker` describes everything that has to be true + * before one can be called; both are written as narrow projections that name no vendor type, so + * that the SDK's shape — its constructor, its retries, its zod schemas and whatever the next + * version renames — is confined here. This module is the adapter that satisfies both from one + * client. A second importer of `@composio/core` under `server/src` would undo that, because the + * point of a single import site is that a version bump has exactly one file to be read against. + * + * NO SESSION IS EVER CREATED, AND THAT IS A SECURITY BOUNDARY RATHER THAN A PREFERENCE. The SDK's + * `composio.create(...)` and `sessions.create(...)` open a Composio tool-router session, and a + * session brings Composio's own hosted surface with it — a remote shell and a Python sandbox that + * this deployment neither asked for, cannot see into, and could not audit if a model reached them. + * Everything below is a plain per-call request carrying a user id. A `create` of a session + * anywhere in this file is a defect, not an optimisation, and it will not look like one: the + * session API is the shortest path to most of what this file does the long way. + * + * EVERY LISTING PASSES AN EXPLICIT LIMIT AND EVERY LISTING IS READ TO THE END OF ITS CURSOR. + * Composio's default page is 20, which is smaller than the number of actions Gmail alone publishes, + * and through the SDK's tool wrapper the default did a second thing as well: `getRawComposioTools` + * set `important=true` whenever a toolkit query arrived with no limit, no tags and no search + * (`@composio/core` 0.18.1, `src/models/Tools.ts:505-515`), so an omitted limit silently narrowed + * the answer to the vendor's own "important" subset and nothing in the result said a filter had + * been applied. {@link LISTING_LIMIT} is the documented page ceiling and therefore the fewest round + * trips a listing can be read in. + * + * AND THE LIMIT IS A PAGE RATHER THAN THE ANSWER, WHICH IS WHAT CHANGED. Two of the four listings + * here used to meet a full page and refuse it, because the SDK wrapper around them could not ask + * for a second — one takes no cursor, the other drops the response's. That was never true of the + * vendor: both raw endpoints carry `cursor` and `next_cursor`, and Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so the catalogue refusal fired on every call and the app picker + * showed an operator nothing at all. All four now go through {@link everyRowOf}, which follows the + * cursor until the vendor stops offering one and refuses rather than truncates when it cannot. + * + * THE API KEY NEVER LEAVES THIS FILE. It arrives as {@link createComposioClient}'s only argument, + * goes straight into the vendor's constructor, and is held from there on by the vendor's client + * inside a closure. Nothing below logs it, no thrown message quotes it, and neither of the two + * returned objects carries a field that could be read back to it — they expose eight methods and + * no state. A key in a log line is a key in a log aggregator, and a key in an error message is a + * key in an audit row and in a model's context. + */ + +/** + * What this adapter needs of `@composio/core`'s client, written as a shape rather than as a class. + * + * A TEST SATISFIES IT WITH AN OBJECT LITERAL, which is the entire argument for it and the reason + * {@link buildComposioClient} takes one of these instead of an API key. The vendor's own client + * cannot be constructed without a key and answers nothing without a network, so an adapter that + * built its own client would be an adapter no test could reach — and this is the file where the + * field names, the argument order and the refusal below would go wrong unnoticed. + * + * Every member is written with METHOD syntax deliberately. Method parameters are compared + * bivariantly, so a real `Composio` — whose signatures carry optional request-options arguments + * and wider parameter types than the calls here use — satisfies this without a cast. + * + * THE TWO DELETES ARE NOT `Composio`'s, AND THAT IS THE ONE PLACE THIS SHAPE DIVERGES FROM IT. Both + * of the vendor's own wrappers hard-code the request body they send — `this.client.authConfigs + * .delete(nanoid, undefined, requestOptions)` and the same line for connected accounts + * (`@composio/core` 0.18.1, `src/models/AuthConfigs.ts:303-311` and + * `src/models/ConnectedAccounts.ts:532-540`) — so through them the `revoke_on_delete` parameter + * cannot be passed at all, and both calls soft-delete while the grant at Google or Slack stands. So + * the shape below asks for the underlying client's signature instead, and + * {@link createComposioClient} satisfies it from `composio.getClient()`. See + * {@link ComposioBroker.revoke} for what that flag is and what its absence made this deployment + * claim. + */ +export type ComposioVendor = { + tools: { + /** + * Every action of one app, one page at a time, THROUGH THE RAW CLIENT RATHER THAN THE WRAPPER. + * + * `getRawComposioTools` was here and could not be paged. `ToolListParamsSchema` names no cursor + * field at all (`@composio/core` 0.18.1, `src/types/tool.types.ts:257-266`) and the method ends + * `tools.items.map(...)` (`src/models/Tools.ts:561`), dropping the response's `next_cursor` + * before any caller sees it — so through the wrapper a page at {@link LISTING_LIMIT} and a + * listing longer than one were the same array, and `./composio` refused rather than commit a + * fragment as the whole truth about an app. The raw client is the difference: `ToolListParams` + * carries `cursor`, "a base64 encoded string of the page and limit", and `ToolListResponse` + * carries `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/tools.d.ts:421-432` and + * `:200-204`). The request IS expressible, and {@link everyRowOf} makes it. + * + * WHAT THE WRAPPER WAS DOING THAT THIS HAS TO KEEP DOING IS TWO PARAMETERS AND ONE NO-OP. + * `toolkit_versions` is the SDK's own `toolkitVersions` config, which defaults to "latest" + * (`src/utils/config-defaults/ConfigDefaults.node.ts`) and was forwarded on every listing it + * made (`src/models/Tools.ts:548`); it decides which `version` each action comes back with, + * which is the value a later call sends back to Composio, so it is passed here as the literal + * the default is. `important` is the one this file already knew about and is now simply not + * named: the wrapper set it to "true" whenever a toolkit query carried no limit (`:505-515`), + * which NARROWS the answer to a featured subset that nothing in the answer declares. And the + * no-op is `applyDefaultSchemaModifiers`, which returns its argument untouched unless + * `dangerouslyAllowAutoUploadDownloadFiles` is on (`:242-248`); it defaults off + * (`ConfigDefaults.node.ts`) and {@link createComposioClient} does not turn it on — which is + * the premise `./composio`'s file-upload guard is written on. + * + * AND `ToolSchema.parse` IS GONE WITH IT, WHICH IS A GAIN AND A COST, BOTH WRITTEN DOWN + * ELSEWHERE. The gain is that the parse STRIPPED every schema key its `ParametersSchema` did + * not name before any caller could see it — see {@link ComposioAction.inputParameters}, where + * that loss was recorded as unavoidable and where the fix was named as this exact call. The + * cost is that nothing validates the row: {@link actionOf} carries every check now, including + * the container, and {@link pageOf} carries the envelope. + */ + list(query: { + toolkit_slug: string; + limit: number; + toolkit_versions: "latest"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** + * `unknown` BECAUSE NOTHING HAS LOOKED AT IT, which is the difference between this listing + * and the two the SDK still serves. Their transformers open `response.items.map(...)`, so a + * malformed envelope dies inside the vendor's package; a generated client parses the body and + * returns it. {@link pageOf} is where that is answered. + */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; + getRawComposioToolBySlug( + slug: string, + options?: { version?: string }, + ): Promise; + execute( + slug: string, + body: { + arguments: Record; + userId: string; + version: string; + /** + * WHICH ACCOUNT OF THE PERSON'S TO RUN IN, absent where any of theirs will do. + * + * `ToolExecuteParams` carries it and `Tools.execute` forwards it to the client + * (`@composio/core` 0.18.1), so this is the wire's own field rather than one this + * deployment composes. Why a verification cannot go without it is written at + * {@link ComposioActions.execute}: a person and an app do not name an account, and one + * person may hold several for one app. + */ + connectedAccountId?: string; + }, + ): Promise; + }; + toolkits: { + /** + * The app catalogue, one page at a time, THROUGH THE RAW CLIENT FOR THE SAME REASON. + * + * AND THIS IS THE ONE THAT WAS BROKEN IN FRONT OF PEOPLE. `Toolkits.getToolkits` does forward a + * `cursor` (`@composio/core` 0.18.1, `src/models/Toolkits.ts:68`) — so the wrapper could ASK + * for a second page — but `transformToolkitListResponse` returns `response.items.map(...)`, a + * bare array (`src/utils/transformers/toolkits.ts:16-43`), so there is no cursor to put in it. + * One half of a pager is not a pager, and `fetchDirectory` refused a full page because it could + * not tell a complete catalogue from a truncated one. Composio publishes more than + * {@link LISTING_LIMIT} toolkits, so that refusal fired on the FIRST call, every time, and an + * operator opening the app picker saw no apps at all. `ToolkitListResponse` carries + * `next_cursor` and `ToolkitListParams` carries `cursor` (`@composio/client` 0.1.0-alpha.76, + * `resources/toolkits.d.ts:322-326`, `:467-478`), so the whole request was expressible the + * entire time. + * + * `sort_by` IS THE WIRE'S SPELLING OF WHAT WAS `sortBy`, and it still matters even though the + * listing is no longer finite. What it buys now is order rather than coverage: the rows reach a + * picker in the order Composio thinks people want them, and the page ceiling in + * {@link PAGE_CEILING} is the one case left where being cut off is possible at all. + * + * NO SEARCH TERM, WHICH IS THE SAME DECISION FOR A DIFFERENT REASON THAN BEFORE. It used to be + * that the SDK's params named no search field and the parse stripped what it did not name, so a + * term passed here would vanish before the request. `ToolkitListParams` DOES name `search` + * (`:480-484`), so that is no longer the argument. The argument is that the catalogue is held + * for ten minutes and searched in this process by both of its callers — the picker and the + * enable route's slug check — and a per-term request would be a per-term cache. Searching over + * the held rows is this deployment's own job and stays so. + */ + list(query: { + limit: number; + sort_by: "usage"; + /** Where the last page left off, ABSENT on the first request rather than undefined. */ + cursor?: string; + }): Promise<{ + /** `unknown` for the reason the action listing's is — see {@link pageOf}. */ + items?: unknown; + /** The vendor's own word for "there is another page", read by {@link everyRowOf}. */ + next_cursor?: unknown; + } | null>; + /** + * ONE app, read for the one thing the catalogue listing does not carry: what it asks a person. + * + * The listing above answers which apps exist and which scheme each of them authenticates with; + * it does not answer which boxes a form for that scheme needs. That is published per app and it + * moves — one app wants a key, the next wants a key and a workspace subdomain — so it is asked + * for at the moment a person presses Connect rather than derived from anything held here. See + * {@link ComposioBroker.connectionFields}. + * + * NO PAGE AND NO CURSOR, because a toolkit is one object rather than a listing: this is the one + * vendor read in this file where there is no second page to be mistaken for the whole answer. + */ + retrieve(slug: string): Promise; + }; + authConfigs: { + list(query: { + toolkit: string; + limit: number; + /** + * Ask for the disabled ones too, ALWAYS, which is why this is the literal and not a boolean. + * + * The vendor's listing returns enabled configs unless asked otherwise + * (`AuthConfigListParamsSchema.showDisabled`, `@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`), and a config this listing cannot see is a config + * {@link ComposioBroker.ensureAuthConfig} creates a second of — which is the exact split that + * method exists to prevent, arriving through the one door it was not watching. Every question + * this file asks of the listing is better served by seeing a disabled config and saying so: + * creation must not duplicate it, deletion must remove it, and consent must refuse against + * it rather than mint a link that cannot work. + */ + showDisabled: true; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `AuthConfigListParamsSchema` names it and `AuthConfigs.list` forwards it + * (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:124-131`, + * `src/models/AuthConfigs.ts:95`), which is the fact that decides how a truncated answer is + * handled here: the catalogue refuses a full page because it has no way to ask for the next + * one, and this listing does. See {@link everyRowOf}. + */ + cursor?: string; + }): Promise<{ + items: VendorAuthConfig[]; + /** + * The vendor's own word for "there is another page", which was being discarded at this type. + * + * `unknown` RATHER THAN `string | null`, because the transformer writes `response.next_cursor + * ?? null` (`src/utils/transformers/authConfigs.ts:80`) and that is the wire's value with no + * check on it — a numeric cursor reaches {@link everyRowOf} exactly as Composio sent it, which + * is the one shape that could be read as the end of a listing that has not ended. + * + * `AuthConfigListResponseSchema` carries it (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:129-133`) and + * `transformAuthConfigListResponse` fills it in from `next_cursor` on every answer + * (`src/utils/transformers/authConfigs.ts:72-80`). Omitting it here did not make the + * truncation go away; it made it unobservable, because a field a projection does not name is + * a field no caller and no test of a caller can ask about. {@link everyRowOf} reads it, and + * follows it until the vendor stops offering one — so a listing at {@link LISTING_LIMIT} is + * no longer a fragment this file can mistake for the whole answer. + */ + nextCursor?: unknown; + }>; + /** + * Create one auth config, and hand back the id Composio gave it. + * + * `Promise` UNTIL NOW, AND THE ANSWER WAS NEVER LOOKED AT. That was read as harmless + * because nothing here needs the id — the next listing finds the config by its name. It is not: + * `transformCreateAuthConfigResponse` builds what this resolves to by dereferencing + * `response.auth_config.id` and `response.toolkit.slug` (`@composio/core` 0.18.1, + * `src/utils/transformers/authConfigs.ts:96-106`), so a drift in the answer's SHAPE raises a + * `TypeError` inside the vendor's package — after the create has gone out and been answered. + * {@link ComposioBroker.ensureAuthConfig} then reported that no config had been created, over a + * config standing at Composio that nothing in this deployment names. Reading the reply is what + * makes the difference between "nothing was created" and "something may have been" a fact + * rather than an assumption. + * + * `id?: unknown` FOR THE REASON EVERY OTHER FIELD IN THIS PROJECTION IS ONE: the `transform()` + * around it is the warn-only kind (`src/utils/transform.ts:26-36`), so + * `CreateAuthConfigResponseSchema` spelling the id a required string is the answer Composio + * MEANS to send rather than a fact about the one that arrived. + * + * NULLABLE THOUGH THE VENDOR'S OWN DECLARATION IS NOT, exactly as the account delete below is: + * the generated client parses the body and returns it, and reading a field off a `null` would + * be a crash carrying a sentence that reads like a stack trace. + */ + create( + toolkit: string, + /** + * THE UNION, BECAUSE THE MANAGED TYPE IS RIGHT FOR ONE KIND OF APP AND WAS SENT FOR ALL OF + * THEM. A self-registering app has no Composio-owned OAuth client behind it and a key app + * has no consent screen to send anybody to, so both are created as custom configs — carrying + * the scheme, and never a credential, which is per-connection rather than per-config. The + * managed shape has no field to name a scheme with, which is why this is a union of two + * shapes rather than one shape with an optional field. + */ + options: + | { type: "use_composio_managed_auth"; name: string } + | { + type: "use_custom_auth"; + authScheme: FieldScheme | "DCR_OAUTH"; + name: string; + /** + * EMPTY, AND PRESENT, WHICH IS NOT THE CONTRADICTION IT LOOKS LIKE. + * + * Neither config this deployment creates carries a secret — a self-registering app + * needs none and a key app's key belongs to each connection made against the config + * rather than to the config — but `CreateCustomAuthConfigParamsSchema` spells + * `credentials` REQUIRED (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:52-65`) and `AuthConfigs.create` `safeParse`s its + * options before it builds a body (`src/models/AuthConfigs.ts:132-137`). So an absent + * field is not "no credentials sent"; it is a `ValidationError` raised inside the + * vendor's package with no request made at all. The empty record is what carries + * nothing THROUGH that check, and the type says so rather than leaving the next reader + * to discover it from a failed enable. + */ + credentials: Record; + }, + ): Promise<{ id?: unknown } | null>; + /** + * Delete one auth config, and ask for the upstream credentials on it to be revoked too. + * + * THE SAME TRAP AS THE ACCOUNT DELETE BELOW, one level up. The endpoint "soft-deletes an + * authentication configuration" and revokes "the upstream credentials of every connection using + * this auth config" only when the flag is passed (`@composio/client` 0.1.0-alpha.76, + * `resources/auth-configs.d.ts:60-72` and `:651-659`), so a delete without it leaves every + * grant that was ever made against this config alive at the provider. + * + * WHICH IS WHY IT IS PASSED HERE EVEN THOUGH THE ACCOUNTS WERE ALREADY ASKED FOR INDIVIDUALLY. + * Removing an app revokes each connected person first and drops the config last, and that loop + * reaches exactly the people this deployment has a `composio_connections` row for. An account + * whose row drifted — cleared by a confirm that Composio answered `false` to, or lost with a + * database this deployment restored — is invisible to it and still live at the vendor. This is + * the one call that reaches those, and there is nothing on this config that removing the app is + * not meant to end. + * + * AND ITS REPLY CARRIES NO VERDICT, WHICH IS WHY THIS ONE IS NOT READ WHERE THE ACCOUNT DELETE + * BELOW IS. That is the obvious question to ask of a call whose answer is awaited and dropped — + * {@link ComposioBroker.revokeAccount} exists to argue that not throwing is not the same as + * having been done, and the same gap one level up would have `removeServer` deleting the app's + * row behind a config that is still standing. It was asked and the answer is in the vendor's own + * type: `AuthConfigDeleteResponse` has exactly one field, an OPTIONAL `revoke_job_id`, and no + * `success` (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:377-385`). The + * account delete's `ConnectedAccountDeleteResponse` does carry `success: boolean` (`:7447-7451`), + * which is what {@link withdrawalDeclined} reads. There is nothing here to read, so `unknown` + * is the honest declaration rather than a field this deployment would be inventing. + * + * WHAT THE REPLY DOES SAY IS THAT THE REVOCATION IS A BACKGROUND JOB. `revoke_job_id` is + * "present only when `revoke_on_delete=true`", and the vendor's own comment on it says to track + * the job and its per-connection results in the Composio dashboard because "a programmatic + * endpoint to poll this job is not yet generally available". So a clean return here means the + * config was deleted and the upstream revocations were STARTED — not that they finished. That + * limit belongs to Composio rather than to this deployment, and it is why `removeServer`'s + * promise is the one it makes: every account is asked to be withdrawn before anything here is + * deleted, which is as far as the vendor lets anybody go. + */ + delete(id: string, params: { revoke_on_delete: true }): Promise; + }; + connectedAccounts: { + list(query: { + userIds: string[]; + toolkitSlugs: string[]; + /** + * Which statuses this particular question is about — see {@link CONNECTED} and + * {@link REVOCABLE}, which are the only two answers this file gives. + */ + statuses: VendorAccountStatus[]; + /** + * Both sharing models, ALWAYS, which is why this is the literal and not the enum. + * + * OMITTING IT IS NOT "NO OPINION", in exactly the way an omitted limit is not. The parameter + * defaults to private accounts only (`ConnectedAccountListParamsSchema.accountType`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:286-293`), so a person whose + * account for an app is a SHARED one reads as not connected, is told to connect an app they + * already have, and — far worse — is invisible to the revoke, which then reports that there + * was nothing to withdraw while their grant stands. Neither of the two questions this file + * asks has any reason to care how an account is shared: the gate is about whether this person + * can act through the app, and the revoke is about ending every account they can act through. + */ + accountType: "ALL"; + /** + * Which authorization configs the question is about, ABSENT where it is about all of them. + * + * THE TWO QUESTIONS DIFFER HERE TOO, AND ONLY ONE OF THEM MAY ACT. `authConfigIds` is a + * parameter of the listing (`ConnectedAccountListParamsSchema.authConfigIds`, + * `@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:260-264`) forwarded as + * `auth_config_ids` (`src/models/ConnectedAccounts.ts:117`), and omitting it asks about every + * config in the project — this deployment's and an operator's hand-made ones alike. That is + * the right question for {@link ComposioBroker.isConnected}, which only reads whether a person + * can act through the app, and the wrong one for {@link ComposioBroker.revoke}, which deletes + * what it finds: see there for why an account on somebody else's config is not this + * deployment's to end. + * + * NEVER THE EMPTY ARRAY. A caller with no configs of its own has nothing to scope TO, and an + * empty filter is the shape most likely to be read as no filter at all by whatever is on the + * far side — which would be the unscoped listing arriving through the parameter added to + * prevent it. `revoke` answers before it asks in that case. + */ + authConfigIds?: string[]; + limit: number; + /** + * Where the last page left off, ABSENT on the first request rather than undefined. + * + * `ConnectedAccountListParamsSchema` names it and `ConnectedAccounts.list` forwards it + * (`@composio/core` 0.18.1, `src/types/connectedAccounts.types.ts:259-266`, + * `src/models/ConnectedAccounts.ts:118`). This is the listing the paging matters most for: + * {@link ComposioBroker.revoke} answers `true` for "this person's access has ended", and one + * page of their accounts is not the set of their accounts. See {@link everyRowOf}. + */ + cursor?: string; + }): Promise<{ + /** + * The id, and `data.displayName` read by name, are the only fields read off an account. + * `data` and `state.val` also carry the account's tokens, and nothing else is read from them. + * + * `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` and `data` arrive exactly as Composio sent them inside the same warn-only + * `transform()` as everything else here. An account with no id is the one shape the revoke + * below cannot act on, and {@link withdrawableAccounts} is what says so about it. + * + * THE ROW ITSELF IS AN OBJECT BY CONSTRUCTION, which is why only the fields are in doubt. The + * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a + * row that is not an object raises inside the vendor's code and never reaches this listing. + */ + items: { id?: unknown; data?: unknown }[]; + /** + * The same truncation signal as the auth-config listing above, from the same vendor schema. + * + * `ConnectedAccountListResponseSchema` spells it `nullish` + * (`src/types/connectedAccounts.types.ts:297-303`) and the transformer sets it on every + * answer (`src/utils/transformers/connectedAccounts.ts:109-117`). It matters more here than + * anywhere else in this file: {@link ComposioBroker.revoke} answers `true` for "this + * person's access has ended", and it used to have seen only one page of the accounts it + * would have to end. {@link everyRowOf} follows it until there is none left. + */ + nextCursor?: unknown; + }>; + /** + * Mint one person's connect link against one auth config, with the page to come back to. + * + * `link` RATHER THAN `initiate`, AND RATHER THAN `toolkits.authorize`. All three end at a + * redirect url, and only the choice between them decides whether this deployment keeps working. + * `toolkits.authorize` takes a user id, a toolkit and an optional auth config id and has no + * parameter for a callback at all (`@composio/core` 0.18.1, `src/models/Toolkits.ts:333-338`), + * which is why consent used to end on Composio's hosted page — the address below has nowhere + * to travel on that call. `initiate` does carry one (`:249`), but the endpoint under it is + * retired for Composio-managed OAuth on redirectable schemes — cutover 2026-05-08 for new + * organizations and 2026-07-03 for the rest, after which it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`src/models/ConnectedAccounts.ts:146-160`) + * — and `use_composio_managed_auth` is exactly what {@link ComposioBroker.ensureAuthConfig} + * creates. `link` is the vendor's own named replacement for that combination, carries the + * callback, and answers in the same shape. + * + * TAKING THE AUTH CONFIG ID IS NOT A COST HERE. It is the one thing `toolkits.authorize` was + * doing for us, and it did it by listing the configs and creating one at Composio's managed + * defaults where it found none — which this deployment already does for itself, at enable + * time, named so an operator can find it in their dashboard. The listing below is that same + * read; the creation is not repeated, because an app with no config is a state to report + * rather than one to paper over. + */ + link( + userId: string, + authConfigId: string, + options: { callbackUrl: string }, + ): Promise<{ redirectUrl?: unknown }>; + /** + * Create one person's account from the secret they typed, WITHOUT a consent screen anywhere. + * + * THE RAW CLIENT FOR THE REASON BOTH DELETES ARE RAW, and a sharper one. `@composio/core`'s + * `connectedAccounts.initiate` is the wrapper over this endpoint, and the endpoint under it is + * retired for the managed-auth path — it throws + * `ComposioLegacyConnectedAccountsEndpointRetiredError` (`@composio/core` 0.18.1, + * `src/models/ConnectedAccounts.ts:146-160`) — while `link`, which replaced it, mints a consent + * url and has nowhere to put a typed value at all. What this flow needs is neither: the person + * has already typed their credential into a form here, so there is no screen to send them to + * and nothing to come back from. `@composio/client`'s own create takes the state directly + * (`0.1.0-alpha.76`, `resources/connected-accounts.d.ts:33`, `:7502-7511`), which is the whole + * of what this call is. + * + * NO `validate_credentials`, AND ITS ABSENCE IS DELIBERATE RATHER THAN AN OVERSIGHT. The + * parameter exists on the same body and the vendor marks it EXPERIMENTAL (`:7505-7509`). + * Whether a typed key actually works is settled here by making a call with it rather than by + * asking Composio to grade it, because a connection Composio accepts is not a connection that + * works: a wrong value comes back `ACTIVE`. + * + * `id` AND `status` READ AS `unknown`, for the reason every other vendor field in this + * projection is. The generated client declares both as required + * (`ConnectedAccountCreateResponse`, `:121-146`), which is the schema's promise about what + * Composio means to send rather than a fact about what arrived — and the id is the one field + * this whole call exists to answer, so a missing one is a refusal here rather than an + * `undefined` handed on as the account somebody is meant to be able to undo. + */ + create(body: { + auth_config: { id: string }; + /** + * The person, and the secret they typed, in the shape the vendor's own builder assembles. + * + * `state` IS `unknown` BECAUSE WHAT GOES IN IT IS THE APP'S QUESTION RATHER THAN THIS FILE'S + * ANSWER. The generated client declares it as a fourteen-member union keyed on the scheme, + * each member's `val` carrying whatever fields that app publishes plus `[k: string]: unknown` + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:7551`, `:8083-8086`) + * — so naming a shape here would be this file asserting which boxes an app asks for, which is + * exactly the thing {@link ComposioBroker.connectionFields} exists to go and ask. + */ + connection: { user_id: string; state: unknown }; + }): Promise<{ id?: unknown; status?: unknown }>; + /** + * Delete one connected account, and ask for the grant behind it to be revoked too. + * + * WITHOUT THE FLAG THIS CALL DOES NOT REVOKE ANYTHING, and that is the vendor's own description + * of it: it "soft-deletes a connected account by marking it as deleted in the database", which + * "prevents the account from being used for API calls but preserves the record" + * (`@composio/client` 0.1.0-alpha.76, `resources/connected-accounts.d.ts:59-72`). The refresh + * token at Google or Slack survives that untouched. Every path in this deployment that claims + * to end somebody's access — a person disconnecting, an administrator removing an app, a person + * being offboarded — runs through here, so an unflagged delete made all three of those claims + * false at once and wrote `true` into the audit trail beside them. + * + * WHAT THE FLAG BUYS IS A REQUEST AND NOT A RESULT, which is the whole reason + * {@link ComposioBroker.revoke}'s answer is named the way it is. The upstream revocation runs as + * a background job; the response carries its `revoke_job_id` and the vendor documents that no + * generally available endpoint polls it (`:7447-7459`). Nothing here can say the provider tore + * the refresh token up, and nothing here pretends to. + * + * BUT THE ANSWER DOES SAY WHETHER COMPOSIO DID ITS OWN HALF, AND THAT WAS BEING DISCARDED. + * `ConnectedAccountDeleteResponse` carries a REQUIRED `success: boolean`, "indicates whether + * the connected account was successfully deleted" (`@composio/client` 0.1.0-alpha.76, + * `resources/connected-accounts.d.ts:7445-7459`). This used to be `Promise`, read for + * nothing, on the argument that no field on the answer could support a stronger claim than "we + * asked". That argument is true of the REVOCATION and false of the DELETE: a 200 carrying + * `success: false` is Composio saying it did not delete the account, so the background job the + * flag asks for was never started either — and {@link ComposioBroker.revoke} answered `true` + * over the top of it while the grant stood at the provider. Reading it is not a stronger claim + * than "we asked"; it is the difference between having asked and having been refused. + * + * DECLARED `success?: unknown` RATHER THAN AT THE VENDOR'S OWN `boolean`, for the reason every + * other field in this projection is: the generated client parses the body and hands it over, so + * "required" is the schema's promise about what Composio means to send rather than a fact about + * what arrived. {@link withdrawalDeclined} is where the three answers are told apart. + * + * `revoke_job_id` IS DELIBERATELY NOT READ, and its absence is deliberately not a refusal. The + * same declaration marks it optional and says it is present "only when `revoke_on_delete=true`" + * — which says when it CAN appear, not that it always does — so a guard on it would turn every + * withdrawal Composio accepted into a permanent failure the first time they stopped sending it, + * which is the shape of mistake this file has already made twice in the other direction. + * + * NULLABLE AND OPTIONAL THOUGH THE VENDOR'S OWN DECLARATION IS NEITHER, because the generated + * client has two answers it resolves with no document: a 204 becomes `null` and a JSON reply + * carrying `content-length: 0` becomes `undefined` (`@composio/client` 0.1.0-alpha.76, + * `src/internal/parse.ts:16-42`). Declaring this at the vendor's `ConnectedAccountDeleteResponse` + * would be the same assertion-over-a-wire-value every other field here refuses to make, and it + * would hide the one case {@link withdrawalDeclined} has to tell from a refusal. + */ + delete( + id: string, + params: { revoke_on_delete: true }, + ): Promise<{ success?: unknown } | null | undefined>; + }; +}; + +/** + * The suffix every auth config this deployment creates carries, so a reader can tell whose it is. + * + * An auth config is visible in Composio's own dashboard beside any that were made by hand there, + * and the two are otherwise indistinguishable. The name is the only field this deployment gets to + * choose, so it is where the provenance goes. + */ +export 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. + */ +export const STATUSES_NAMED = 5; + +/** + * Whether this deployment made that auth config, which is the question every decision here turns on. + * + * THE SUFFIX IS WRITTEN FOR EXACTLY THIS AND WAS NOT BEING READ. Both callers used to take the + * first row of an unordered listing, and the two consequences are of different sizes. Removing an + * app deleted whatever came back first — which can be a config an operator built by hand, with + * their own scopes and their own tool restrictions, taking every account on it down with it. + * Beginning a connection attached a person to whatever came back first — which can be a + * configuration nobody here chose and this deployment cannot see or tighten. And because the order + * is the vendor's, the two calls can resolve DIFFERENT rows, so an app could be removed while + * people kept connecting against a config the removal left behind. + * + * MATCHED ON THE END OF THE NAME rather than on the whole of it, because the rest of the name is an + * app's title as an administrator saw it at enable time and titles are edited. The suffix is the + * part this file writes. Trailing whitespace is tolerated for the same reason it is tolerated + * anywhere a human-edited string is compared: a name that picked up a space in a dashboard is the + * same config. + * + * IT TAKES A {@link CheckedAuthConfig} AND NOT A {@link VendorAuthConfig}, which is what makes this + * one line honest. A predicate cannot refuse — it answers true or false — so reading a name the + * vendor may not have sent here could only ever have meant silently answering `false`, and `false` + * from this function means "somebody else's config": untouched by a removal, and satisfying the + * check that stops a second one being created. {@link readableConfigs} is where the absence becomes a + * sentence instead, upstream of every caller. + */ +export function madeHere(config: CheckedAuthConfig): boolean { + return config.name.trimEnd().endsWith(CONFIG_SUFFIX); +} + +/** + * The statuses that answer "is this person connected", which is the narrow question of the two. + * + * ACTIVE ONLY. An `INITIATED` account is somebody who started an authorization and never finished + * it, and an `EXPIRED` or `REVOKED` one is a grant that no longer opens anything; counting any of + * them as connected tells a person their app is wired up and then fails every call they make with + * it. + */ +export 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. + */ +export const REVOCABLE: VendorAccountStatus[] = [ + "INITIALIZING", + "INITIATED", + "ACTIVE", + "FAILED", + "EXPIRED", + "INACTIVE", +]; + +/** + * How long one catalogue answer is served to everybody who asks for it. + * + * THE CATALOGUE IS READ ONCE PER SEARCH KEYSTROKE OTHERWISE, WHICH IS WHAT THIS IS ABOUT. The admin + * picker debounces its search field and then asks `/composio/apps`, and that route filters in this + * process precisely because Composio's toolkit listing takes no search term — so every distinct term + * a person types is another request for the whole directory, a few hundred rows of it, to answer a + * question about one. Typing "linear" pulls the catalogue four or five times, and enabling the app + * afterwards pulls it once more. + * + * TEN MINUTES BECAUSE OF WHAT GOES STALE IN IT. The rows are Composio's published toolkits: an app + * is added to their catalogue or its action count moves every so often, never within one + * administrator's sitting, and the worst a stale row can do here is show a description or a count + * that is a few minutes behind. Held for a working session it would be a cache nobody could explain + * to an operator whose new app is missing; held for seconds it would not survive the debounce it + * exists for. + */ +export 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` AND `connection` ARE BOTH COPIED, because a shallow spread of the row would hand + * either of them on by reference. This used to copy the array and say it was "the one field here + * that is not a primitive", which is what let the other one through: {@link BrokerConnection} is an + * object in every one of its five shapes, so the row's spread carried one object out of the cache + * and into every caller for the whole ten minutes — the exact sharing this function exists to end, + * surviving in the field it is worst in. `connection` is what the app picker hides an `unsupported` + * app by and what the enable route branches on to decide whether to create an authorization config + * at all, so a caller that edited the object it was handed would be editing what those two + * decisions read of every later caller's rows. + * + * SPREAD RATHER THAN NAMED PER KIND, so that adding a member to the union — or a field to one of + * them — cannot quietly reintroduce the sharing. The spread of a union widens to the union, which + * is the one shape here a `switch` on `kind` would have to be kept in step with by hand. + * + * 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. + */ +export function copyOf(apps: Promise): Promise { + return apps.then((held) => + held.map((app) => ({ + ...app, + categories: [...app.categories], + connection: { ...app.connection }, + })), + ); +} diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts new file mode 100644 index 000000000..cc8f4e9d4 --- /dev/null +++ b/server/src/plugins/composio-vendor.ts @@ -0,0 +1,1812 @@ +import { + type BrokerApp, + type BrokerConnection, + BrokerRefusalError, + type FieldScheme, + flagOf, + isFieldScheme, +} from "./broker"; +import { type ComposioAction, vendorSentence } from "./composio"; +import type { AccountRef } from "./shared-accounts"; + +/** + * What Composio's API hands back, and how it is read: the vendor shapes, the parsers that turn a + * response into OpenBot's own types, paging, and the mapping from a vendor's refusal to a sentence. + * + * Split out of `./composio-adapter`, which keeps the SDK client and the methods it is called + * through. Nothing here imports `@composio/core`: these read plain objects, so they are exercised + * with literals, and the adapter remains the one file under `server/src` that imports the SDK. + */ + +/** + * One tool as the SDK's SINGLE-TOOL call hands it over, in as much detail as anything here reads. + * + * Declared structurally rather than imported as `Tool`, for the same reason the seams it feeds are + * structural: a field this file does not read is a field a vendor rename cannot break. `toolkit` + * is optional because the SDK spells it optional — see {@link ComposioActions.execute} below for + * what is done when it is in fact missing. + * + * THIS IS THE ONE ROW THE SDK STILL VALIDATES, AND IT IS NO LONGER TWO. `Tools.transformToolCases` + * ends in `ToolSchema.parse(...)` (`@composio/core` 0.18.1, `src/models/Tools.ts:193`), a throwing + * parse rather than the warn-only `transform()` every other answer here goes through, and + * `getRawComposioToolBySlug` runs it (`:719`). The LISTING used to as well (`:561`) and does not + * any more: it reads {@link VendorToolRow} off the raw client, because the wrapper that ran the + * parse is also the wrapper that could not be paged. So `ToolkitSchema` spelling that inner `slug` + * required (`src/types/tool.types.ts:12-16`) is a guarantee this declaration may still rest on, + * and it is a guarantee about exactly one call. + * + * WHAT THIS FILE HANDS ON IS STILL `unknown`, WHICH IS WHERE THAT ARGUMENT ALWAYS STOPPED BEING + * TRUE. A parse is a fact about one method of one version of one package, and what these + * declarations govern is {@link ComposioVendor} — the seam a test satisfies with a literal and the + * shape the next version will be read against. Running it shows the gap is not academic: a + * `description` of 42 crosses into `./composio` as the `string` this said it was and reaches + * `.replaceAll` in `./store` as a bare `TypeError`. A declaration is an assertion and not a check. + */ +export type VendorTool = { + slug: string; + description?: unknown; + inputParameters?: unknown; + tags?: string[]; + version?: unknown; + toolkit?: { slug: string }; +}; + +/** + * One tool as the LISTING hands it over, which is the wire's own spelling and nobody's parse. + * + * SNAKE_CASE BECAUSE THIS IS COMPOSIO'S ANSWER RATHER THAN THE SDK'S RESTATEMENT OF IT. The listing + * reads `client.tools.list` directly — see {@link ComposioVendor} for why it has to — so nothing + * renames `input_parameters` on the way here and nothing runs `ToolSchema` over it. Both halves of + * that are deliberate. The rename was never a service: `transformToolCases` re-spelled the field + * and `ToolSchema.parse` then STRIPPED every schema key its `ParametersSchema` did not name — + * `if`, `then`, `else`, `examples`, every `x-` extension at the root, and `deprecated` and + * `contentEncoding` per property (`@composio/core` 0.18.1, `src/types/tool.types.ts:77-174`) — + * before any caller could see them. What a model is shown is now what Composio published; see + * {@link ComposioAction.inputParameters}, where that loss was written down as unavoidable. + * + * AND EVERY FIELD IS DECLARED AT WHAT THE WIRE CAN HOLD, because with the parse gone there is + * nothing between Composio and {@link actionOf} at all. `slug` widens for exactly that reason: + * `ToolSchema` required it and nothing does now, and `actionOf` was already checking it anyway. + * `tags` stays narrow on the same argument it always stood on, which never involved the parse — + * `./composio` refuses a `tags` that is not a list of labels where it reads them, container and + * contents both, and a check on both sides of one seam is a check nobody maintains. + */ +export type VendorToolRow = { + slug?: unknown; + description?: unknown; + input_parameters?: unknown; + tags?: string[]; + version?: unknown; +}; + +/** + * One catalogue row as the vendor hands it over, which is the wire's own spelling and nobody's map. + * + * `meta` is where all of it lives and every field of it is optional, which is not the SDK being + * cautious: Composio genuinely publishes toolkits with no logo, no description and no category. + * See {@link ComposioBroker.listApps} below for what each absence becomes. + * + * SNAKE_CASE AND `unknown` THROUGHOUT, BECAUSE THE TRANSFORMER THIS WAS WRITTEN AGAINST IS GONE. + * The catalogue reads `client.toolkits.list` directly — see {@link ComposioVendor} for why it has + * to — so `transformToolkitListResponse` no longer stands between Composio and {@link appOf}, and + * three things it was doing have to be accounted for rather than assumed. + * + * IT RENAMED, so the count is `tools_count` here and the category's own word is `name` + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-435`). That rename was the one + * thing in this projection easiest to get silently wrong — a count read off the wrong key is a + * plausible zero rather than an error — which is why {@link appOf} is where it is read and why a + * test asserts the figure rather than the field. + * + * IT REBUILT `meta` AND THE `categories` LIST, spreading each into a fresh literal and mapping + * every entry (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:21-34`). Those were + * this file's two structural guarantees and they were real: a `meta` of null and a `categories` of + * "crm" each raised a `TypeError` from that line when 0.18.1 was run, which is why neither was + * declared `unknown` and neither was checked. Nothing raises now. A `meta` that is a string reads + * as an app with no description, no logo, no categories and no count, and a `categories` that is a + * string reads as an app in no category — two silent, plausible answers about a real app. Both are + * declared at what the wire can hold and both are refused in {@link appOf}. + * + * AND IT NEVER VALIDATED, which is the part that does not change. `transform()` checks with + * `safeParse` and, where that fails, logs a warning and returns the unvalidated object anyway + * (`src/utils/transform.ts:26-36`), so `ToolKitItemSchema` spelling `name` required and the count a + * number always described the answer Composio MEANS to send rather than the one that arrived. Every + * wire-valued field was already `unknown` on that argument and stays so: the slug, which is the + * only name this deployment has for an app; the name, which is the only thing to show a person + * choosing between apps; the description, the logo, each category's word, and the count. + */ +export type VendorToolkit = { + slug?: unknown; + name?: unknown; + meta?: unknown; + no_auth?: unknown; + auth_schemes?: unknown; + composio_managed_auth_schemes?: unknown; +}; + +/** + * One toolkit read on its own, which this file wants for exactly one thing: what it asks a person. + * + * ONE FIELD, BECAUSE ONE FIELD IS WHAT IS READ. The per-app retrieve answers everything the + * catalogue row does and a good deal more, and naming any of it here would be this file declaring + * knowledge of a shape nothing below opens. + * + * `unknown` FOR THE REASON EVERY OTHER VENDOR TYPE IN THIS FILE SAYS SO. What hangs off + * `auth_config_details` is a list of modes, each carrying the fields its scheme wants, and every + * one of those is copied across verbatim — so the declaration would be an assertion about the wire + * rather than a fact about it. {@link ComposioBroker.connectionFields} reads it a step at a time, + * and refuses at whichever step it stops being able to read rather than showing the shorter form + * that is left. + */ +export type VendorToolkitDetail = { + auth_config_details?: unknown; +}; + +/** + * One auth config as the vendor hands it over, which is three fields because all three decide. + * + * `name` IS THE ONLY PROVENANCE THERE IS. Composio publishes no field saying which client created a + * config, and the listing is scoped to the project rather than to this deployment, so a config an + * operator made by hand in the dashboard comes back beside the ones made here and is otherwise + * identical. The name is the one field this deployment chooses, which is why {@link CONFIG_SUFFIX} + * is written into it and why every decision below reads it. + * + * `status` because a DISABLED config is still a config: it answers the listing, it satisfies the + * "does one exist" question, and a connect link minted against it does not work. The two facts have + * to be separable or an app with a disabled config reads as an app that is ready. + * + * BOTH OF THOSE ARE DECLARED AS THE WIRE CAN SEND THEM RATHER THAN AS THE SDK SPELLS THEM, for the + * reason the toolkit row above gives at length: `transformAuthConfigRetrieveResponse` copies + * `name` and `status` across verbatim inside a warn-only `transform()` + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:29-58`), so + * `AuthConfigRetrieveResponseSchema` requiring a string name and an `ENABLED`/`DISABLED` enum is + * not something this file can rest on. A null name reaches {@link madeHere}, and a status the + * enum does not contain reaches the choice of config to connect against — where "not ENABLED" and + * "disabled" are different facts and only one of them is worth telling an operator. + * + * `id` IS DECLARED THE SAME WAY, AND THE GUARD IT WAS WAITING FOR IS WRITTEN. {@link readableConfigs} makes + * that check, so the declaration no longer claims more than the wire promises. It is the one of the + * three whose absence sends a request: a delete named with `undefined` asks Composio to remove + * whatever it cares to, and this deployment then records that the app was withdrawn. + * + * THE ROW BEING AN OBJECT AT ALL IS THE ONE THING THAT IS NOT IN DOUBT. + * `transformAuthConfigRetrieveResponse` reads `authConfig.toolkit.logo` while building every row + * (`@composio/core` 0.18.1, `src/utils/transformers/authConfigs.ts:41`), so a row that is not an + * object raises a `TypeError` inside the vendor's own code and never arrives. All three fields + * below are `unknown` for the opposite reason: the same function copies them across verbatim. + */ +export type VendorAuthConfig = { + id?: unknown; + name?: unknown; + status?: unknown; +}; + +/** + * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. + * + * The whole enum is named rather than the two or three in use, because the point of the two lists + * below is that they are CHOICES: a reader comparing them can see which statuses each question + * leaves out, and a status added by a vendor version shows up here as a name nothing mentions + * rather than as an answer that quietly got narrower. Written as literals for the reason the + * previous `"ACTIVE"[]` was: the vendor's parameter is an enum and a widened `string[]` does not + * satisfy it. + */ +export type VendorAccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + +/* + * WHERE THE LINE BETWEEN "READ AT ITS TYPE" AND "READ OUT OF `unknown`" IS DRAWN, AND WHY IT MOVED. + * + * Everything below used to be read out of `unknown` — containers and fields alike — on one argument: + * `transform()` validates with `safeParse`, logs a warning where it fails, and returns the + * unvalidated object anyway (`@composio/core` 0.18.1, `src/utils/transform.ts:26-36`), so a + * TypeScript declaration over a wire value is an assertion and not a check. + * + * THAT ARGUMENT IS TRUE OF THE FIELDS AND FALSE OF THE SHAPES AROUND THEM, which a round of running + * the SDK against malformed answers established rather than reasoned about. `transform()` returns + * whatever its TRANSFORMER built, and every one of these transformers builds its result by + * dereferencing the raw answer — `response.items.map(...)`, `item.meta.categories`, + * `authConfig.toolkit.logo`, `response.auth_config.id`. So the containers and the rows inside them + * are the vendor's own construction and cannot arrive malformed; only the values copied ACROSS + * those lines can, and those are exactly the fields declared `unknown` above. Guards were written + * for both halves, and the ones covering the shapes were branches no answer could reach, standing + * where the next reader would take them for what was keeping them safe. + * + * WHAT KEEPS THEM SAFE IS `askVendor`. A shape the SDK could not read raises inside the SDK, and + * the `TypeError` row in {@link vendorRefusal} turns that into a sentence — one guard, at the layer + * where the fault actually surfaces, covering every shape rather than the four somebody listed. + * + * REFUSING RATHER THAN FILLING IN, WHICH IS THE WHOLE OF THE ARGUMENT FOR THE FIELDS. A `?? ""`, a + * `String(x)` or a cast does not make a malformed answer safe; it converts a fault this deployment + * could have reported into an answer it gives wrongly, and the wrong answers are not small ones — + * an app with no name in an administrator's picker, an action with no slug put in front of a model, + * and the one that was actually happening: a delete sent with `undefined` where an account id belongs, + * answered by Composio however it likes, after which the audit trail records that a person's access + * was withdrawn and nothing had been. Every reader below therefore answers null on anything it + * cannot read, and every caller turns that null into a sentence naming what Composio sent. + */ + +/** + * The remedy every shape refusal here ends with, because it is the same act in every one of them. + * + * None of these is a misconfiguration. The key is right, the request is right and the answer + * arrived; what changed is the shape of it, which is a thing nobody operating this deployment can + * correct from any page it has. Saying so is the difference between an operator reading their own + * settings for an hour and an operator upgrading a package. + */ +export const VENDOR_SHAPE_REMEDY = + "That is a change in what Composio answers rather than a setting an operator can correct, so upgrading this deployment's @composio/core is what fixes it."; + +/** + * WHAT COMPOSIO PUT SOMEWHERE, NAMED IN A SENTENCE A READER CAN ACT ON. + * + * Every refusal below says what arrived where something else belonged, because "Composio's answer + * was not a shape this deployment reads" sends whoever is holding the page looking through a vendor + * dashboard with nothing to look for. + * + * THE VALUE IS NEVER QUOTED, AND THAT IS THE POINT OF THE FUNCTION RATHER THAN AN INTERPOLATION. A + * listing row carries a person's mailbox address, an account handle and whatever else the vendor + * chose to put on it, and a refusal from here is read off an admin page, written into an app's + * `lastError` and put in front of a model. The shape is the part that is safe to say and is also + * the only part that helps: a reader who knows a list arrived where an object belongs knows which + * vendor change they are looking at. + * + * AND THE EMPTINESS IT REPORTS IS THE ONE THE CALLER JUDGED, WHICH IT WAS NOT. Every caller reaches + * this function on the branch {@link textOf} sent it down, and `textOf` decides on the TRIMMED + * value while this tested `value === ""` — so a padded blank, which is the shape a wire value + * actually arrives in, was refused for being empty and then described as "a string". "Composio sent + * a string where the id belongs" is a sentence with no finding in it: a string is what an id IS, so + * the reader is told the field was right and the call refused anyway. The two branches now agree on + * what blank means, and they say which of the two blanks arrived, because an id that is three + * spaces and an id that is absent are different things to go looking at in a dashboard. + */ +export function sent(value: unknown): string { + if (value === undefined) return "nothing"; + if (value === null) return "null"; + if (Array.isArray(value)) return "a list"; + if (typeof value === "string") { + if (value === "") return "an empty string"; + return value.trim() === "" ? "a string of blank space" : "a string"; + } + if (typeof value === "object") return "an object"; + return `a ${typeof value}`; +} + +/** + * A vendor status named as itself, because an enum value is the one wire value worth quoting. + * + * {@link sent} withholds what it is given for a reason that does not reach here: an auth config's + * status is one of a closed set of vendor enum names, carries nobody's data, and IS the finding — + * "Composio called it PENDING" is something an operator can search their dashboard and the vendor's + * changelog for, where "Composio sent a string" is something they can only shrug at. + * + * AND THE ARGUMENT ONLY HOLDS WHILE THE VALUE IS ACTUALLY ONE OF THOSE NAMES, which is the hole + * this closes. Every caller reaches this function on the branch taken precisely BECAUSE the value + * is not one of the words the code expects — so what arrives is not "an enum name Composio has + * added", it is whatever came off the wire: a gateway's HTML error page, a stack trace, a sentence + * carrying a person's mailbox address, a megabyte of it. That string was interpolated whole into a + * refusal that is read off an admin page, written into an app's `lastError` and put in front of a + * model. + * + * SO THE SHAPE OF AN ENUM NAME IS THE TEST, and anything that is not one is described by + * {@link sent} like every other wire value in this file. A vendor enum name is a short run of + * letters, digits and underscores; nothing that fails that is a word an operator could search a + * changelog for, which was the entire argument for quoting it. + */ +const VENDOR_ENUM_NAME = /^[A-Za-z0-9_]{1,40}$/; + +export function named(value: unknown): string { + const text = typeof value === "string" ? value.trim() : ""; + return VENDOR_ENUM_NAME.test(text) ? `"${text}"` : sent(value); +} + +/* + * THE CONTAINER OF A LISTING IS THE ONE THING BELOW THAT IS NOT READ OUT OF `unknown`, AND THE + * REASON IS THAT THE SDK PROVES IT. + * + * There used to be a `hasFields` predicate and an `itemsOf` reader here, and every listing passed + * its answer through them before touching a field — on the argument the row types give at length, + * that a TypeScript interface over a wire value is an assertion rather than a check. That argument + * is correct about the FIELDS and wrong about the CONTAINER, which running `@composio/core` 0.18.1 + * settles rather than reasons about. Every list transformer dereferences the answer before + * returning it: `response.items.map(...)` in `transformAuthConfigListResponse` + * (`src/utils/transformers/authConfigs.ts:79`), in `transformConnectedAccountListResponse` + * (`connectedAccounts.ts:113`). So a container of the wrong shape — null, a bare list where an + * envelope belongs, an `items` that is a string — dies inside the vendor's code and NEVER arrives + * here. Each of those guards was therefore a branch no input could reach, sitting where the next + * reader would take it for the thing keeping them safe. + * + * WHAT KEEPS THEM SAFE IS ONE LAYER DOWN NOW. The vendor's crash is a bare `TypeError`, and + * {@link vendorRefusal} translates it into a sentence naming what did not happen and the one act + * that changes it — which catches every malformed container, including the shapes nobody here + * thought to enumerate. + * + * AND IT IS TWO OF THE FOUR LISTINGS NOW RATHER THAN ALL OF THEM, WHICH IS THE COST OF PAGING THE + * OTHER TWO. The catalogue and the action listing read `@composio/client` directly — a generated + * client that parses the body and returns it — so nothing dereferences their answers before this + * file does. The same two shapes therefore reach this code rather than dying in the vendor's, and + * {@link pageOf} answers them at exactly those two call sites. The argument above still holds + * everywhere it is made: a guard is written where an input can reach it and nowhere else. + * + * SO THE DECLARATIONS BELOW ARE READ AT THEIR TYPES, and each one says which vendor line makes it + * true. The fields inside them stay `unknown`, because the warn-only `transform()` really does copy + * those across whatever they turn out to be. + */ + +/** + * One field as the non-empty string it has to be, or null where the vendor sent anything else. + * + * EMPTY COUNTS AS ABSENT because every caller of this reads an identifier — a slug, an id, a name + * this file matches a suffix against — and an empty identifier is unusable in exactly the way a + * missing one is, while being the one that reads as present at every glance. + * + * AND THE STRING THAT COMES BACK IS THE ONE THAT WAS JUDGED, which it was not. This decided + * emptiness on the TRIMMED value and answered the PADDED one, so " " was correctly refused while + * " ac_1 " was accepted and handed on with its spaces — a guard that checked one thing and passed + * along another. What that reached is the whole of this file: a padded id is what an auth-config + * delete and an account withdrawal NAME, so Composio is asked to remove an object nobody has; + * a padded slug is what an enabled app records into its url and what an action list writes into + * `mcp_tools`; and a padded app slug on the vendor's own answer compares unequal to the app the + * caller was gated on, refusing a call that was about the right app the whole time. Trimming is + * not tidying here — it is answering with the identifier rather than with the identifier plus + * whatever the wire wrapped it in. + */ +export function textOf(value: unknown): string | null { + if (typeof value !== "string") return null; + const text = value.trim(); + return text === "" ? null : text; +} + +/** + * Who an error sentence is about, named the way the holder it acts for would read it. + * + * A DEPLOYMENT HOLDS NO "PERSON" TO BE TOLD ABOUT. The sentences below were all written for a + * person pressing Connect or Disconnect on their own settings page, and every one of them says so + * in the words "this person" — which is simply wrong where the account acted for is the + * deployment's own shared one, with nobody's personal credential anywhere behind it. + */ +export const whose = (account: AccountRef) => + account.holder === "deployment" + ? "this deployment's shared account" + : "this person"; + +/** + * How many pages of one listing this deployment will read before it stops and says so. + * + * THE BOUND IS AGAINST A VENDOR THAT NEVER STOPS, not against a large answer. What it guards is a + * cursor that keeps being handed back, which without a ceiling is a request that never returns: a + * person waiting on a page they pressed disconnect from, and a process holding every row it has + * read so far. + * + * REACHING IT IS A REFUSAL AND NEVER A TRUNCATION, which is the property the whole guard exists for + * — see {@link everyRowOf}. A ceiling that answered with what it had would be the page ceiling + * again, one order of magnitude further out and harder to notice. + * + * IT WAS 50, AND 50 WAS ARGUED FROM TWO NARROW LISTINGS THAT ARE NO LONGER THE ONLY ONES. The + * number was justified by one app's authorization configs and one person's accounts for one app: + * at {@link LISTING_LIMIT} rows a page a second page is extraordinary there and a fiftieth is not + * a data set. The app CATALOGUE is not that listing. Composio publishes more than + * {@link LISTING_LIMIT} toolkits today, so it is a listing whose SECOND page is the ordinary case, + * and a ceiling reasoned about from the narrow two would be sitting on top of a healthy answer + * rather than above it. + * + * 200 IS CHOSEN AGAINST THE PAGE THE VENDOR MIGHT ACTUALLY SEND rather than the one asked for, and + * that is the whole of the arithmetic. Asking for {@link LISTING_LIMIT} does not oblige Composio to + * answer with it — the cursor is documented as "a base64 encoded string of the page and limit" + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:469-478`), so the page size is the + * vendor's to settle. At the page asked for, 200 is 200,000 rows, two orders of magnitude past any + * catalogue Composio has published. At the vendor's OWN default page of twenty it is 4,000 rows, + * which still clears today's catalogue with room — where 50 pages of twenty is 1,000, which is + * today's catalogue exactly, and a ceiling that lands on the real answer is a healthy vendor turned + * into a refusal. And 200 sequential requests is still a request that ends. + * + * ONE NUMBER FOR ALL FOUR LISTINGS, because the two narrow ones lose nothing by it: they refuse a + * runaway cursor after 200 pages instead of 50, and there is no state in which a real answer to + * either of those questions is even a second page. A per-listing ceiling would be a second number + * to reason about in exchange for tightening a bound that nothing genuine approaches. + * + * AND IT IS THE NUMBER OF PAGES THAT ARE READ, WHICH IS NOT WHAT IT USED TO BE. The test stood + * ahead of the line recording the page it was counting, so the set held one fewer than had + * arrived and the refusal fired on the two-hundred-FIRST page while telling its reader two hundred. + * A ceiling is a number somebody reasons about; stating one and doing another makes it the one + * number here nobody can check. + */ +const PAGE_CEILING = 200; + +/** + * The listing a refusal is about, as the two clauses every sentence below is built from. + * + * WRITTEN AT THE CALL SITE for the same reason {@link VendorCall}'s outcome is: what could not be + * told is a fact about the question being asked — "whether this person is connected" is not + * something {@link everyRowOf} can know — and composing it beside the call keeps it true. + */ +export type Listing = { + /** The listing as a noun phrase: "this person's gmail accounts". */ + noun: string; + /** What could not be told, as a clause following "so": "whether one exists could not be read". */ + consequence: string; +}; + +/** + * EVERY ROW OF A LISTING THE VENDOR PAGES, or a refusal rather than a fragment read as the whole. + * + * EVERY LISTING IN THIS FILE COMES THROUGH HERE NOW, AND TWO OF THEM USED TO REFUSE INSTEAD. This + * paragraph said that `fetchDirectory` met a full page and refused, that `./composio` did the same + * with a full action listing, and that both were right to: "the SDK offers no cursor to ask for a + * second page with, so an answer at the ceiling and an answer past it are indistinguishable and no + * second request could tell them apart". The reasoning was sound and the premise was wrong. It was + * a fact about the WRAPPER — `ToolListParamsSchema` names no cursor and + * `transformToolkitListResponse` drops the response's — and never about the request, which the raw + * client has always been able to compose: both list params carry `cursor` and both responses carry + * `next_cursor` (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:467-478` and + * `:322-326`, `resources/tools.d.ts:421-432` and `:200-204`). The cost of the mistake was not + * theoretical: Composio publishes more than {@link LISTING_LIMIT} toolkits, so the catalogue + * refusal fired on the first call every time and the app picker showed an operator nothing at all. + * See {@link ComposioVendor}, where both of those listings now name the raw client. + * + * THE OTHER TWO WERE ALWAYS EXPRESSIBLE THROUGH THE WRAPPER: `AuthConfigListParamsSchema` and + * `ConnectedAccountListParamsSchema` both name a `cursor` (`@composio/core` 0.18.1, + * `src/types/authConfigs.types.ts:124-131`, `src/types/connectedAccounts.types.ts:259-266`), both + * models forward it (`src/models/AuthConfigs.ts:95`, `src/models/ConnectedAccounts.ts:118`) and + * both transformers fill `nextCursor` in from the response's `next_cursor` + * (`src/utils/transformers/authConfigs.ts:80`, `connectedAccounts.ts:116`). + * + * AND THE CALLER THAT DECIDES IT IS `revoke`. Refusing a truncated listing would be honest and + * would also mean that the person it happened to could never disconnect: every attempt would meet + * the same page and the same refusal, with their grants standing the whole time. The removal of an + * app is the same shape one level up. Reading the rest is the answer that finishes the job, and + * refusing is what is left for the cases where reading the rest is not possible — which is what the + * three refusals below are, and why none of them can be reached by a caller carrying a partial + * answer that reports itself complete. + * + * THE FIRST REQUEST CARRIES NO CURSOR FIELD AT ALL rather than an undefined one, which is what + * {@link everyRowOf}'s callers spread for: a `cursor: undefined` would reach the vendor's `parse` + * as a key, and an explicit undefined is not something this file needs to make the SDK have an + * opinion about. + * + * THERE WAS A FOURTH REFUSAL HERE AND IT MOVED RATHER THAN DIED, which is the correction the raw + * client forces. It stood at the top of this loop for a page that is not an envelope at all, and + * for the two SDK listings no answer can reach it: both transformers begin `response.items.map(...)` + * (`src/utils/transformers/authConfigs.ts:79`, `connectedAccounts.ts:113`), so a null, a bare list + * and an `items` that is not one all raise a `TypeError` inside the vendor's own code, answered + * where it happens — see the `TypeError` row in {@link vendorRefusal}. Nothing dereferences the + * answer on the two RAW listings, so for those the same shapes reach this loop, and + * `rows.push(...answered.items)` over a string is "string is not iterable" with nothing in it a + * person can act on. {@link pageOf} is where that is answered, at the two call sites that need it, + * rather than as a branch every listing pays for and two of them cannot reach. + * + * What is left below are the three faults every one of the four can hand over, all of them about + * the cursor, because the cursor is the one field nothing on any of these paths checks. + * + * AND A QUESTION ALREADY ANSWERED STOPS HERE, WHICH IS WHAT `enough` IS FOR. Paging made three of + * this file's answers complete and made one of them FAILABLE: {@link ComposioBroker.isConnected} + * returns a boolean, and the first page carrying a single row has settled it — no cursor Composio + * could send next, and no fiftieth page, can turn that `true` into anything else. Reading on + * anyway put the two cursor refusals and the page ceiling in front of a person whose account had + * already been found, so a fault on a page nobody needed decided the answer to a question nobody + * still had. The default reads every page, because that is what a withdrawal and a config listing + * genuinely need; a caller that says when it has enough is a caller that cannot be failed after it + * has its answer. + * + * CHECKED WHERE THE ROWS LAND AND BEFORE THE CURSOR IS LOOKED AT, deliberately. Reading the cursor + * first and stopping afterwards would keep every one of the three refusals reachable on the page + * that already answered the question, which is the whole of what this closes. + */ +export async function everyRowOf( + listing: Listing, + page: ( + cursor: string | undefined, + ) => Promise<{ items: Row[]; nextCursor?: unknown }>, + enough: (rows: Row[]) => boolean = () => false, +): Promise { + const rows: Row[] = []; + const followed = new Set(); + let cursor: string | undefined; + + for (;;) { + const answered = await page(cursor); + rows.push(...answered.items); + if (enough(rows)) return rows; + + /* + * ABSENT AND NULL BOTH MEAN THE END. The auth-config schema spells the field nullable and the + * connected-account one spells it nullish, and a transformer that met no `next_cursor` writes + * `response.next_cursor ?? null`. + * + * AND SO DOES A CURSOR WITH NOTHING IN IT, WHICH IS THE CORRECTION AND WAS THE COSTLIEST + * REFUSAL IN THIS FILE. The claim above used to be that absent and null are "the two the vendor + * actually sends" — which is not something this deployment can know, and the installed types + * say otherwise: all four list responses declare `next_cursor?: string | null` + * (`@composio/client` 0.1.0-alpha.76, `resources/auth-configs.d.ts:248`, + * `connected-accounts.d.ts:4987`, `toolkits.d.ts:326`, `tools.d.ts:204`), so `""` is type-legal + * on the wire, and `?? null` does not catch it. It therefore arrived here, failed + * {@link textOf}, and became a refusal — one that kills `revoke`, `authorize`, + * `ensureAuthConfig`, `deleteAuthConfig` and `isConnected` for EVERY app at once, permanently, + * over a field whose whole content is that there is nothing in it. + * + * A CURSOR NAMES A POSITION, AND THE BLANK ONE NAMES NONE. It is exactly what this loop sends + * when it has no position — the first request omits the field — so following it would ask for + * page one again, and the repeat guard below would then answer the vendor's empty string with + * a sentence accusing it of sending the same page twice. There is no reading of `""` under + * which a second request could reach anything the first did not. So it is the end of the + * listing, which is the same rule {@link textOf} already applies to every other identifier + * here, applied to the one field that had been left out of it. + * + * WHICH IS NOT COERCION, AND THE DIFFERENCE IS THE TEST BELOW IT. A cursor that is a number, an + * object or a list is a position this deployment cannot express and CANNOT rule out being real, + * so it is still the refusal it always was: one page read as the whole answer is the mistake + * this function exists to prevent. What changed is only the string that says nothing. + */ + const next = answered.nextCursor; + if (next === undefined || next === null) return rows; + + const follow = textOf(next); + if (follow === null) { + if (typeof next === "string") return rows; + throw new BrokerRefusalError( + `Composio sent ${sent(next)} where the cursor to the next page of ${listing.noun} belongs, so ${listing.consequence}: there are more of them than arrived and no cursor this deployment can ask for the rest with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * A CURSOR ALREADY FOLLOWED IS A LOOP AND NOT A PAGE. Nothing here can tell a vendor bug from a + * proxy answering from a cache, and both end the same way: the same rows for ever. Refusing on + * the second sight of one is what keeps a person's disconnect a request that returns. + */ + if (followed.has(follow)) { + throw new BrokerRefusalError( + `Composio answered the same page of ${listing.noun} twice, so ${listing.consequence}: following its cursor did not advance, so the rest of them cannot be reached. ${VENDOR_SHAPE_REMEDY}`, + ); + } + followed.add(follow); + + /* + * COUNTED AFTER THE PAGE IS RECORDED, so the number in the sentence is the number of pages that + * happened — see {@link PAGE_CEILING} for the off-by-one this order closes. + * + * AND THE ROWS ARE THE ONES THAT ARRIVED. The sentence asserted `at ${LISTING_LIMIT} rows each`, + * which is the page this deployment ASKED FOR and not one thing it measured: Composio is free + * to answer fifty pages of one row, and a reader told the listing was fifty thousand rows long + * would be reading a figure nothing had counted. What is said now is what was collected. + */ + if (followed.size >= PAGE_CEILING) { + throw new BrokerRefusalError( + `Composio has answered ${PAGE_CEILING} pages of ${listing.noun}, ${rows.length} rows in all, and is still offering another, so ${listing.consequence}: this deployment stops there rather than read on, because what is left cannot be told from a listing that never ends. ${VENDOR_SHAPE_REMEDY}`, + ); + } + cursor = follow; + } +} + +/** + * ONE PAGE OFF THE RAW CLIENT, CHECKED FOR BEING A PAGE, in the shape {@link everyRowOf} reads. + * + * THE TWO SDK LISTINGS DO NOT NEED THIS AND THE TWO RAW ONES CANNOT DO WITHOUT IT. Every answer the + * wrapper hands over has already been dereferenced inside the vendor's package — both transformers + * open with `response.items.map(...)` — so a malformed envelope there is a `TypeError` raised + * inside `@composio/core` and translated by {@link vendorRefusal} with a sentence about a package + * upgrade. `@composio/client` is a generated client: it parses the body and returns it. Nothing + * looks at `items` before this file does. + * + * SO THE TWO SHAPES THE WRAPPER USED TO CATCH ARE CAUGHT HERE, and they are the same two: + * an answer that is not an envelope, and an `items` that is not a list of rows. Both would + * otherwise reach `rows.push(...answered.items)` as a bare `TypeError` naming a vendor field — + * the crash-wearing-a-refusal's-clothes that every sentence in this file exists not to be. + * + * OUTSIDE {@link askVendor} RATHER THAN INSIDE IT, deliberately, and it is the reason this is a + * function rather than four lines at each call site. That wrapper goes around the `await vendor.*` + * AND NOTHING ELSE, which is what makes "a throw reaching `vendorRefusal` came out of the vendor's + * code" true by construction; a refusal composed here is this file's own reading, and it already + * carries an authored sentence. + * + * THE CURSOR IS RENAMED AND NOT READ. `next_cursor` is the wire's spelling and `nextCursor` is what + * {@link everyRowOf} looks for, and it is carried across as `unknown` — every check on it belongs + * there, where the three faults it can carry are enumerated and answered together for all four + * listings. + */ +export async function pageOf( + listing: Listing, + ask: () => Promise<{ items?: unknown; next_cursor?: unknown } | null>, +): Promise<{ items: Row[]; nextCursor?: unknown }> { + const answered = await ask(); + /* + * A BARE LIST IS EXCLUDED HERE AND NOT LEFT TO THE `items` GUARD, which is the same correction + * every other container in this file has already had. `typeof [] === "object"` and `[] !== null`, + * so a page that arrived as a bare list satisfied this test, fell through to the one below, and + * was refused with "Composio sent nothing where the rows of the app catalogue belong" — a sentence + * about an envelope whose rows did not arrive, told of an answer that held no envelope at all. An + * operator reading it goes looking for a listing that came back short; there was no listing. + * + * AND IT IS THE LIKELIER OF THE TWO SHAPES RATHER THAN AN EXOTIC ONE. The SDK transformers these + * two listings stopped going through returned bare arrays, so "a list where a page belongs" is + * precisely what a drift back towards them would put on the wire — the one shape this guard's own + * sentence is written for and the one it could not see. + */ + if ( + answered === null || + typeof answered !== "object" || + Array.isArray(answered) + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(answered)} where a page of ${listing.noun} belongs, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + if (!Array.isArray(answered.items)) { + throw new BrokerRefusalError( + `Composio sent ${sent(answered.items)} where the rows of ${listing.noun} belong, so ${listing.consequence}: what came back is not a listing at all. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return { items: answered.items as Row[], nextCursor: answered.next_cursor }; +} + +/** + * Where each field scheme sits in the order a person would rather meet them. + * + * A RECORD KEYED BY {@link FieldScheme} RATHER THAN A LIST OF THEM, because the compiler counts the + * keys of a record and counts nothing at all about a list. This was a `readonly FieldScheme[]` + * spelling the same four names a second time, which is precisely what the comment on + * {@link FIELD_SCHEME_NAMES} argues against: the same set stated twice, where a member added to one + * of them and not the other typechecks perfectly. An element type refuses a name that is not a + * scheme and says nothing whatever about a scheme left out. + * + * AND THE ONE LEFT OUT FAILS OPEN, MORE QUIETLY HERE THAN THERE. A fifth scheme added to the names + * and not given a rank here would match nothing in the pick below, so every app publishing it would + * fall through to `unsupported` — and the directory route hides an unsupported app, so those apps + * would simply leave the picker: no refusal for anyone to read, no operator sentence, and no failing + * test. `Record` does not compile until the new scheme has a place, which is + * the whole of the protection, and the reason the pick goes through {@link isFieldScheme} rather + * than through a second list of names. + * + * THE ORDER ITSELF IS THE PERSON'S AND NOT THE WIRE'S, and it is unchanged: a plain key first, + * because it is the one they are likeliest to already hold, then the other three ways of spelling a + * secret they have to go and assemble. + */ +const FIELD_SCHEME_ORDER: Record = { + API_KEY: 0, + BEARER_TOKEN: 1, + BASIC: 2, + BASIC_WITH_JWT: 3, +}; + +/** + * The schemes a managed config can actually send somebody to a page for, as the VENDOR names them. + * + * WHICH IS THE WHOLE OF WHAT `consent` CLAIMS. That kind means one thing below: create a + * `use_composio_managed_auth` config and mint a link the person visits. Composio publishes the set + * of schemes a link exists for and calls it exactly that — `RedirectableAuthSchemeSchema` is + * `z.enum([OAUTH1, OAUTH2])` (`@composio/core` 0.18.1, + * `src/types/connectedAccountAuthStates.types.ts:15-18`) — so a managed scheme outside it has no + * page to send anybody to, whatever else is true of it. + * + * `DCR_OAUTH` IS NOT HERE AND IS NOT AN OMISSION. The vendor leaves it out of that enum and this + * file drives it through its own kind — `self-registering`, a CUSTOM config whose client registers + * itself at connect time — so it is read off {@link VendorToolkit.auth_schemes} below rather than + * off the managed list. + */ +const REDIRECTING_SCHEMES = ["OAUTH2", "OAUTH1"]; + +/** + * The scheme Composio publishes for an app that asks nobody for anything. + * + * THE FLAG AND THE SCHEME ARE TWO SPELLINGS OF ONE FACT, and only the second is guaranteed. A + * toolkit needing no authentication publishes `NO_AUTH` among its schemes; `no_auth` is an + * additional boolean the catalogue MAY carry, and `ToolKitItemSchema` spells it optional. Reading + * only the flag made an app that published the scheme without it `unsupported` — whose sentence + * claims the app wants an OAuth application registered here, which is the opposite of true — and + * `routes.ts` then hid the app from the picker over it. + */ +const NO_AUTH_SCHEME = "NO_AUTH"; + +/** + * The words out of an `unknown`, which is all a vendor list promises — and a refusal for the rest. + * + * ABSENT IS AN ANSWER AND UNREADABLE IS NOT, WHICH IS THE WHOLE OF THE CORRECTION. Composio + * genuinely lists toolkits with no scheme beside them at all, and {@link connectionOf} reads that + * as an app nothing here can connect — a true thing to say about a real row. This used to answer + * the same empty list for an `auth_schemes` that arrived as a string, and for a list one of whose + * members was not a word: the unreadable answer and the honest absence became the same sentence, + * and an unsupported app is HIDDEN from the picker, so the one nobody could act on was also the one + * nobody could see. On the managed list it is louder still — an app whose consent screen would have + * asked a person for nothing drops a rank and starts asking them to go and find a key. + * + * A MEMBER IS GUARDED AS HARD AS THE LIST, because dropping the ones that are not words leaves a + * shorter list that reads exactly like a shorter list the app published, and the decision below is + * made on which words are in it. + * + * AND THE WORD IS THE TRIMMED ONE, WHICH IS WHY THE MEMBER GOES THROUGH {@link textOf}. A scheme is + * the NAME of a flow: it is compared against the literals below and recorded on the app's row when + * somebody enables it, so a padded `" OAUTH2 "` is the app's own scheme wearing whatever the wire + * wrapped it in, and `" "` is the one unusable value that reads as present at every glance. + * + * `where` IS THE NOUN PHRASE THE SENTENCE IS BUILT AROUND, so the two call sites differ in the one + * thing that differs between them: which of an app's two scheme lists could not be read. + */ +function labelsOf(value: unknown, where: string): string[] { + const listed = value ?? []; + if (!Array.isArray(listed)) { + throw new BrokerRefusalError( + `Composio sent ${sent(value)} where ${where} belong. This deployment reads that list to decide which flow the app gets, and an app that publishes no scheme at all is ordinary and shows as one nothing here can connect — so a list that is not a list would show as that same app, missing from the picker for a reason nobody could act on. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return listed.map((entry: unknown, index: number) => { + const scheme = textOf(entry); + if (scheme === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(entry)} where scheme ${index + 1} of ${where} belongs, and a scheme is the word this deployment matches against the flows it knows how to run. Leaving out the ones that are not words makes a shorter list, which reads exactly like a shorter list the app published. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return scheme; + }); +} + +/** + * One step of the connection form's read that Composio answered with a shape nothing here can open. + * + * WHY THIS IS A REFUSAL AND NOT AN EMPTY FORM is the argument {@link labelsOf} makes about an app's + * scheme lists, arriving where it costs the most. {@link ComposioBroker.connectionFields} + * reads the boxes an app asks for a step at a time off `unknown` — the modes, the recorded one's + * `fields`, the initiation block inside that, and the two lists of rows inside that — and every + * step of it answered an empty list for a shape it could not make sense of. An empty list is a + * sentence some modes truthfully say. A step that arrived as something else says only that Composio + * moved the shape, and the two reached the browser as the same empty form: a person presses submit + * on it, {@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. + * + * NOT THE SENTENCE FOR A MODE THE APP HAS STOPPED PUBLISHING, which is the other half of the same + * confusion. That one is a real drift between the recorded scheme and the vendor, and its remedy is + * an administrator's: remove the app and add it again so the scheme is recorded afresh. Nothing an + * administrator can do fixes a shape Composio changed, and handing them that remedy sends them to + * re-add an app whose publication never moved — after which the re-add reads the same unreadable + * answer and records the same word. + */ +export function unreadableForm( + was: unknown, + where: string, +): BrokerRefusalError { + return new BrokerRefusalError( + `Composio sent ${sent(was)} where ${where} belongs, and this deployment reads that answer to draw the boxes a person types their credential into. An app that asks for nothing and an answer this deployment cannot read would be the same empty form, and submitting an empty form makes a connection carrying no credential at all that Composio accepts and the first tool call discovers — so no form was drawn. ${VENDOR_SHAPE_REMEDY}`, + ); +} + +/** + * Which flow an app gets, and why that order. + * + * NO AUTHENTICATION 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 that says so has no other reading available, whatever else it + * publishes beside it. It says so in either of two places; see {@link NO_AUTH_SCHEME}. + * + * Then managed OAuth, because it asks the person for nothing at all — and MANAGED OAUTH IS READ + * RATHER THAN COUNTED, which is the difference between a consent and a dead end. This tested the + * LENGTH of the managed list, so a list holding any word at all became `consent`: the app got a + * Composio-managed config and a person got sent to a link mint for a scheme with no page behind it. + * {@link REDIRECTING_SCHEMES} is the vendor's own answer to which words have one. + * + * 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. + * + * AND "UNSUPPORTED" IS A VERDICT RATHER THAN A PLACE TO PUT WHAT COULD NOT BE READ. `routes.ts` + * hides an unsupported app from the picker, and the enable route answers its `reason` — so a row + * whose shape this file could not read used to leave the catalogue silently, under a sentence + * telling an administrator the app wants an OAuth application when nothing here had established + * that. Every unreadable shape now refuses, which stops the whole catalogue for the reason + * {@link appOf} gives at length: one refusal an operator can act on is worth more than several + * hundred rows, one of which is a guess. + */ +export function connectionOf(row: VendorToolkit): BrokerConnection { + /* + * THE APP IS NAMED OFF THE ROW RATHER THAN HANDED IN, because a refusal out of {@link labelsOf} + * travels up through {@link appOf} and onto an operator's page, where "this app" is a sentence + * with nothing to look for in it. {@link appOf} refuses a row with no readable slug before it + * reaches here, so the fallback is for the one caller that is a test. + */ + const at = textOf(row.slug) ?? "this app"; + const offered = labelsOf( + row.auth_schemes, + `the authentication schemes ${at} offers`, + ); + const managed = labelsOf( + row.composio_managed_auth_schemes, + `the authentication schemes Composio holds ${at}'s own credentials for`, + ); + + /* + * AND A FLAG THIS FILE CANNOT READ IS NOT THE SAME AS ONE THE APP DOES NOT PUBLISH. Most rows + * carry no `no_auth` at all and reading that as "this app needs authenticating" is the right + * answer, which is what {@link flagOf}'s default keeps — and the scheme list below carries the + * same fact for every toolkit that publishes it. A `no_auth` that is PRESENT and is not a + * boolean has no such reading: `"true" === true` is false, so the vendor saying an app needs + * nothing arrived at the very check that acts on it as the vendor having said nothing — and the + * app is then enabled down a path Composio refuses outright ("Cannot create an auth config for + * toolkit hackernews because it does not require authentication"), or a person is sent to a form + * for a credential the app has no use for. + */ + const flagged = flagOf(row.no_auth, false); + if (flagged === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.no_auth)} where its flag saying whether ${at} needs authenticating at all belongs, and that one field decides the whole of the flow: an app flagged this way is one Composio REFUSES an authorization config for, whatever else it publishes beside the flag. A value this deployment cannot read is not the app's silence on the question, so it is not read as one. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + if (flagged || offered.includes(NO_AUTH_SCHEME)) return { kind: "no-auth" }; + + if (managed.some((scheme) => REDIRECTING_SCHEMES.includes(scheme))) { + return { kind: "consent" }; + } + if (offered.includes("DCR_OAUTH")) return { kind: "self-registering" }; + + const field = offered + .filter((scheme) => isFieldScheme(scheme)) + .sort( + (left, right) => FIELD_SCHEME_ORDER[left] - FIELD_SCHEME_ORDER[right], + )[0]; + if (field) return { kind: "fields", authScheme: field }; + + /* + * THE SENTENCE NAMES WHAT WAS ACTUALLY PUBLISHED, INCLUDING THE MANAGED WORDS NOTHING ELSE READS. + * A managed list of schemes that do not redirect is now the commonest way to reach this branch, + * and a reason built from `auth_schemes` alone would tell an administrator the app published + * nothing — about a row that published something this deployment simply cannot drive. + */ + const published = [...new Set([...offered, ...managed])]; + return { + kind: "unsupported", + reason: published.length + ? `${published.join(", ")} needs an OAuth application registered by whoever runs this deployment, and this deployment holds no place to put its own OAuth client for a brokered app.` + : "Composio published no authentication scheme for this app, so there is no flow this deployment could run, and its own OAuth client is not something this deployment can register.", + }; +} + +/** + * One catalogue row checked into the app an administrator picks from, or a refusal saying why not. + * + * A ROW THIS FILE CANNOT READ STOPS THE WHOLE CATALOGUE, for the reason the full-page guard in + * {@link buildComposioClient} gives at length: the directory is held for ten minutes and both the + * picker and the enable route read the held copy, so a row quietly dropped is an app missing from a + * search and an app whose Add button reports that Composio does not publish it. One refusal an + * operator can act on is worth more than several hundred rows, one of which is a guess. + * + * THE ABSENCES THAT ARE REAL ANSWERS ARE STILL ANSWERS. Composio genuinely publishes toolkits with + * no description, no logo, no category and no count, and each of those is a fact about the app + * rather than a fault in the answer — so an absent one becomes the value that reads honestly on a + * screen, exactly as it did before. What is refused is the other thing: a field that is PRESENT and + * is not what it is declared to be. A count that arrived as the string "63" is not a count, and + * `Number(x)` over it would turn a vendor change into a plausible figure nobody would question. + * + * THE ROW'S SHAPE IS CHECKED AGAIN, AND THAT IS THE RAW CLIENT RATHER THAN A CHANGE OF MIND. Two + * refusals used to open this function — one for a row that is not an object, one for a row with no + * meta — and they were deleted because neither could be reached: `transformToolkitListResponse` + * read `item.meta.categories` while building each row (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27`) and `Toolkits.getToolkits` rethrew everything as + * `ComposioToolkitFetchError` (`src/models/Toolkits.ts:70-82`), so a catalogue this deployment + * could not read never became a row here at all. The catalogue does not go through that function + * any more — see {@link ComposioVendor} — and both shapes now arrive. + * + * BOTH ARE BACK RATHER THAN LEFT TO THE FIELD READS, and the reason is the category guard three + * screens down, which was deleted on the same reasoning and restored after it was RUN. `("gmail") + * .slug` is `undefined` and not a throw, so a row that is a bare string would be refused with + * "Composio sent nothing where the slug of row 1 of Composio's app catalogue belongs" — a sentence + * that sends an operator looking in a dashboard for an app with a missing slug, about an answer + * that had no app in it. A guard whose absence is argued from a dereference that does not + * dereference is not a guard anything should rest on. + */ +export function appOf(row: VendorToolkit, position: number): BrokerApp { + const at = `row ${position + 1} of Composio's app catalogue`; + + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new BrokerRefusalError( + `Composio sent ${sent(row)} where ${at} belongs, and a catalogue row is an object carrying an app's slug and name. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs. The slug is the only name this deployment has for an app — it is what enabling one records and what every later call names — so the directory was not shown, rather than shown with an app nothing could be done with. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const name = textOf(row.name); + if (name === null) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.name)} where the name of ${at} belongs, and ${slug} is a slug rather than a title, so there is nothing to show an administrator choosing between apps. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * `meta` IS CHECKED FOR BEING AN OBJECT, WHICH IT DID NOT USED TO BE AND NOW HAS TO BE. + * + * The argument for taking it on trust was `transformToolkitListResponse`: it read + * `item.meta.categories` while building each row and then spread the result into a fresh literal + * (`@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:24-38`), so a meta that was + * absent or null raised there and every meta that did arrive was an object whatever the wire had + * sent. That was a guarantee about the transformer, it was written down as one, and the catalogue + * no longer goes through it — see {@link ComposioVendor}, where the toolkit listing names the raw + * client because the wrapper could not be paged. + * + * WHAT THE ABSENT GUARD WOULD COST IS FOUR SILENT ABSENCES RATHER THAN A CRASH, which is the + * worse of the two. `meta.description` off the string "productivity" is `undefined`, not a throw, + * and so are the logo, the categories and the count — so a row this file cannot read would show + * on an administrator's screen as a real app that publishes nothing, indistinguishable from the + * many that genuinely publish little. Absent IS an answer here, which is exactly why a meta that + * is present and is not a meta cannot be allowed to look like one. + */ + const rawMeta = row.meta; + if ( + typeof rawMeta !== "object" || + rawMeta === null || + Array.isArray(rawMeta) + ) { + throw new BrokerRefusalError( + `Composio sent ${sent(row.meta)} where ${slug}'s description, logo, categories and action count belong. Every one of those is a thing an app is allowed to publish none of, so a row whose metadata is not readable at all would show as a real app that publishes nothing rather than as the answer this deployment could not read. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const meta = rawMeta as { + description?: unknown; + logo?: unknown; + categories?: unknown; + tools_count?: unknown; + }; + + const description = meta.description ?? ""; + if (typeof description !== "string") { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.description)} where ${slug}'s description belongs. An app that publishes none is ordinary and reads as a gap on the page; an app whose description is not text is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const logo = meta.logo ?? null; + if (typeof logo !== "string" && logo !== null) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.logo)} where ${slug}'s logo belongs, and this deployment puts that value in an image address. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * NEITHER THE LIST NOR THE ENTRIES IN IT ARE ANYBODY'S CONSTRUCTION NOW, so both are checked. + * + * The list used to be the transformer's: `item.meta.categories?.map(category => ({ slug: + * category.id, name: category.name }))` (`@composio/core` 0.18.1, + * `src/utils/transformers/toolkits.ts:27-30`) has no `.map` for a `categories` that is not a + * list, and that half genuinely held when it was run. It does not hold through the raw client, + * where nothing maps this field at all — a `categories` of "productivity" would simply read as an + * app in no category, which is a state real apps are in. + * + * THE ENTRY GUARD WAS ALREADY BACK, AND FOR A REASON WORTH KEEPING IN VIEW. It had been deleted + * on the ground that "a category that is null throws on `.id`, and everything that survives is an + * object". Only the first clause was ever true: `("crm").id` is `undefined`, not a throw, and so + * is `(7).id` — so a primitive survived the map as `{ slug: undefined, name: undefined }`, and + * the whole catalogue was refused with "Composio sent nothing where the name of gmail's category + * 1 belongs" about a value that was the string "crm". An operator reading that goes looking in a + * dashboard for a category with a missing name, and there is no such category. + * + * SO THE SHAPE IS TESTED BEFORE THE FIELD IS READ, twice over, and the three faults get the three + * sentences they are. The vendor's own word is `name` on both spellings of this row + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:425-435`), which is why that is + * the one field read. + */ + const listed = meta.categories ?? []; + if (!Array.isArray(listed)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.categories)} where ${slug}'s categories belong, and the catalogue shows an app's categories as the words a person chooses by. An app in no category is ordinary; a list of them that is not a list is an answer this deployment cannot show. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const categories = listed.map((entry: unknown, index: number) => { + const at = `${slug}'s category ${index + 1}`; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new BrokerRefusalError( + `Composio sent ${sent(entry)} where ${at} belongs, and a category is an object carrying the word a person picks an app by. ${VENDOR_SHAPE_REMEDY}`, + ); + } + const label = textOf((entry as { name?: unknown }).name); + if (label === null) { + throw new BrokerRefusalError( + `Composio sent ${sent((entry as { name?: unknown }).name)} where the name of ${at} belongs. The catalogue shows an app's categories as the words a person chooses by, so a category with no name is a blank one of those. ${VENDOR_SHAPE_REMEDY}`, + ); + } + return label; + }); + + /* + * `tools_count` IS THE WIRE'S OWN KEY AND `toolsCount` WAS THE WRAPPER'S RESTATEMENT OF IT + * (`@composio/client` 0.1.0-alpha.76, `resources/toolkits.d.ts:405-408`; + * `@composio/core` 0.18.1, `src/utils/transformers/toolkits.ts:34`). Reading the old key off the + * new answer is the one mistake in this function that would not look like one: every count would + * be `undefined`, every count would default to zero, and every app in the picker would say it + * publishes no actions — a plausible figure on a page whose whole job is to show one. Which is + * why it is a number a test asserts rather than a field a type checks. + */ + const actionCount = meta.tools_count ?? 0; + if (typeof actionCount !== "number" || !Number.isFinite(actionCount)) { + throw new BrokerRefusalError( + `Composio sent ${sent(meta.tools_count)} where ${slug}'s action count belongs. The count is shown BEFORE anybody enables an app, because it is the difference between a small addition and a rewrite of what a model sees, so a figure derived from a value that is not a number is the one number here nobody would think to question. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + return { + slug, + name, + description, + logo, + categories, + actionCount, + connection: connectionOf(row), + }; +} + +/** + * One auth config with the two fields every decision here turns on, checked. + * + * `status` IS CARRIED ACROSS UNCHECKED ON PURPOSE, which is the one field of the three this reader + * does not settle. {@link ComposioBroker.authorize} is its only reader and it has three answers to + * give rather than two — enabled, disabled, and a word the vendor has invented since — so a check + * here could only collapse the third into one of the first two, which is the exact defect being + * closed. It is left as `unknown` so the reader has to say what it does with it. + */ +export type CheckedAuthConfig = { + id: string; + name: string; + status: unknown; +}; + +/** + * That status as the WORD it is, so the test and the sentence about it are about the same value. + * + * THE TWO READINGS OF THIS ONE FIELD DISAGREED, AND THE DISAGREEMENT REACHED AN OPERATOR AS A + * SENTENCE CONTRADICTING ITSELF. Both callers tested the RAW value — `=== "ENABLED"` to pick the + * config a connection is made against, `!== "DISABLED"` to sort the rest — while {@link named} + * quotes the TRIMMED one. So a status of `" ENABLED "` was refused for not being ENABLED and then + * reported as `"ENABLED", which is neither ENABLED nor DISABLED`: a claim the reader can see is + * false, ending in a remedy — upgrade the package — that is nobody's act on the page they are + * standing on, about a config Composio calls enabled and this deployment made. + * + * IT COSTS MORE THAN THE SENTENCE. A padded `" ENABLED "` is an app nobody can connect at all, + * through either door, for as long as the vendor pads it; a padded `" DISABLED "` is a disabled + * config an operator is never told to go and enable. This is the same argument {@link textOf} makes + * for every identifier in this file and {@link labelsOf} makes for a scheme name — the value is the + * word, and the padding is whatever the wire wrapped it in — arriving at the one field that had + * been left out of it. + * + * NULL FOR ANYTHING THAT IS NOT A WORD, which keeps the third answer this type exists to preserve: + * a status that is absent, or that arrived as a number or an object, is neither ENABLED nor + * DISABLED and is sorted by both callers exactly as it was before. + */ +export function statusOf(config: CheckedAuthConfig): string | null { + return textOf(config.status); +} + +/** + * One app's auth configs split into the ones a decision can be made about and the ones it cannot. + * + * EVERY ROW IS CHECKED AND NOT ONLY THE ONES THAT TURN OUT TO BE OURS, because which ones are ours + * is precisely what the name decides. A row whose name cannot be read cannot be sorted into "made + * here" or "somebody's dashboard work", and both of the guesses are damaging in opposite + * directions: read as somebody else's, {@link ComposioBroker.ensureAuthConfig} creates a second + * config beside it and splits one app's connections in two; read as ours, + * {@link ComposioBroker.deleteAuthConfig} deletes an object nobody here chose and every account + * anybody had connected against it. + * + * PARTITIONED RATHER THAN THROWN, WHICH IS THE SAME CORRECTION {@link withdrawableAccounts} + * ALREADY CARRIES ONE LEVEL DOWN, ARRIVING HERE A WAVE LATE. This checked every row on the way out + * of the listing and threw on the first one it could not read, so a single unreadable config row + * was a permanent block on everything behind it: a person pressing disconnect got the same throw + * every time, for ever, because the row will be exactly as unreadable on the next attempt and + * nothing they can reach changes it. That is not the safe end of the trade — it is the SAME defect + * as a false success, pointing the other way, which is the finding the accounts path was corrected + * for and this one was left standing on. + * + * WHAT A CALLER IS OWED IS BOTH HALVES: every config this file CAN decide about, decided about, and + * a refusal counting the ones it cannot. Which half matters differs per caller and is therefore + * settled at each of the four rather than here — a withdrawal acts on what it can name and reports + * the rest, a creation must not put a second config beside a row that might already be ours, and a + * connect link minted against a readable config of ours is right whatever else the listing held. + * + * AND ONE ID IS ONE CONFIG, HOWEVER MANY TIMES THE LISTING NAMED IT — the other half + * {@link withdrawableAccounts} had and this did not. The paging loop guards against a repeated + * CURSOR and not a repeated ROW, and a page boundary crossed while a config is being created, or a + * proxy stitching two overlapping pages together, hands one id over twice with the cursor + * advancing normally each time. The second delete of one config then meets Composio's "there is no + * such auth config", which arrives as a refusal — so {@link ComposioBroker.deleteAuthConfig} + * counted a removal that had in fact completed as a partial one and refused to finish removing the + * app, every time, over a duplicate that is still there on the retry. It also inflates the count + * both that method and {@link ComposioBroker.authorize} put in front of an operator, and sends the + * same id twice in the withdrawal's `authConfigIds` filter. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, exactly as it does for accounts; the caller sorts on the id + * afterwards, so the order two callers see is the same one either way. + */ +export function readableConfigs( + rows: VendorAuthConfig[], + toolkit: string, +): { configs: CheckedAuthConfig[]; unreadable: BrokerRefusalError[] } { + /* + * ONE SLOT PER CONFIG, HOLDING WHICHEVER COPY OF IT COULD BE READ. + * + * ONE ID IS ONE CONFIG HOWEVER MANY TIMES THE LISTING NAMED IT, and the invariant is about the + * CONFIG rather than about the first row that happened to mention it. Two orderings of one + * repeated id therefore have to answer the same, and the previous shape — dedup on first + * sighting, then read the name — answered them differently: good copy first and unreadable + * repeat second was right, unreadable copy first and good copy second claimed the id for the + * unreadable one and the readable copy returned at the dedup test, so the config never reached + * `configs` at all. `deleteAuthConfig` then left standing a config the other ordering would have + * deleted. + * + * THE POSITION IS THE FIRST SIGHTING'S AND THE VERDICT IS THE BEST COPY'S, which is what a slot + * buys over a `Set`: a later readable copy REPLACES an unreadable one in place, so neither the + * order two callers see nor the count of what could not be read depends on which copy the paging + * happened to hand over first. A readable copy is never replaced — a second reading of a config + * this deployment has already read tells it nothing new. + * + * A ROW WITH NO ID HAS NO SLOT TO SHARE, because the id is the whole of what identifies a config: + * two id-less rows are two rows nothing can say are one, so each keeps its own place in the list + * and its own refusal. + */ + const settled: (CheckedAuthConfig | BrokerRefusalError)[] = []; + const slotOf = new Map(); + + rows.forEach((row, position) => { + const at = `row ${position + 1} of Composio's authorization configs for ${toolkit}`; + + const id = textOf(row.id); + if (id === null) { + settled.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; + } + + /* + * THE ID IS READ FIRST AND THE SLOT IS FOUND BEFORE ANYTHING ELSE IS ASKED OF THE ROW. + * + * A repeat whose verdict is already settled readable is dropped here, which is the half that + * was already right: the listing repeating `ac_1` across a page boundary can perfectly well + * hand the repeat over as `{ id: "ac_1", name: null }` — a rename in flight in the dashboard, + * or a proxy stitching two partial reads — and counting that as a config this deployment could + * not read cost a refusal AFTER THE WORK WAS ALREADY DONE. `configs` held the one readable + * `ac_1` and `unreadable.length` was 1, so `deleteAuthConfig` and `revoke` — which both act on + * what they can name and then report the rest — deleted the config, succeeded, and THEN threw + * "…and the app has not been fully withdrawn", over a config that was already gone. + * `removeServer` therefore left the app's row standing behind a completed removal. + */ + const slot = slotOf.get(id); + if (slot !== undefined && !(settled[slot] instanceof BrokerRefusalError)) { + return; + } + + const name = textOf(row.name); + if (name === null) { + // The first copy of this id that could not be read keeps the slot; a second one adds nothing + // to what a caller can act on and would count one config twice. + if (slot === undefined) { + slotOf.set(id, settled.length); + settled.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; + } + + const config: CheckedAuthConfig = { id, name, status: row.status }; + if (slot === undefined) { + slotOf.set(id, settled.length); + settled.push(config); + return; + } + // The readable copy of a config an earlier row could not describe, in that row's place. + settled[slot] = config; + }); + + const configs: CheckedAuthConfig[] = []; + const unreadable: BrokerRefusalError[] = []; + for (const verdict of settled) { + if (verdict instanceof BrokerRefusalError) unreadable.push(verdict); + else configs.push(verdict); + } + + return { configs, unreadable }; +} + +/** + * This person's accounts split into the ones a withdrawal can name and the ones it cannot. + * + * THE ID IS THE WHOLE OF WHAT A WITHDRAWAL NAMES, which is why it is read at all. + * {@link ComposioBroker.revoke} deletes by id and then answers `true`, and `store.ts` writes that + * answer into the audit trail as this person's access having been withdrawn before deleting the one + * row in this deployment naming which app they had connected. An id-less account reaching the + * delete is a request to withdraw `undefined` — which the vendor is free to read as anything at all + * — followed by a `true`, a trail entry, and a live grant with nothing left pointing at it. + * + * PARTITIONED RATHER THAN THROWN, AND THAT IS THE CORRECTION. This reader used to refuse, and the + * caller mapped EVERY row through it before sending a single delete — so one row whose id Composio + * omitted threw ahead of the first withdrawal, and the next attempt met the same row and threw in + * the same place. A person with three grants and one unreadable row could not withdraw any of them, + * ever, while being told to try again. That replaced a false success with a permanent block, which + * is the same defect pointing the other way. + * + * WHAT A PERSON IS OWED IS BOTH HALVES: every grant this deployment CAN name withdrawn, and a + * sentence counting the ones it cannot. The second half is not a thing they can retry — the row + * will be unreadable next time too — so the refusal names the dashboard rather than the button + * they just pressed. + * + * A ROW THAT IS NOT AN OBJECT IS NOT A CASE HERE, and that is the SDK's doing rather than an + * omission. `transformConnectedAccountResponse` reads `response.auth_config.id` while building + * every row (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:60`), so a + * non-object row raises a `TypeError` inside the vendor's own code and never reaches this function + * — see the `TypeError` row in {@link vendorRefusal}, which is where that answer is now given. + * + * ONE ID IS ONE WITHDRAWAL, HOWEVER MANY TIMES THE LISTING NAMED IT. The paging loop above guards + * against a vendor repeating a CURSOR and not against it repeating a ROW, and those are different + * faults: a page boundary crossed while an account is created or deleted, or a proxy stitching two + * overlapping pages together, hands the same account id over twice with a cursor that advanced + * normally every time. The second delete of one account then meets Composio's "there is no such + * account", which arrives here as a refusal — so {@link ComposioBroker.revoke} counted a + * withdrawal that had in fact completed as a partial one, threw over it, and left the person's + * connection row standing to be pressed again. Every retry meets the same duplicate. Deduplicating + * is not tidying the listing: it is the difference between one account and two. + * + * THE FIRST SIGHTING KEEPS ITS PLACE, so the order the deletes go out in is still the listing's, + * which is the order {@link buildComposioClient}'s sort makes stable. + */ +export function withdrawableAccounts( + rows: { id?: unknown }[], + toolkit: string, +): { ids: string[]; nameless: BrokerRefusalError[] } { + const ids: string[] = []; + const alreadyNamed = new Set(); + const nameless: BrokerRefusalError[] = []; + + rows.forEach((row, position) => { + const id = textOf(row.id); + if (id === null) { + nameless.push( + new BrokerRefusalError( + `Composio sent ${sent(row.id)} where the id of row ${position + 1} of its ${toolkit} accounts for this person belongs, and the id is the whole of what a withdrawal names. Nothing was sent for that account, because a delete without one is a request this deployment cannot describe, after which the audit trail would record that this person's access had ended while their grant stood. ${VENDOR_SHAPE_REMEDY}`, + ), + ); + return; + } + if (alreadyNamed.has(id)) return; + alreadyNamed.add(id); + ids.push(id); + }); + + return { ids, nameless }; +} + +/** + * Composio's own verdict on one withdrawal, as a refusal wherever it is not a yes. + * + * THE VENDOR TELLS US WHEN THE DELETE DID NOT HAPPEN AND NOTHING WAS LOOKING. `success` is a + * required field of `ConnectedAccountDeleteResponse` — see the declaration on + * {@link ComposioVendor}'s `connectedAccounts.delete` — and the answer used to be thrown away + * unread. A 200 whose body says `success: false` therefore became a withdrawn account in + * {@link ComposioBroker.revoke}'s count, a `true` out of that method, and a + * `vendorRevocationRequested: true` in the audit trail, over a grant Composio had just said it had + * not touched. That is the same class of lie as the unflagged delete before it and the one-page + * listing before that, arriving through the one door left unwatched: the reply. + * + * WHICH DOES NOT BLUR "ASKED" INTO "DONE", and the distinction is worth being exact about because + * the whole audit field rests on it. `success: true` still claims no more than it ever did — the + * account is gone at the broker and the revocation job was started — and whether Google honoured it + * happens afterwards, out of sight, with no supported way to poll. What `success: false` adds is + * the other end: Composio did not delete the account, so there is no job and nothing was asked of + * the provider at all. Reading it narrows the set of things `true` can be covering up rather than + * widening what `true` means. + * + * TWO SENTENCES, BECAUSE THEY ARE TWO DIFFERENT FACTS ABOUT TWO DIFFERENT THINGS. "Composio said + * no" is a fact about this account: the account is still there, a second press reaches it, and the + * caller's own count already says to press again. "Composio answered with something where its + * verdict belongs" is a fact about the package — nobody holding an admin page can correct the shape + * of a reply, and pressing disconnect again would be answered identically — so it carries + * {@link VENDOR_SHAPE_REMEDY} instead. Collapsing the two would send an operator to press a button + * for a condition no button changes. + * + * NULLABLE IN THE PARAMETER THOUGH THE DECLARATION SAYS OTHERWISE. The generated client parses the + * body and returns it, and there are two answers for which it hands over no body at all. + * + * AND NO BODY AT ALL IS NOT A REFUSAL, WHICH IS THE CORRECTION AND THE OPPOSITE MISTAKE TO THE ONE + * ABOVE. `defaultParseResponse` resolves a 204 to `null` — "fetch refuses to read the body when the + * status code is 204" — and a JSON reply carrying `content-length: 0` to `undefined` + * (`@composio/client` 0.1.0-alpha.76, `src/internal/parse.ts:16-42`). Neither of those ever reaches + * a non-2xx: the client throws `APIError` for every `!response.ok` before parsing + * (`src/client.ts:539`), so an answer arriving here at all is Composio having accepted the request. + * A 204 is therefore the vendor saying it did the delete and has nothing to add, and the guard + * added for `success: false` read it as the one shape it could not tell apart from a failure — + * turning a withdrawal that HAPPENED into a partial-withdrawal refusal, over a grant that was in + * fact ended. That is the same lie as the one this function exists to stop, pointing the other way. + * + * WHICH IS NOT THE SAME AS A BODY THAT ARRIVED WITHOUT THE FIELD. `{}` is Composio answering with a + * document whose verdict is missing, and a document this deployment cannot read is a fact about the + * package rather than an outcome — so it stays in the unreadable branch below. What is exempted + * here is the narrower thing the client documents: no document. + * + * AND A BODY THAT IS NOT A DOCUMENT IS A THIRD THING AGAIN, WHICH THE LAST BRANCH USED TO SPEAK FOR. + * The same parse that hands over `{}` hands over whatever else the body held, so a reply that is a + * bare string or a list reaches this read too — and `("deleted").success` is `undefined`, not a + * throw. The refusal then said "Composio sent nothing where its verdict belongs, and that field is + * the only thing in the reply that says whether the account was deleted": a claim about a reply with + * one field missing, made about an answer that was not a reply. The shape is the whole finding, and + * it was the one part the sentence did not name. The parameter widens to `unknown` for the same + * reason — a declaration naming `{ success?: unknown }` over a wire value is the assertion this file + * refuses to make everywhere else. + */ +export function withdrawalDeclined( + answer: unknown, + toolkit: string, +): BrokerRefusalError | null { + if (answer === null || answer === undefined) return null; + if (typeof answer !== "object" || Array.isArray(answer)) { + return new BrokerRefusalError( + `Composio sent ${sent(answer)} where its reply to the withdrawal of one of this person's ${toolkit} accounts belongs, so there is no field in it saying 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}`, + ); + } + const verdict = (answer as { success?: unknown }).success; + if (verdict === true) return null; + if (verdict === false) { + return new BrokerRefusalError( + `Composio answered the withdrawal of one of this person's ${toolkit} accounts with success: false, so it did not delete the account and started no revocation of the grant behind it. Nothing was asked of the provider for that account, whatever this deployment would otherwise have recorded. Disconnecting again asks Composio for it a second time.`, + ); + } + return new BrokerRefusalError( + `Composio sent ${sent(verdict)} where its verdict on the withdrawal of one of this person's ${toolkit} accounts belongs, and that field is the only thing in the reply that says whether the account was deleted at all. This deployment cannot tell a withdrawal that happened from one that did not, so the account is reported as still standing rather than counted as ended. ${VENDOR_SHAPE_REMEDY}`, + ); +} + +/** + * One tool row as the action this deployment holds, with the one check the SDK's schema leaves open. + * + * THE VENDOR USED TO VALIDATE THIS ROW AND NO LONGER DOES, WHICH IS WHY THE GUARDS ARE ALL HERE. + * `transformToolCases` ends in `ToolSchema.parse(...)` — a THROWING parse rather than the + * warn-only `transform()` every other listing goes through (`@composio/core` 0.18.1, + * `src/models/Tools.ts:193`) — and the listing used to run through it (`:561`). It does not any + * more: `getRawComposioTools` is the one method of the vendor's tool model that cannot be paged, + * so the listing reads `client.tools.list` and {@link VendorToolRow} is the wire's own shape. Five + * refusals once stood here for five shapes that parse caught — a description that is a number, an + * input schema that is a string of JSON, tags that are not all labels, a version that is a number, + * and a row that is not an object at all — and each was deleted as unreachable. Four of the five + * had already come back, on the argument below. The fifth, the container, comes back with this + * change, because there is nothing at all between Composio and this function now. + * + * THE ARGUMENT THAT BROUGHT THE OTHER FOUR BACK IS THE SAME MISTAKE AS THE DELETED CATEGORY GUARD + * IN {@link appOf}, CORRECTED THE SAME WAY. "The SDK validates this" is a fact about one method of + * one version, and it is not a fact about {@link ComposioVendor}, which is the seam this function + * actually sits on and the shape a test satisfies with a literal. Running it settles what the + * difference costs: a `description` of 42 and an input schema of "not-a-schema" both travel through + * here untouched into {@link ComposioAction}, whose declared types say they cannot. What they reach + * is not a refusal. `storableTools` writes + * `(tool.description ?? "").replaceAll(NUL, "")` and `tool.version?.replaceAll(NUL, "")` + * (`./store`), so a description or a version that is not a string is a bare + * "42.replaceAll is not a function" thrown from outside every vendor `try` in this file — a crash + * wearing a refusal's clothes, which is the one outcome none of this file's sentences may become. + * An `inputParameters` that is not an object is quieter and worse: it is stored as the app's input + * schema and then shown to a model as Composio's own. + * + * SO THE THREE WIRE VALUES THIS FILE HANDS ON ARE CHECKED HERE, BESIDE THE SLUG, and `tags` is not + * — `./composio` already refuses a `tags` that is not a list where it reads them, and a check on + * both sides of one seam is a check nobody maintains. + * + * WHAT THE SCHEMA DOES NOT SETTLE IS THE FOURTH. `slug: z.string()` is satisfied by the + * empty string, and an action's slug is not a label: it becomes `mcp_tools.name`, which is NOT NULL + * and half that table's primary key, it is what a grant points at, and it is what a later call + * sends back to Composio. An empty one is a row that cannot be written and a call that names + * nothing, so it is refused here. + * + * A PLAIN `Error` RATHER THAN A `BrokerRefusalError`, because this listing's failures are not + * answered to a route. `./composio` records them in an app's `lastError` for an administrator to + * read on its Plugins page, and `listingSentence` passes an authored message through untouched. + */ +export function actionOf( + row: VendorToolRow, + position: number, + toolkit: string, +): ComposioAction { + const at = `row ${position + 1} of Composio's action list for ${toolkit}`; + + /* + * THE CONTAINER, FOR THE REASON {@link appOf}'S ROW GUARD IS BACK ONE FUNCTION UP. `ToolSchema` + * refused a row that is not an object and nothing does now. Read without it, `("GMAIL").slug` is + * `undefined` rather than a throw, so a listing of bare strings would be refused with "Composio + * sent nothing where the slug of row 1 ... belongs" — an administrator sent to look for an action + * with a missing name, about an answer that had no action in it. + */ + if (typeof row !== "object" || row === null || Array.isArray(row)) { + throw new Error( + `Composio sent ${sent(row)} where ${at} belongs, and an action is an object carrying the name a call to it uses, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + const slug = textOf(row.slug); + if (slug === null) { + throw new Error( + `Composio sent ${sent(row.slug)} where the slug of ${at} belongs, and the slug is what calling the action names, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * ABSENT IS AN ANSWER AND PRESENT-AND-WRONG IS NOT, which is the same split {@link appOf} makes + * over a catalogue row. Composio genuinely publishes actions with no description and no version, + * and one that publishes no parameters at all arrives with none — the SDK normalizes a `{}` to + * absent before parsing. Each of those reaches `./composio` as the absence it is and is defaulted + * where a column has a default. What is refused is the other thing: a field that is THERE and is + * not what this file has told `./composio` it is. + * + * AND `null` IS THE WIRE'S OTHER SPELLING OF ABSENT, WHICH ALL THREE OF THESE USED TO REFUSE. + * That directly contradicted the file this seam reports to: `./composio`'s version guard exempts + * `null` on purpose and writes down why — "refusing it would turn a healthy refresh into a total + * failure for every app that publishes one" — and the three reads it makes of these fields are + * `description ?? ""`, `inputParameters ?? {}` and `version?.trim()`, every one of them already + * null-safe. So the layer that had no trouble with a null was protected by a layer that refused + * it, and the refusal is not a dropped field: {@link ComposioBroker}'s listing stops whole, so + * every OTHER action on the app keeps neither its effect nor its version, and the grants pointing + * at them are stranded until a vendor this deployment does not control stops sending a null. + * + * NORMALIZED HERE RATHER THAN HANDED ON, because {@link ComposioAction} spells all three + * optional-and-typed, and a `null` travelling under that declaration is the same untrue assertion + * these guards exist to stop. `undefined` is what an absence is on this side of the seam. + * + * `tags` IS STILL NOT READ HERE AT ALL, for the reason {@link VendorToolRow} gives: `./composio` + * refuses a `tags` that is not a list of labels where it reads them — and exempts a null there on + * exactly this argument — and a check on both sides of one seam is a check nobody maintains. + */ + const description = row.description ?? undefined; + if (description !== undefined && typeof description !== "string") { + throw new Error( + `Composio sent ${sent(description)} where the description of ${at} belongs, and that value is written into this app's tools and read back as text, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * `input_parameters` IS THE WIRE'S KEY AND `inputParameters` WAS THE WRAPPER'S — see + * {@link VendorToolRow}. Reading the old one off the new answer would put no schema on any + * action, which `./composio` treats as the ordinary case of an action that publishes none: every + * tool would reach a model with an open schema and nothing would report a fault. + */ + const inputParameters = row.input_parameters ?? undefined; + if ( + inputParameters !== undefined && + (typeof inputParameters !== "object" || 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 ?? undefined; + if (version !== undefined && typeof version !== "string") { + throw new Error( + `Composio sent ${sent(version)} where the version of ${at} belongs, and the version is what a later call to this action asks Composio for, so the list was not refreshed and the tools already held are untouched. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + /* + * Mapped field by field rather than spread, so what crosses the seam is the four things + * `./composio` documents and not whatever else the vendor's tool object happens to carry. + */ + return { + slug, + description, + inputParameters: inputParameters as Record | undefined, + tags: row.tags, + version, + }; +} + +/** + * WHAT THIS DEPLOYMENT WAS DOING WHEN A VENDOR CALL REFUSED, so a translated refusal can say. + * + * A CONDITION AND AN OUTCOME ARE TWO DIFFERENT HALVES OF A SENTENCE, and only one of them is the + * vendor's. "This person already holds an account for gmail" is what Composio determined; "so their + * connection to gmail was not begun" is what this deployment did about it, and a reader needs both + * — the first to know why asking again will not help, the second to know what state they are in + * now. The vendor cannot supply the second, because it does not know which of this file's ten calls + * it was answering. + * + * WRITTEN AT THE CALL SITE AS A FINISHED CLAUSE, in the past tense, so that it reads after "so" + * without any sentence below having to conjugate it. That is also what keeps the outcomes honest: + * each one is composed beside the call it describes, where whether anything was sent is a fact + * rather than a guess. + */ +type VendorCall = { + /** What did not happen, as a clause following "so": "the app catalogue was not read". */ + outcome: string; + /** The app the call is about, or null where the question names no app at all. */ + app: string | null; +}; + +/** + * The vendor's own name for the condition it raised, or null where it raised something anonymous. + * + * READ OFF `name` RATHER THAN ASKED WITH `instanceof`, which is a deliberate choice and not a + * shortcut. Every error class in `@composio/core` 0.18.1 ends its constructor by assigning its own + * `name` (`src/errors/*.ts`), so the name is the vendor's published discriminator and is stable + * across the package boundary; `instanceof` is not, because it is identity on a constructor and + * therefore hostage to a second copy of the package anywhere in the tree. The SDK makes the same + * judgement about its own classes — `isRequestAbortError` falls back to `constructor.name` and + * `name` and says why: "dual-package-hazard cases" (`src/errors/SDKErrors.ts`). `./composio`'s + * `isSchemaMismatch` reaches for a shape rather than a class for the same reason. + * + * AND IT IS WHAT LETS THE TABLE BE A TABLE. Naming eight classes as values would mean importing + * eight symbols from the vendor into the one file whose whole argument is that the vendor's surface + * is confined — and a version that renames one would then be a compile error in a switch that is + * meant to degrade to "not a condition this file knows" rather than to fail the build. + */ +function conditionOf(error: unknown): string | null { + /* + * AND THE NAME 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 — {@link textOf}'s own defect, on + * the value that decides which of eight authored remedies a reader gets. The switch below + * compares against literals, so a name the package or a proxy padded matched none of them and + * every condition this file translates fell through to "Composio said nothing about why", which + * sends an operator to check a key that is fine. + */ + return textOf((error as { name?: unknown } | null | undefined)?.name); +} + +/** + * A VENDOR CONDITION AS A SENTENCE THIS DEPLOYMENT WROTE, or null where there is none to write. + * + * THE DEFECT THIS CLOSES IS THAT ALMOST NOTHING WAS TRANSLATED. `routes.ts` answers a thrown broker + * error by reaching for the vendor's own sentence and, finding none, telling the reader that + * Composio said nothing about why and that an administrator should check this deployment's Composio + * key. That is exactly right about a socket that hung up. It is wrong twice over about a condition + * `@composio/core` raised by name: the key is fine — the call that failed usually went out through + * a listing that had just succeeded on the same key — and several of these states are settled at + * the vendor, so the "and try again" half of the advice is an instruction to repeat something that + * will answer identically for ever. The worst of them is the first row below: a person who already + * has an account for an app was told to check an API key and retry. + * + * ONE SENTENCE PER CONDITION AND NO SENTENCE SHARED, which is the property its test asserts in both + * directions. A translation that gave two conditions one wording would be worse than leaving both + * alone: the reader would be handed a remedy that is right for somebody else's failure and would + * have no way to tell, where an untranslated failure at least says plainly that nothing is known. + * + * NULL WHERE COMPOSIO'S OWN SERVER EXPLAINED ITSELF, WHICH IS THE LIMIT ON DOING THIS AT ALL. + * `routes.ts` reads `brokerSentence` first and `vendorSentence` second, so a refusal authored here + * HIDES the vendor's message rather than joining it. Several of the SDK's classes are wrappers + * around whatever the API returned — `ComposioFailedToCreateConnectedAccountLink` keeps the + * `BadRequestError` as its `cause` (`src/models/ConnectedAccounts.ts`), and `vendorSentence` reaches + * through exactly that nesting — so translating one of those unconditionally would replace a + * specific server sentence with this deployment's general one. Where the vendor said something a + * reader can use, the error goes on untouched and the vendor gets the last word. + * + * THE ORIGINAL IS KEPT AS `cause` on every refusal, for whoever is reading a log rather than a page. + * It is never quoted into the message: the file's promise about {@link BrokerRefusalError} is that + * its sentence is safe to show anybody who could have made the request, and a vendor error object + * out of `connectedAccounts.link` carries the request that was being minted. + * + * WHAT IS DELIBERATELY NOT HERE, because the route's default answer is the correct one for it: + * `ComposioToolkitFetchError`, which `Toolkits.getToolkits` wraps around EVERY catalogue failure + * including its own validation one, and whose message is the bare "Failed to fetch toolkits" — the + * key and the status page genuinely are the remedy; and `ComposioToolExecutionError`, the same + * wrapper one call further on, whose `cause` carries the server's own words for `vendorSentence` to + * find and whose own message `./composio`'s {@link VENDOR_PLACEHOLDER} already refuses to pass on. + */ +function vendorRefusal( + error: unknown, + call: VendorCall, +): BrokerRefusalError | null { + if (vendorSentence(error) !== null) return null; + + const app = call.app ?? "the app"; + const outcome = call.outcome; + const refusal = (message: string): BrokerRefusalError => + new BrokerRefusalError(message, { cause: error }); + + switch (conditionOf(error)) { + /* + * THE ONE THAT WAS DOING THE MOST DAMAGE. `connectedAccounts.link` lists this person's active + * accounts for the config before it mints anything and refuses where it finds one + * (`@composio/core` 0.18.1, `src/models/ConnectedAccounts.ts`), which is this deployment's own + * rule met one layer down — one person holds one account per app, because the call that runs an + * action names the person and not the account. So it is a settled fact rather than a moment, + * and "check the key and try again" is advice that cannot ever come true. + */ + case "ComposioMultipleConnectedAccountsError": + return refusal( + `Composio answered that this person already holds a connected account for ${app}, so ${outcome}. That is a settled state at Composio rather than a moment that passes — asking again meets the same answer — and what clears it is disconnecting the account they already hold, on this deployment's Connected accounts page, before another is attached.`, + ); + + /* + * ACCESS RULES ARE NOT SOMETHING THIS FILE SENDS, which is the whole of why this one is worth a + * sentence. The SDK raises it when the server rejects ACL fields on an account that is not + * shared, and nothing here asks for sharing — so the reader must not go looking through this + * deployment's settings for a field it does not have. The config in Composio's dashboard is + * where the sharing is decided and where an operator can change it. + */ + case "ComposioAclOnlyForSharedError": + return refusal( + `Composio refused account-sharing rules on an account that is not a shared one, so ${outcome}. This deployment attaches every account to one person and asks for no sharing, so the rules are on the authorization config rather than on anything sent from here: an operator changing how ${app} is shared in Composio's own dashboard is what clears it.`, + ); + + /* + * REACHED ONLY WHERE THE SERVER SAID NOTHING, by the guard at the top of this function. What is + * left when it is reached is still worth far more than the route's default, because of what has + * already happened by the time this call is made: the auth configs were listed through the same + * key moments earlier and one of them was found enabled. So the two things the default sends an + * operator to check are both already proven, and the two things a person needs to know — that + * they were not sent anywhere, and that their consent is unspent — are facts about this + * particular call that no general sentence carries. + */ + case "ComposioFailedToCreateConnectedAccountLink": + return refusal( + `Composio would not mint a connect link for ${app} and said nothing about why, so ${outcome}. Nobody was sent to a consent screen and no consent was spent. This deployment's key and its authorization config for the app were both read through successfully moments earlier, so neither of those is what to check; Composio's status page is.`, + ); + + /* + * THE SDK'S OWN SCHEMA REFUSING, ON EITHER SIDE OF THE WIRE. `ValidationError` is raised by + * nearly every model here — the auth-config create, the connected-account listing and link, the + * tool listing and the execute all `safeParse` what they are handed and what comes back — and in + * both directions it means the same thing: this deployment's copy of `@composio/core` and + * Composio's API no longer agree. It is the one condition below whose remedy is a package + * rather than a page, which is why it shares its wording with the shape refusals above. + */ + case "ValidationError": + return refusal( + `This deployment's @composio/core refused the request or Composio's answer against its own schema, so ${outcome} — either before Composio was asked or after it had replied. Nothing an operator can set corrects that and the key is not what to check: upgrading this deployment's @composio/core is what fixes it.`, + ); + + /* + * NOTHING IS WRONG AT COMPOSIO, WHICH IS THE ENTIRE MESSAGE. A cancelled request is a caller's + * own abort, so sending somebody to a key or a status page is sending them to look at two things + * that are working. The one honest thing to add is the ambiguity: a call cancelled in flight may + * or may not have been acted on at the vendor, and this deployment cannot tell which. + */ + case "ComposioRequestCancelledError": + return refusal( + `The request was cancelled before Composio answered, so ${outcome} as far as this deployment can tell — and how far it had got when the cancellation landed is exactly what it cannot tell. Nothing is wrong at Composio and nothing needs setting here; asking for it again is what settles which of the two it was.`, + ); + + /* + * THE ACCOUNT IS GONE AT THE VENDOR, WHICH THIS DEPLOYMENT'S ROWS DO NOT KNOW. `tools.execute` + * maps API error code 1803 onto this class (`src/errors/ToolErrors.ts`), and what it reports is + * a person whose `composio_connections` row still stands over an account Composio no longer + * holds — a grant withdrawn at Google, or an account removed in the dashboard. Retrying reaches + * neither; connecting again is what puts an account back under the row. + */ + case "ComposioConnectedAccountNotFoundError": + return refusal( + `Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`, + ); + + /* + * THE ACTION COULD NOT BE FETCHED, WHICH IS NOT THE SAME CLAIM AS THE ONE THIS USED TO MAKE. + * + * The sentence here read "Composio no longer publishes that action", on the strength of the + * class's name. The name does not carry that: `getRawComposioToolBySlug` wraps its whole + * retrieve in a try whose catch rethrows EVERYTHING except a cancellation as this class — + * `throw new ComposioToolNotFoundError(\`Unable to retrieve tool with slug ${"${slug}"}\`, { cause: error })` + * (`@composio/core` 0.18.1, `src/models/Tools.ts:709-721`) — and `tools.execute` resolves + * through that same method (`:1163`). So a 500, a 429, a refused key, a socket that hung up and + * an action genuinely withdrawn all arrive under one name, and nothing on the error tells them + * apart. An outage was being reported to an administrator as a catalogue change, with an + * instruction to press Refresh at a vendor that was not answering. + * + * WHAT CAN HONESTLY BE SAID IS THAT IT COULD NOT BE FETCHED, AND WHICH TWO READINGS THAT HAS. + * The refresh stays in the sentence because a withdrawn action is the commonest of them and + * the refresh is the only act that settles it — but it is named as the remedy for ONE of the + * readings rather than as the remedy, and the reader is given the fact that separates them: + * whether every other action of every other app is failing too. That is a thing they can look + * at, which "no longer publishes" was not. + * + * THE VENDOR'S OWN WORDS STILL WIN WHERE THERE ARE ANY. Where the failure underneath was an API + * error carrying a server sentence, the guard at the top of this function has already returned + * null and none of this is reached — so what this row answers is the half of the class that + * explained itself least. + */ + case "ComposioToolNotFoundError": + return refusal( + `Composio would not hand that action over at the version this deployment recorded for it, so ${outcome}. This deployment's @composio/core reports an action Composio has withdrawn and a request for one that failed — a timeout, a dropped connection, a 500, a refused key — under one condition and says nothing that tells the two apart, so neither can this deployment. Refreshing ${app}'s tools on its Plugins page records what Composio publishes now, which settles it where the action is gone; where every action of every app is failing the same way, it is the request rather than the action, and Composio's status page is where that shows.`, + ); + + /* + * A RECORDED VERSION OF "latest" IS A ROW THAT NEEDS REWRITING, not a call that needs repeating. + * The SDK refuses `latest` for a tool executed one at a time (`src/models/Tools.ts`), and the + * version travelling with a call is whatever the listing wrote down for the action, so the fix + * is on the row rather than at Composio. + */ + case "ComposioToolVersionRequiredError": + return refusal( + `Composio refuses a call whose toolkit version is "latest", and that is the version travelling with this one, so ${outcome}. A dated version is recorded when an app's actions are listed, so refreshing ${app}'s tools on its Plugins page replaces "latest" with a version Composio will accept.`, + ); + + /* + * AN ANSWER THE VENDOR'S OWN PACKAGE COULD NOT READ, WHICH IS THE ROW THE FILE HAD BACKWARDS. + * + * This used to be classified as a bug of this deployment's — see the note now on + * {@link askForEach} — on the premise that a `TypeError` is what a program's own mistake looks + * like and never something Composio can reply. Running `@composio/core` 0.18.1 falsifies that + * outright. Its list transformers dereference the answer before returning it, so a malformed + * reply dies inside the vendor's code and arrives here as a bare `TypeError`: + * `response.items.map(transformAuthConfigRetrieveResponse)` off a bare list or an `items` that + * is a string (`src/utils/transformers/authConfigs.ts:79`), `authConfig.toolkit.logo` off a row + * that is not an object (`:41`), and the same two shapes at + * `src/utils/transformers/connectedAccounts.ts:113` and `:60` and at `src/models/Tools.ts:561`. + * Five vendor answers, five `TypeError`s, none of them this deployment's doing. + * + * IT IS TRANSLATED HERE BECAUSE HERE IS WHERE IT SURFACES. {@link askVendor} wraps the + * `await vendor.*` and nothing else, so a throw reaching this function came out of the vendor's + * code by construction rather than by inspection — which is exactly the distinction the guards + * this replaces were trying to make one layer too late, in readers the SDK never let them reach. + * + * WHAT THE READER IS TOLD IS DELIBERATELY NOT A THING TO TRY. Nobody holding an admin page can + * correct the shape of a reply, and "try again" would be advice to repeat a request that will + * be answered identically; the one act that changes anything is a package upgrade, and the two + * things the route's default sends an operator to check are both already proven fine — the + * request went out and Composio replied to it. The crash itself is carried as `cause` and never + * quoted: a sentence that reads like a stack trace is what every refusal here exists not to be. + */ + case "TypeError": { + const about = call.app === null ? "" : ` for ${call.app}`; + return refusal( + `Composio's answer${about} was a shape this deployment's @composio/core could not read, so ${outcome}. The failure was raised inside the vendor's own package as it read the reply, so the request went out and Composio answered it: neither the key nor anything on this deployment's pages is what to check. ${VENDOR_SHAPE_REMEDY}`, + ); + } + + default: + return null; + } +} + +/** + * One vendor call, with whatever it refuses with translated on the way out. + * + * WRAPPED AROUND THE `await vendor.*` AND NOTHING ELSE, which is the discipline that makes this + * safe to apply everywhere. Everything else inside these methods is this file's own reading and + * refusing, and those already carry authored sentences; passing one back through + * {@link vendorRefusal} could only find a name it does not know, but the narrower scope is what + * makes that true by construction rather than by inspection. + * + * A CALL WITH NO ENTRY HERE IS THE FAILURE MODE THE TABLE IN THE TESTS EXISTS FOR. TypeScript has + * no checked exceptions, so nothing enumerates the calls that translate and nothing notices a new + * one that does not; the seam's own test walks every method of both projections and fails the + * method that forgot. + */ +export async function askVendor( + call: VendorCall, + ask: () => Promise, +): Promise { + try { + return await ask(); + } catch (error) { + const refusal = vendorRefusal(error, call); + if (refusal !== null) throw refusal; + throw error; + } +} + +/** + * Every reason a set-wide refusal collected, in one value a `cause` can hold. + * + * ALL OF THEM, WHICH IS THE CORRECTION. The two loops below used to throw with `cause: refused[0]`, + * so a person with five accounts of which three refused left one reason attached and two discarded + * — and the sentence those throws carry is a COUNT, deliberately, because a count is the thing a + * reader can act on. The reasons were therefore the only place the detail existed at all, and two + * thirds of it was being dropped on the floor. + * + * CARRIED RATHER THAN LOGGED, and that is this file's rule rather than a preference. A vendor error + * out of `connectedAccounts.link` or an account delete carries the request it was made for, and a + * console line is a line in an aggregator; nothing in this module logs a vendor object, for the same + * reason nothing in it logs the key. A `cause` travels to whoever is already holding the failure. + * + * ONE REFUSAL STAYS ITSELF. Wrapping a single error in an `AggregateError` would make every reader + * unwrap a list to find one thing, and `store.ts` already reads `error.cause` directly. + */ +export 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.`, + ); +} diff --git a/server/src/plugins/oauth.ts b/server/src/plugins/oauth.ts index 9ce118333..c11c4e978 100644 --- a/server/src/plugins/oauth.ts +++ b/server/src/plugins/oauth.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { seal, unseal } from "../auth/signed-value"; +import { OAUTH_REQUEST_TIMEOUT_MS } from "../timeouts"; import type { CatalogueAuth } from "./catalogue"; /** @@ -375,7 +376,7 @@ export async function redeemAuthorizationCode(input: { * Manual leaves the 3xx as the response, which is not `ok`, so it falls into the refusal below. */ redirect: "manual", - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS), }); } catch (error) { /* @@ -482,7 +483,7 @@ export async function registerDynamicClient(input: { // The registration endpoint is pinned in the catalogue, so a redirect is somebody else deciding // where this deployment introduces itself. Left as the response, which is not `ok`. redirect: "manual", - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS), }); } catch (error) { // Logged for the same reason the redemption above is: the caller's 502 tells an administrator diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts new file mode 100644 index 000000000..b4c8e76bf --- /dev/null +++ b/server/src/plugins/plugin-store/calls.ts @@ -0,0 +1,550 @@ +import { and, eq } from "drizzle-orm"; +import { currentApprovalContext } from "../../approvals/types"; +import { type AuditInitiator, recordAuditEvent } from "../../audit"; +import { + evaluateActionPolicy, + type PolicyContext, + policyInitiator, +} from "../../computer/policy"; +import { withoutStatement } from "../../db/query-failure"; +import { mcpTools, pluginGrants } from "../../db/schema"; +import { classifyTool } from "../catalogue"; +import { VERSION_ARG } from "../composio"; +import { inspectToolArguments } from "../content-governance"; +import { shareTargetOf } from "../share-target"; +import type { AccountMode } from "../shared-accounts"; +import { transportFor } from "../transport"; +import type { StoreContext } from "./context"; +import { + effectiveUrl, + type PluginDecision, + type PluginKind, + PluginRefusedError, + reachedAsFor, + toolNameFor, + UNATTRIBUTED_ACTOR, + withoutEmptyOptionals, +} from "./model"; + +/** Deciding whether a Bot may call a tool, and calling it. */ +export function callMethods(context: StoreContext) { + const { + options, + database, + auditStore, + injectedVendor, + connectionTokenFor, + requireServer, + } = context; + return { + /** + * May this Bot use this plugin? + * + * The single question every caller asks, so there is one place the answer is decided and one + * place to audit it. A missing row is a refusal, not an oversight. + */ + async decide( + kind: PluginKind, + ref: string, + agentId: string, + ): Promise { + const [row] = await database + .select() + .from(pluginGrants) + .where( + and( + eq(pluginGrants.kind, kind), + eq(pluginGrants.ref, ref), + eq(pluginGrants.agentId, agentId), + ), + ) + .limit(1); + + if (!row) { + return { + allowed: false, + reason: + kind === "mcp" + ? `This Bot has not been given the tool ${ref}.` + : `This Bot has not been given the skill ${ref}.`, + }; + } + return { allowed: true }; + }, + + /** + * Call a tool on somebody else's server, on a Bot's behalf. + * + * Decide, record, then act, which is the order the computer gateway uses and for the same + * reason: a call that was permitted and then failed is exactly what an investigation needs to + * see, and a trail written only on success cannot show it. The grant is checked first because a + * tool this Bot was never given should not reach the policy engine, the vault or the network. + */ + async callTool(input: { + ref: string; + args: Record; + botId: string; + actorId: string; + initiator?: AuditInitiator; + /** + * Whose connected account the call goes out on, when that is not the asker's: a Team Bot + * reaching its owner's account (team-bots/team-bots.ts). Every gate is still the asker's. + */ + credentialActorId?: string; + }): Promise<{ text: string; isError: boolean }> { + const [serverId, ...rest] = input.ref.split("/"); + const toolName = rest.join("/"); + if (!serverId || !toolName) { + 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, { + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + payload: { + actor: auditActor, + bot: input.botId, + server: serverId, + tool: toolName, + refusal: "not_granted", + reason: decision.reason, + }, + }); + throw new PluginRefusedError(decision.reason, null); + } + + const { row, entry, access, app } = await requireServer(serverId); + + const advertised = await database + .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, + advertised[0]?.effect, + ); + + const args = withoutEmptyOptionals( + input.args, + advertised[0]?.inputSchema as Record | undefined, + ); + + /* + * The version this action was listed at, handed to the transport that needs one. + * + * Under a reserved key rather than as a parameter on the shared signature, because that + * signature is MCP's and three other transports implement it. The Composio transport strips + * the key before anything reaches the vendor, and asserts that it did. + * + * A `__version` in the model's own arguments is not an argument: it is this key, and no + * vendor publishes it. So it is stripped unconditionally, whatever its value, and that strip + * is the whole protection. The recorded version is then merged into arguments that provably + * cannot carry the key, which makes both spread orders identical: the merge order has no + * reachable failure mode. Do not read the strip as belt-and-braces on top of an ordering + * guarantee — the ordering is the redundant half, and removing the strip is what would let a + * model choose which revision of an action runs. + * + * Absent when the app has not been refreshed since the column existed, and because the key + * was stripped there is then no version at all for the transport to read, which is what makes + * its refusal hold rather than guessing — a guessed version is a call against an action's + * other behaviour. + */ + const { [VERSION_ARG]: _dropped, ...modelArgs } = args; + const vendorArgs = advertised[0]?.version + ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } + : modelArgs; + + /** + * The same policy the computer actions are judged by, asked about a tool call. + * + * Every field is present, including the ones a tool call has no use for, and that is load + * bearing rather than tidy. This engine treats an expression it cannot evaluate as a match, + * which is correct for a browser action on an element the server could not resolve. Applied to + * a tool call it is a disaster: the boundary this product ships in `.env.example` denies + * `contains(element.name, "submit") || key == "Enter"`, and with `element` and `key` absent + * that rule is unevaluable, so it would match, so every deployment using the shipped preset + * would refuse every MCP call for a reason mentioning a submit button. + * + * Neutral values instead. Empty strings match no substring, no key and no extension, so a rule + * written about the browser evaluates to false against a tool call, which is the honest answer: + * a tool call did not click anything. A rule meant to catch tool calls says so, with `mcp` or + * with `intent`. + */ + const context: PolicyContext = { + tool: { name: toolNameFor(input.ref) }, + bot: { id: input.botId }, + actor: { id: input.actorId }, + page: { url: "", host: "" }, + element: { ref: "", role: "", name: "", type: "" }, + key: "", + file: { path: "", name: "", extension: "" }, + // Empty, like the browser fields above: an MCP call runs no shell command, but a + // `deny: contains(command, "rm -rf")` names `command`, and an unbound identifier throws and + // fails closed — which would refuse every MCP call once a deployment wrote a rule about its + // shell. An empty command matches no such rule. + command: "", + intent: effect === "write" ? "write_tool" : "read_tool", + mcp: { server: serverId, tool: toolName, effect }, + /* + * The real one, and this is the path where it is not neutral. A routine's turn reaches its + * tools through here, carrying the initiator its run assertion was signed with, so this is + * where `initiator.kind == "routine"` becomes a rule a deployment can actually write. A + * chat turn arrives with none and reads as a person. + */ + initiator: policyInitiator(input.initiator), + }; + + const verdict = evaluateActionPolicy(options.policy(), context); + + /* + * The parts of the row that are known before the attempt, held rather than written. + * + * Everything here is a fact about the decision, and the decision is final at this point. What + * is NOT yet known is whether the call worked, which is why this is a variable and not a write: + * the row goes down once, after the outcome exists. + */ + const decided = { + actor: auditActor, + bot: input.botId, + server: serverId, + tool: toolName, + effect, + /* + * Whose credential this call goes out with. + * + * Without it the trail cannot answer "who did this run reach as", which is the whole question + * 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( + access, + input.credentialActorId ?? input.actorId, + ), + decision: { + allowed: verdict.allowed, + mode: verdict.mode, + rule: verdict.matched, + source: verdict.source, + carriedOut: verdict.forward, + }, + }; + + /* + * A refusal is written on the POLICY's answer, not on whether the call was then let through. + * + * This deployment declining is the whole event, and it is recorded before the throw so that a + * refusal cannot be lost by the caller's error handling. + * + * In `dry-run` the policy still refuses and the mode forwards anyway, which is the whole point + * of the mode: `evaluateActionPolicy` returns `allowed: false` with `forward: true` so a rule + * can be tried against live traffic before it starts refusing anybody. Writing this row on + * `forward` therefore recorded nothing at all on this surface for exactly the traffic an + * operator switched dry-run on to measure — the browser gateway keys its row on + * `decision.allowed` and does record it — so `Blocked` on the audit page, and every + * `eventType=mcp.call_rejected` query behind it, answered "this rule would refuse none of your + * tool calls" about calls it would refuse. The rule then looked inert, and enforcing it + * started refusing Bots with no warning in the trail. + * + * `decision.carriedOut` is what tells the two rows apart: false is a call this deployment + * stopped, true is one dry-run recorded and let past. The outcome row below is unchanged, so a + * forwarded call still says separately whether the vendor answered. + */ + if (!verdict.allowed) { + await recordAuditEvent(auditStore, { + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + payload: decided, + }); + } + if (!verdict.forward) { + throw new PluginRefusedError(verdict.reason, verdict.matched); + } + + /** + * Structural policy answers whether this Bot may call this tool. Content inspection answers + * whether the arguments would carry a credential out of the deployment. It runs after policy + * and before credentials are read or a vendor is contacted, and its result contains paths and + * categories only: never the values it refused. + * + * IT IS ASKED ABOUT WHAT WOULD BE SENT, WHICH IS `vendorArgs` AND NOT `args`. + * + * CRITERION. The object this inspection judges is the object handed to the transport below, + * identically — not a version of it taken before the reserved key was stripped and the + * recorded one merged in. + * + * REASON. It was asked about `args`, so the gate and the call were about two different + * objects, and they came apart in both directions. Whatever is merged in below the strip left + * this deployment WITHOUT HAVING BEEN LOOKED AT, which is not a boundary at all — it is a + * boundary around a neighbouring value. And the reserved key, which is stripped + * unconditionally and provably reaches no vendor, was still judged: a model that put anything + * credential-shaped under `__version` had its granted call refused and its person told the + * arguments carry credential material, over material that was never going anywhere and a + * refusal no rule of this deployment's asked for. + */ + const contentDecision = inspectToolArguments(vendorArgs); + if (!contentDecision.safe) { + await recordAuditEvent(auditStore, { + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + payload: { + ...decided, + decision: { ...decided.decision, carriedOut: false }, + refusal: "sensitive_tool_arguments", + contentInspection: { + reason: contentDecision.reason, + findings: contentDecision.findings, + }, + }, + }); + throw new PluginRefusedError( + contentDecision.reason === "sensitive_content" + ? "The tool call was refused because its arguments contain credential material." + : "The tool call was refused because its arguments could not be inspected safely.", + null, + ); + } + + /* + * A write that sends content to other people asks the owner first, whatever their general + * approvals switch says. Pending throws the check's own wait, which a headless turn and the + * open chat both turn into a paused tool call; denied is a refusal like any other. + */ + const share = + effect === "write" && options.privateShareCheck + ? shareTargetOf(serverId, toolName, vendorArgs) + : null; + if (share && options.privateShareCheck) { + const shareVerdict = await options.privateShareCheck({ + ownerUserId: input.actorId, + botId: input.botId, + audience: share.audience, + content: share.content, + origin: { kind: "unknown" }, + }); + if (shareVerdict.status !== "allowed") { + await recordAuditEvent(auditStore, { + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + payload: { + ...decided, + decision: { ...decided.decision, carriedOut: false }, + refusal: + shareVerdict.status === "pending" + ? "private_share_pending" + : "private_share_denied", + }, + }); + // A pending share outside any run that can wait says so and sends nothing. + if (shareVerdict.status === "pending" && currentApprovalContext()) + throw shareVerdict.suspension; + throw new PluginRefusedError(shareVerdict.message, null); + } + } + + /* + * A SHARED ACCOUNT ANSWERS TO WHOEVER CAN STEER THE BOT, and that is checked on every call. + * + * The grant says this Bot may use the app; it says nothing about who may reach the Bot. A + * Shared app acts as one team account for everyone, so a Bot opened to more people than an + * administrator approved would hand that account to all of them. The gate decides that here, + * at call time, so an audience widened a moment ago is caught on its next call. With no gate + * wired there is nothing that can answer the question, and the call is refused rather than + * assumed safe. + */ + /* + * DECIDED ON, AND KEYED BY, THE APP'S ANSWERING ROW — never the dialled one. A duplicate row + * at a Shared app's url saying `personal` must not take the call round this gate, and an + * approval is a fact about the app, so it is asked for under the app's id whichever row the + * grant happens to name. `gatedMode` then travels to {@link connectionTokenFor}, which + * refuses if the account it resolves is not the one this gate was decided about. + */ + const gatedMode: AccountMode | null = + access.credential !== "brokered" + ? null + : app?.accountMode === "shared" + ? "shared" + : "personal"; + if (gatedMode === "shared" && app) { + const verdict = options.sharedUse + ? await options.sharedUse({ + botId: input.botId, + serverId: app.id, + title: row.title, + actorId: input.actorId, + ...(input.initiator ? { initiator: input.initiator } : {}), + }) + : { + allowed: false as const, + message: `${row.title} is shared, and this deployment cannot check who may use it, so it was not called.`, + }; + if (!verdict.allowed) { + await recordAuditEvent(auditStore, { + eventType: "mcp.call_rejected", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + payload: { + ...decided, + decision: { ...decided.decision, carriedOut: false }, + refusal: verdict.refusal ?? "shared_audience", + }, + }); + throw new PluginRefusedError(verdict.message, null); + } + } + + /* + * THE APPROVAL GATE IS ASKED UNDER THE APP'S ID TOO, for a brokered row whose app has an + * answering row. Rules are written against the app — the Shared default is `${appId}/*` + * scoped to the app's id — so a write granted through a duplicate row has to be judged as + * the app's, or it matches no rule and walks past the ask an administrator set. `target` + * still names the dialled row, which is what the call itself goes to; every other row is + * asked about exactly as before. + */ + const gateApp = access.credential === "brokered" ? app : null; + const approval = await options.approvalGate?.({ + actorId: input.actorId, + botId: input.botId, + toolRef: gateApp ? `${gateApp.id}/${toolName}` : input.ref, + effect, + scope: gateApp ? gateApp.id : serverId, + args: vendorArgs, + target: { serverId, toolName, url: row.url, effect }, + continuation: currentApprovalContext(), + }); + if (approval && "replay" in approval) + return approval.replay as { text: string; isError: boolean }; + + /* + * Attempt first, record second. + * + * The row now says what HAPPENED rather than what was permitted. It used to be written here, + * before the two lines below, which meant a call that died at the vendor left `call_succeeded` + * behind it — and a per-person connector fails at exactly these two lines: no connection for + * the asker, a refresh token the vendor no longer accepts, an API not enabled for the project. + * Every one of those was invisible, and worse than invisible, because the trail asserted the + * opposite. + * + * `isError` counts as a failure. A vendor that answers the protocol correctly to say the tool + * itself failed has not completed the call, and a reader counting successes should not be told + * it did. + */ + try { + const { token, vendorUserId } = await connectionTokenFor( + row, + entry, + input.credentialActorId ?? input.actorId, + access, + gatedMode, + ); + const vendor = + injectedVendor ?? transportFor(access.transport).callTool; + const result = await vendor( + { + url: effectiveUrl(row, entry), + token, + actorId: vendorUserId ?? input.credentialActorId ?? input.actorId, + botId: input.botId, + }, + toolName, + vendorArgs, + ); + await recordAuditEvent(auditStore, { + eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", + targetType: "mcp_tool", + targetId: input.ref, + ...(input.initiator ? { initiator: input.initiator } : {}), + /* + * The vendor's own words, when it is reporting a failure. + * + * Only on the failure branch, and this is the whole point of the distinction. A successful + * result is somebody's data — a file listing, a document — and it has no business in an + * audit row that an administrator can read. An `isError` result is a message written for + * whoever operates this deployment, and it is the most useful sentence available: Google + * refuses the Drive MCP server with "The caller does not have permission", which named the + * problem after a generic message had already cost a round of probing. + * + * Capped, because the failure branch is not a promise about length. + */ + payload: result.isError + ? { + ...decided, + failure: + result.text.slice(0, 400) || "the tool reported an error", + } + : decided, + }); + const answer = { text: result.text, isError: result.isError }; + await approval?.complete(answer); + return answer; + } catch (error) { + /* + * Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is + * that the failure now exists in the trail, which is where somebody asking "is this connector + * working" looks. The vendor's own sentence is kept, since for a 403 that is the sentence + * naming which API is not enabled. + * + * Capped like the `isError` branch above, and for the same reason: parts of this sentence + * came from the vendor, and a failure is not a promise about length. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.call_failed", + targetType: "mcp_tool", + targetId: input.ref, + ...(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 + ? withoutStatement(error) + : String(error) + ).slice(0, 400), + }, + }); + throw error; + } + }, + }; +} diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts new file mode 100644 index 000000000..ab748bd12 --- /dev/null +++ b/server/src/plugins/plugin-store/connections.ts @@ -0,0 +1,2495 @@ +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { recordAuditEvent } from "../../audit"; +import { + brokeredConnections, + credentials as credentialRows, + mcpServers, + mcpTools, + mcpUserCredentials, + users, +} from "../../db/schema"; +import { + BrokerRefusalError, + BrokerUnconfiguredError, + type Decides, + isFieldScheme, + type SchemeKind, +} from "../broker"; +import { + asksForArguments, + askAction as composioAskAction, + toolkitOf, + VERSION_ARG, +} from "../composio"; +import type { AccountRef } from "../shared-accounts"; +import type { StoreContext } from "./context"; +import { + type BrokeredProbe, + IDENTITY_ACTION, + iso, + type OAuthClient, + PluginRefusedError, +} from "./model"; + +/** OAuth clients and a person's connections, including every brokered (Composio) connection flow. */ +export function connectionMethods(context: StoreContext) { + const { + options, + database, + auditStore, + credentials, + registerClient, + broker, + accountRow, + swapUserCredential, + withOAuthClientLock, + heldOAuthClient, + writeOAuthClient, + recordClientRegistered, + persistOAuthClient, + storedOAuthClient, + requireServer, + brokeredAppRow, + brokeredAppRowsAt, + brokeredAppScheme, + brokeredAppKind, + } = context; + return { + /** + * Register the deployment's OAuth client for a `user-oauth` server. + * + * An administrator pasting in what they created at the vendor. The work itself is + * {@link persistOAuthClient}, which self-registration goes through too — one path, so a client + * this deployment issued itself is stored, revoked and recorded exactly like a pasted one. + */ + registerOAuthClient: persistOAuthClient, + + /** + * The client to send somebody to the vendor with, registering one first if that is this + * vendor's way of getting one. + * + * A dynamically registered client is not paperwork anybody did: there is no console entry to + * paste, so "none yet" is the ordinary state of a server nobody has connected — and the answer + * is for the deployment to introduce itself, which is what it would have to do eventually + * anyway. Where an administrator registers by hand instead, and where the deployment has no + * public URL to be sent back to, the answer stays null: inventing a client at a vendor that + * never offered to issue one, or registering a redirect URI that resolves to nothing, would + * both leave behind a client that can never complete a consent flow. + * + * ONE CLIENT PER DEPLOYMENT EVEN WHEN TWO PEOPLE ASK AT ONCE. This is `requireUser`'s handler, so + * two first connects racing is the ordinary first hour of a connector. Registering twice is not + * merely wasteful: the loser's consent screen names a client the vault no longer holds, so that + * person consents and their callback then redeems the code against the client that replaced it — + * a connect that fails after the vendor already said yes. So the registration happens under + * {@link withOAuthClientLock}, with the "do we hold one" question asked AGAIN inside it, and the + * second caller finds the first one's client and is handed the same one. + * + * The lock is held across the registration request to the vendor, deliberately. It is one round + * trip with its own timeout, and a lock released before it would serialise nothing. + */ + async ensureOAuthClient( + serverId: string, + by: string, + ): Promise { + const stored = await storedOAuthClient(serverId); + if (stored) return stored; + + const { entry } = await requireServer(serverId); + if ( + entry?.auth.kind !== "user-oauth" || + entry.auth.clientRegistration !== "dynamic" || + !entry.auth.registrationUrl || + !options.redirectUri + ) { + return null; + } + // Held before the lock, because narrowing does not survive into the closure below. + const { registrationUrl } = entry.auth; + const { redirectUri } = options; + + const outcome = await withOAuthClientLock( + serverId, + async (transaction) => { + /* + * Asked again, under the lock. The read above was a fast path taken without one, and by now + * the caller we were racing has committed a client of its own — which is the one this + * deployment holds, so it is the one to consent against. + */ + const held = await heldOAuthClient(transaction, serverId); + if (held) return { client: held, registered: false, replaced: false }; + + const registered = await registerClient({ + registrationUrl, + redirectUri, + }); + if (!registered) return null; + + const { replaced } = await writeOAuthClient( + { serverId, client: registered }, + transaction, + ); + return { client: registered, registered: true, replaced }; + }, + ); + + if (!outcome) return null; + // Only what this call actually did. A trail row for handing back somebody else's client would + // claim a registration that never happened. + if (outcome.registered) { + await recordClientRegistered( + { serverId, client: outcome.client, by }, + outcome.replaced, + ); + } + return outcome.client; + }, + + /** + * Record that one person connected their own account to one server. + * + * The credential swap is {@link swapUserCredential}, and this is its only caller: a person + * connecting or reconnecting is exactly when there is an older grant to revoke. Rotation writes + * the same connection in place instead ({@link rotateConnectionToken}). What is only here is the + * audit row: this one IS somebody's act, and the trail should say so. + */ + async recordConnection(input: { + serverId: string; + userId: string; + refreshToken: string; + scope: string; + }): Promise { + const { replaced } = await swapUserCredential(input); + + await recordAuditEvent(auditStore, { + eventType: "mcp.account_connected", + targetType: "mcp_server", + targetId: input.serverId, + payload: { + actor: input.userId, + server: input.serverId, + // What the vendor granted, so a later refusal for want of a scope can be explained. + scope: input.scope, + reconnected: replaced, + }, + }); + }, + + /** + * The deployment's OAuth client for a server, or null if none is registered. + * + * Reads only. {@link ensureOAuthClient} is the one that will go and get one. + */ + oauthClientFor: storedOAuthClient, + + /** Which `user-oauth` servers this person has connected, for their own settings page. */ + async connectionsFor( + userId: string, + ): Promise<{ serverId: string; scope: string; connectedAt: string }[]> { + const rows = await database + .select({ + serverId: mcpUserCredentials.serverId, + scope: mcpUserCredentials.scope, + connectedAt: mcpUserCredentials.connectedAt, + }) + .from(mcpUserCredentials) + .where(eq(mcpUserCredentials.userId, userId)) + .orderBy(asc(mcpUserCredentials.serverId)); + + return rows.map((row) => ({ + serverId: row.serverId, + scope: row.scope, + connectedAt: iso(row.connectedAt) ?? "", + })); + }, + + /** + * 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 fields this shares with + * {@link connectionsFor} — `serverId`, `scope`, `connectedAt` — line up and one screen can draw + * both kinds of row. What comes back here is a SUPERSET of that shape rather than the same one: + * `verified` and `verifiedAt` ride along too, and only on a brokered row, because only a + * brokered row is a thing this deployment can re-check. + * + * `verified` AND `verifiedAt` COME ALONG BECAUSE "connected" IS NOT A PRESENT TENSE HERE. + * Composio never re-checks a key somebody typed in: it answers ACTIVE for as long as the row + * exists, whatever the vendor on the other side now thinks of that credential. So a page drawn + * off `connectedAt` alone would assert something this deployment has not known since the day it + * was written. These two fields are what lets it say when the claim was last earned instead — + * "connected with a key you provided, last checked 13 Sep". What the pair separates is a + * CHECKED key connection from an unchecked one, and nothing more: which KIND of connection a + * row is comes from the app's recorded {@link ServerRecord.authScheme}, which the page branches + * on first, and not from anything answered here. + * + * `probe` IS THE ACTION THE LAST CHECK SPENT, READ OUT OF THE ROW. The pair above says whether a + * key connection was ever checked; it cannot say WHY one was not, and three different + * situations share the one word `false`. Until this field, only the answer to a connect or a + * re-check could tell them apart — so a page reload lost the distinction, and the worst of the + * three degraded into the mildest: a row saying the key was accepted without being checked, over + * an account whose key the vendor had actually REFUSED. + * + * IT IS STORED BECAUSE IT IS A FACT ABOUT A MOMENT, NOT ABOUT TODAY'S METADATA. This field was + * once derived here, by asking {@link probeActionFor} which action this deployment WOULD check + * the app with; the argument for that was that the chooser holds every condition the probe + * itself runs on, so the two could not disagree. They cannot disagree AT AN INSTANT, and that is + * all it establishes. `verified` records a check made against the app's action listing as it + * stood THEN, and the chooser answers from the listing as it stands NOW — and `POST + * /servers/:id/refresh` is a generic administrator's route keyed on a server id, of which + * `composio-` is one, so an ordinary press of Refresh moves the second without touching + * the first. It is the very press the Composio transport tells an operator to make when an + * action appears or gains the version that makes it callable. So: somebody connects a key to an + * app that publishes nothing safe to try it on, and the row honestly says the key was accepted + * unchecked. An administrator presses Refresh. From that page load on, the derivation named an + * action, and the row drew the sentence written for a REFUSED key — your key was checked against + * this app and rejected, the account it was checked in still stands, so disconnect it. Every + * clause of that is false for somebody whose key was never tried, it tells them to take down a + * connection that works, and it persists: it is what every page load says until they press + * Re-check. + * + * SO THE WRITER RECORDS WHAT IT SPENT AND THIS READS IT BACK. {@link recordBrokeredConnection} + * is the single writer, every path into it knows the action it spent or that it spent none, and + * {@link composioConnections.probeAction} is where that goes. Read together with `verified`, the + * column tells four states apart, and no inference is made in any of them: + * + * no probe, not verified — nothing was tried: at the time of the check the app published + * nothing safe to spend a key on. A fact about the app, not the + * key. A key row written before the column existed reads this way + * too, for the reason that column gives. + * no probe, verified — a CONSENT connection. The vendor's own yes at its own screen is + * the evidence, no call was ever made against the account, and so + * there is no action to name. + * a probe, verified — it ran in this person's account and the vendor took the key. + * a probe, NOT verified — it ran and the vendor refused the key, and the account it ran in + * is still standing. A live account with a bad key behind it. + * + * AND THE LAST LINE IS A RECORD RATHER THAN AN INFERENCE, which is the whole of what changed. + * The caveat that stood here used to argue the state into existence: a key connection is ALWAYS + * probed at connect time, a probe that fails withdraws the account it just made, so an + * unverified row under an app that HAS a probe must be a refusal whose withdrawal failed — or + * else a re-check the vendor refused, which leaves the person's own older account alone. That + * reasoning was sound about the rows it described and said nothing about the row a refresh had + * quietly moved underneath it. What the row now warrants, it warrants by having been written: + * the check ran, it spent this action, and the vendor refused. + * + * WHICH PATH WROTE IT IS STILL NOT RECOVERABLE, and a reader must not invent one. The column + * records what was spent, not who spent it, and the two writers of that pair end differently: a + * connect withdraws the account it had just made and only leaves the row where Composio refused + * to take it back, while a re-check never withdraws anything, because the account predates the + * press and is the person's own. So a page may say the key was refused and the account stands; + * a page that goes on to blame a failed withdrawal is right on the connect path and FALSE on the + * re-check, where nothing ever tried to remove anything. + * + * `checkable` IS THE OTHER QUESTION, AND IT TRAVELS SEPARATELY BECAUSE COLLAPSING THE TWO IS + * WHAT DEADLOCKED THE SETTINGS SCREEN. "What did the check SPEND" is a fact about the past, and + * `probe` answers it. "Does this app have anything to check with TODAY" is a fact about the + * present, and this answers that — out of {@link probeActionFor}, asked of the APP rather than + * of the connection. The two agreed for as long as `probe` was derived: one field, one moment, + * two questions nobody ever had to tell apart. They part company the instant it became a + * record, which is the same instant it started being right about the past. + * + * THE DEADLOCK IN FULL, BECAUSE IT DOES NOT SELF-HEAL. The page gates its Re-check button on + * whether there is anything to check with, and it read `probe` for that. Somebody connects a + * key to an app that publishes nothing safe to spend it on: the check spends nothing, the row + * records null, and both of those are correct and permanent. An administrator presses Refresh, + * the app gains a safe versioned read, and the button is STILL withheld — because the record + * still says, truthfully, that nothing was spent. And pressing that button is the only thing in + * this product that can ever put an action into the record. The state is stable, wrong, and + * unreachable from inside itself: the single act that would end it is the act being withheld. + * + * SO A CALLER TAKES THE PAST FROM ONE AND THE PRESENT FROM THE OTHER, and must take neither out + * of the other one. A screen drawing its SENTENCE off `checkable` would accuse a key nobody + * tried, which is the defect the recorded column was made for; a screen gating its BUTTON on + * `probe` is the deadlock above. Neither field is a weaker spelling of the other, and the + * moment one is asked to answer both questions the two failures simply trade places. + * + * WHICH COSTS ONE QUERY PER CONNECTED APP, AND THAT IS THE RIGHT PRICE. Making `probe` a stored + * column took the per-row call out and left a listing that was one query; this puts it back — + * on top of the two the rows themselves now take, which the body below says why. + * The alternative is to fold the chooser's rule into the join — vendor-labelled read, not + * destructive, no required inputs, a recorded version — and that rule has no honest spelling in + * SQL: `required` is the vendor's own JSON Schema stored unchanged, and deciding whether it is + * a non-empty list of names is a thing JavaScript does and a `json` operator does badly. So + * folding it in means writing the rule a SECOND time, in a second language, over the same rows, + * and this file already records what a rule in two places costs: while the version condition + * sat in one of them, an app whose chosen action had no version connected honestly as "nothing + * was tried" and reloaded as "your key was checked and rejected". {@link probeActionFor} is the + * one authority on what can be checked, and a listing that asks it N times cannot disagree with + * the probe that asks it once. N is the apps ONE person has connected — a handful of indexed + * reads by server id, made in parallel — behind a settings page and not on any hot path. + * + * `verifiedAt` STAYS NULL WHERE IT IS NULL, unlike `connectedAt`, which collapses to `""` + * because a row cannot exist without one and the fallback is unreachable. Null here is + * reachable and it means something: never checked. Folding it into `""` would hand the page a + * row that was checked at a time nobody recorded, which is a different fact and not one this + * table ever holds. Note also what {@link composioConnections.verified} sets out about the rows + * migration 0038 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; + verified: boolean; + verifiedAt: string | null; + probe: string | null; + checkable: boolean; + }[] + > { + /* + * THIS PERSON'S OWN ACCOUNTS AND NOTHING ELSE. `holder = 'person'` is said out loud rather + * than left to the user id, because a settings page listing "your connections" must never + * draw the deployment's shared account as one of them — a deployment row has no `user_id`, + * and the filter keeps that a fact this query states rather than one it happens to rely on. + */ + const connections = await database + .select({ + toolkit: brokeredConnections.app, + connectedAt: brokeredConnections.connectedAt, + verified: brokeredConnections.verified, + verifiedAt: brokeredConnections.verifiedAt, + probeAction: brokeredConnections.probeAction, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + ), + ); + + /* + * THE APP IS RESOLVED PER CONNECTION, NOT JOINED TO IT, BECAUSE THE URL IS NOT A KEY. + * + * CRITERION. One connection is one row out of here, whatever `mcp_servers` holds. + * + * REASON. This read used to be an inner join on `mcp_servers.url = 'composio://' || toolkit`, + * and nothing in the schema makes that column unique — the one `uniqueIndex` in + * `db/schema/plugins.ts` is `skills_slug_key` on `skills.slug`. A join answers one row per + * PAIR, so a second row at an app's url listed the same account twice: two rows on the + * settings page saying the same app under two different server ids, each offering to + * disconnect the single connection standing behind both. And it is not an exotic state — it + * is what any database looks like the moment a row at `gmail` sits beside the + * `composio-gmail` an administrator really added, which is how a development database + * routinely looks. + * + * A UNIQUE INDEX WOULD BE THE OTHER FIX AND IS THE WRONG ONE. Two rows at one address is a + * state this product means to allow — a deployment holding two accounts at one vendor adds + * the same URL twice under two ids with two credentials, and `addCustomServer` refuses only a + * re-add that MOVES an existing id's address. Constraining the column would forbid that for + * everybody to tidy a display defect, and the migration that added it would fail outright on + * any deployment already holding a duplicate, taking the whole boot with it. + * + * SO THE LOWER ID ANSWERS FOR THE APP, and it is a rule rather than whatever the scan met + * first: a page that redrew under a different `serverId` each load would be offering buttons + * keyed on a value moving underneath it. The same rule decides the scheme reads — see + * {@link brokeredAppScheme} — so nothing in this file can name one row for an app while + * something else names another. + * + * AND "LOWER" IS THE DATABASE'S OWN WORD FOR IT, WHICH IS WHY THE ORDER IS IN THE QUERY. This + * read took its rows unordered and picked the smallest with a JavaScript `<`, which compares + * UTF-16 code units and nothing else, while {@link brokeredAppRow} asks for `order by id` + * under whatever collation the deployment's database runs. Those two agree for ASCII on a `C` + * database and are free to disagree everywhere else — a linguistic collation reorders case and + * punctuation, and byte order and code-unit order part company above the BMP. One rule spelled + * in two languages is two rules, and where they parted the page drew an app under one server + * id while the Re-check button beside it, the probe behind that button and the scheme that + * decides whether the button appears at all were about the other row: the same defect the + * single read was written to end, reached through the collation instead of through the query. + * So the resolution is {@link brokeredAppRowsAt} and not a rule spelled again here: it is the + * same function {@link brokeredAppRow} answers one app out of, which is what makes the row + * this page draws an app under the row every read behind its buttons is about. + * + * A CONNECTION WITH NO ROW AT ITS URL IS STILL LISTED BY NOTHING, which is what the inner + * join answered and what the missing `serverId` below drops. A person can hold an account at + * an app this deployment has since removed, and the settings page has no row to draw for it. + */ + const named = await brokeredAppRowsAt( + connections.map((row) => `composio://${row.toolkit}`), + ); + + const rows = connections + .flatMap((row) => { + const serverId = named.get(`composio://${row.toolkit}`)?.id; + return serverId === undefined ? [] : [{ ...row, serverId }]; + }) + // By server id, as the join's own `order by` was, so this read and `connectionsFor` hand the + // route two lists ordered alike. Compared as plain strings rather than through a collation, + // for the reason the route gives where it merges them: the order only has to be the same + // one every time. + .sort((left, right) => { + if (left.serverId < right.serverId) return -1; + return left.serverId > right.serverId ? 1 : 0; + }); + + return await Promise.all( + rows.map(async (row) => ({ + serverId: row.serverId, + scope: "", + connectedAt: iso(row.connectedAt) ?? "", + verified: row.verified, + verifiedAt: iso(row.verifiedAt), + // The name alone, because that is what the four states are told apart by; the version the + // check was made at is the caller-of-the-call's business, and this read makes none. + probe: row.probeAction, + /* + * WHETHER, NOT WHICH. The chooser names an action and this keeps only the yes or no, + * because the yes or no is the whole of the question being asked: is there anything to + * spend a key on. Carrying the name would put a second action name on a row that already + * has one, inches from the field that records what was actually spent — and the first + * reader to draw a sentence off the wrong one re-opens the defect that made `probe` a + * record. A boolean cannot be mistaken for a record of anything. + */ + checkable: (await this.probeActionFor(row.serverId)) !== null, + })), + ); + }, + + /** + * The deployment's one account at a brokered app, as the admin page draws it; null where none + * is connected, and null for a server that is not brokered at all. + * + * `connectedBy` IS THE ADMINISTRATOR'S EMAIL, because an id is not something a page can show + * anybody. It falls back to the stored id when that person has since been removed, so the row + * still says somebody connected it rather than claiming nobody did. + * + * `displayName` IS ASKED OF THE BROKER AND NEVER FAILS THE READ. It is the vendor's name for + * the account — the one thing that tells an administrator WHICH team account is behind the app + * — and a vendor that will not say is a page missing a label, not a page that cannot load. + */ + async deploymentConnectionFor(serverId: string): Promise<{ + connectedAt: string; + connectedBy: string | null; + verified: boolean; + verifiedAt: string | null; + probe: string | null; + displayName: string | null; + } | null> { + const [server] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + const toolkit = server ? toolkitOf(server.url) : null; + if (!toolkit) return null; + + const [row] = await database + .select({ + vendorUserId: brokeredConnections.vendorUserId, + connectedBy: brokeredConnections.connectedBy, + connectedByEmail: users.email, + connectedAt: brokeredConnections.connectedAt, + verified: brokeredConnections.verified, + verifiedAt: brokeredConnections.verifiedAt, + probeAction: brokeredConnections.probeAction, + }) + .from(brokeredConnections) + .leftJoin(users, eq(users.id, brokeredConnections.connectedBy)) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + eq(brokeredConnections.holder, "deployment"), + ), + ) + .limit(1); + if (!row) return null; + + const displayName = broker + ? await broker + .accountName({ + account: { holder: "deployment", vendorUserId: row.vendorUserId }, + toolkit, + }) + .catch(() => null) + : null; + + return { + connectedAt: iso(row.connectedAt) ?? "", + connectedBy: row.connectedByEmail ?? row.connectedBy, + verified: row.verified, + verifiedAt: iso(row.verifiedAt), + probe: row.probeAction, + displayName, + }; + }, + + /** + * Every Shared app, connected or not, as `GET /connections` lists it beside a person's own. + * + * A SHARED APP WITH NOTHING CONNECTED IS STILL LISTED, as `connected: false`. Its absence would + * read as "this app needs nothing", when it is the one state an administrator has to act on — + * the page offers its Connect action off exactly this entry. + * + * `checkable` IS TRUE ONLY WHEN AN ACCOUNT IS CONNECTED AND THE APP IS A KEY APP WITH A PROBE + * ACTION, because a re-check spends a key on a safe read and there is no key to spend without + * a connected account, nor on a consent app, which has no key at all. Where it is a connected + * key app the answer is {@link probeActionFor}'s, asked of the app, for the reason + * {@link brokeredConnectionsFor} gives for keeping `checkable` apart from `probe`. + */ + async sharedConnections(): Promise< + { + serverId: string; + scope: ""; + holder: "deployment"; + connected: boolean; + connectedAt: string | null; + connectedBy: string | null; + verified: boolean; + verifiedAt: string | null; + probe: string | null; + checkable: boolean; + displayName: string | null; + }[] + > { + /* + * ONE ENTRY PER SHARED APP, under the row that answers for it. A row marked `shared` that is + * a duplicate at some app's url is not the app — the app is Shared only if its answering row + * says so — and listing it would offer a Connect for an account no call would use. + */ + const marked = await database + .select({ id: mcpServers.id, url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.accountMode, "shared")) + .orderBy(asc(mcpServers.id)); + const answering = await brokeredAppRowsAt( + marked + .filter((row) => toolkitOf(row.url) !== null) + .map((row) => row.url), + ); + const shared = marked.filter( + (row) => answering.get(row.url)?.id === row.id, + ); + + return await Promise.all( + shared.map(async (server) => { + const toolkit = toolkitOf(server.url); + const [connection, kind] = await Promise.all([ + this.deploymentConnectionFor(server.id), + toolkit ? brokeredAppKind(toolkit) : null, + ]); + return { + serverId: server.id, + scope: "" as const, + holder: "deployment" as const, + connected: connection !== null, + connectedAt: connection?.connectedAt ?? null, + connectedBy: connection?.connectedBy ?? null, + verified: connection?.verified ?? false, + verifiedAt: connection?.verifiedAt ?? null, + probe: connection?.probe ?? null, + checkable: + connection !== null && + kind === "key" && + (await this.probeActionFor(server.id)) !== null, + displayName: connection?.displayName ?? null, + }; + }), + ); + }, + + /** + * Whether this person has one brokered app connected, and since when. + * + * THE ROW IS A CACHE OF COMPOSIO'S ANSWER, not a record of a flow this deployment watched + * finish. Nothing here holds a secret for a brokered app: the vendor keeps the account, and + * what {@link brokeredConnections} 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 what the partial unique indexes enforce one row per: + * (provider, app) among deployment rows and (provider, app, user_id) among person rows — + * `holder` is each partial index's WHERE predicate, not a key column, so an ON CONFLICT + * target must name exactly those columns with the matching WHERE. 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; + account: AccountRef; + }): Promise<{ connectedAt: string } | null> { + const [row] = await database + .select({ connectedAt: brokeredConnections.connectedAt }) + .from(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .limit(1); + + if (!row) return null; + return { connectedAt: iso(row.connectedAt) ?? "" }; + }, + + /** + * The action a key verification should call against this app, and the version to call it at. + * + * THIS CHOOSES THE ONE ACTION THAT WILL BE CALLED WITH SOMEBODY'S JUST-TYPED API KEY, which is + * what makes it the most dangerous line in the verification: whatever comes back from here runs + * against a stranger's account, once, purely to find out whether their key works. So the two + * safety conditions below are BOTH non-negotiable, and neither is a stricter spelling of the + * other. + * + * READ EFFECT, because a probe must not change anything. The label is the vendor's own and not + * a guess of ours: `effectOf` answers `read` only where Composio sent `readOnlyHint`, and + * everything unlabelled was already recorded as a write, so `read` here means Stripe or Linear + * or Notion said so. `destructive` is checked beside it rather than trusted to be implied — the + * two are separate columns precisely so a vendor can say both things, and a row that somehow + * says read AND destructive is a row this deployment has no business calling unasked. + * + * ZERO REQUIRED INPUTS, because there is nothing to invent an argument from. A probe happens + * before this deployment knows anything about the account beyond the key, so a required customer + * id or query has no honest value to carry, and a made-up one turns "is this key good" into + * "does this identifier exist" — which fails for a perfectly good key. + * + * BOTH, NEVER EITHER, and the live catalogue is why this sentence is here rather than a comment + * saying the checks are belt-and-braces. The first argument-less action on Stripe's own list is + * `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`. A probe chosen on "takes no arguments" alone — + * the condition that looks sufficient, because it is the one that makes a call possible at all — + * would therefore write to somebody's account to find out whether their key works. The read + * effect is the whole of what stands between those two names. + * + * AND A RECORDED VERSION, WHICH IS PART OF "CAN THIS BE CALLED AT ALL" AND NOT A DETAIL OF THE + * CALLER. Composio refuses an execution without a specific version and rejects `latest`, so the + * transport refuses before dialling where none travels with the call — and Composio publishes + * some actions with no version at all. An action this deployment recorded without one is + * therefore an action nothing here can spend a key on, which is the same kind of fact as a + * required input: not unsafe, just not callable. The version is SELECTED AND RETURNED for the + * caller that has to send it, so the choice and the call cannot come apart. + * + * THE CONDITION LIVES IN THE FILTER RATHER THAN AFTER THE CHOICE, so there is ONE predicate for + * one question. While it sat downstream — read by the probe, unknown to everything else — there + * were two, and the weaker of them was what the connections listing derived its `probe` field + * from: an app whose chosen action had no version connected honestly as "nothing was tried", + * then reloaded as "your key was checked and rejected". That listing no longer derives that + * field from here — {@link brokeredConnectionsFor} reads what the check RECORDED, because no + * derivation from today's metadata can be right about yesterday's check. It still asks this + * function a question, but a present-tense one: whether the app has anything to check with now, + * kept as a yes or no beside the record. A split predicate would therefore no longer put a + * false sentence on a settings page; it would show up in two worse places — a probe that chose + * an action it then refused to send, and a button offered over an app nothing can be spent on. + * A filter also lets the search CONTINUE: a versionless + * candidate is passed over for the next safe read rather than short-circuiting the whole app to + * "nothing to try", so an app that can be checked is. + * + * NULL IS AN ANSWER AND NOT A FAILURE. Of fifteen key-based apps sampled, most publish some safe + * argument-less read and PostHog publishes none at all, so an app that cannot be probed is an + * ordinary app rather than a broken one. What a caller does about it — and running the probe at + * all — belongs to the verification path; this function only chooses. + */ + async probeActionFor( + serverId: string, + ): Promise<{ name: string; version: string } | null> { + // Ordered, because the fallback below is "the first candidate" and Postgres promises no order + // without one: an unordered read would make which action gets called with somebody's key a + // property of whichever plan the server happened to pick. + const actions = await database + .select({ + name: mcpTools.name, + inputSchema: mcpTools.inputSchema, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + version: mcpTools.version, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + .orderBy(asc(mcpTools.name)); + + // One pass, and it yields the pair rather than the row: an action that survives every + // condition below has a version by definition, and building the answer here is what carries + // that fact into the type instead of leaving the caller to re-check it. + const safe = actions.flatMap((action) => { + if (action.effect !== "read" || action.destructive) return []; + // Null in the column and blank in the data are the same nothing, and neither is a version + // the transport can put on a call. + const version = action.version?.trim(); + if (!version) return []; + /* + * THE WHOLE SCHEMA IS ASKED, NOT ITS TOP-LEVEL `required`. This read `schema?.required` and + * passed over the action only for a non-empty list there — so an action whose required + * arguments are published under `allOf`/`anyOf`/`$ref`, which is how a generated toolkit + * ordinarily writes them, was selected as argument-less, called with `{}`, and answered with + * the vendor's validation error. That arrives at `probeConnection` as `answered: true, + * isError: true`, which is the `refused` verdict — a valid key declared bad, and on connect + * the account created seconds earlier deleted behind it. See {@link asksForArguments} for + * which keywords bear on an empty object and why the unreadable shapes answer yes. + */ + if (asksForArguments(action.inputSchema)) return []; + return [{ name: action.name, version }]; + }); + + const identity = safe.find((action) => IDENTITY_ACTION.test(action.name)); + return identity ?? safe[0] ?? null; + }, + + /** + * Write down that this person holds this brokered app, and how well that is known. + * + * ONE WRITER SO THE VERIFIED AND UNVERIFIED PATHS CANNOT DRIFT INTO TWO ROW SHAPES. What says + * how well a connection is known is a SET of fields and not a column: `verified` is meaningless + * without the moment it was earned, and `verified_at` without the flag is a date on a claim + * nobody made. Every path that records a connection therefore comes through here rather than + * spelling that set for itself — {@link confirmBrokeredConnection} with `true` today, and the + * verify path, which is the caller `false` exists for, when a probe against a key connection + * comes back unanswered. Two call sites each writing the set by hand is how one of them comes + * to set the flag and leave the timestamp null, or to move `connected_at` on a confirm that + * healed a row nothing changed; spelled once, a reader asking what shape a connection row takes + * has one answer and every path takes it. + * + * `verified` IS THE CALLER'S CLAIM AND `verifiedAt` FOLLOWS FROM IT, never the other way round. + * True means the caller has evidence as of now — the vendor's own yes at the end of a consent + * screen, or a call that went out and came back — so the timestamp is stamped here rather than + * passed in, and it is the moment of the write because that is the moment the evidence was in + * hand. False takes the timestamp back to null rather than leaving the old one standing: a row + * that has stopped being verified must not keep a date saying when it last was, because the one + * sentence the page builds out of the pair — "last checked 13 Sep" — would then be drawn for a + * connection this deployment is no longer claiming anything about. + * + * `connected_at` IS LEFT ALONE, which is the whole reason this is an upsert with an explicit + * `set` rather than a delete and an insert. The person connected when they connected; a write + * that moved it would make every page load look like a fresh connection on their own settings + * page, and would erase the one date the row holds that nothing else in this deployment knows. + * + * AND THE STAMP IT WROTE IS WHAT IT ANSWERS, for the same reason the caller does not pass one + * in. A caller that needs the moment — {@link recheckBrokeredConnection}, which hands it to the + * browser as the date the row's sentence is drawn from — would otherwise have to read the row + * back and hope it was reading its own write. The timestamp is still this writer's; what + * changed is that it is no longer thrown away. + * + * `only` IS WHETHER THIS WRITE MAY CREATE A CONNECTION, AND ONE CALLER MUST SAY NO. + * + * CRITERION. With `only: "a row that is still there"` this method never inserts. It updates a + * row that exists and answers `wrote: false` for one that does not, and the caller decides what + * to do about that. + * + * REASON. {@link recheckBrokeredConnection} reads the row first, deliberately, "because the + * writer below is an upsert, so a re-check that probed first would INSERT a connection for + * somebody who has none". That read and this write straddle a LIVE VENDOR CALL of several + * seconds, and nothing held the row still across it. A person pressing Re-check and then + * Disconnect in another tab — or a concurrent {@link confirmBrokeredConnection} getting `false` + * from Composio and deleting the row — had the revoke complete at Composio and the row deleted, + * and then this in-flight upsert PUT IT BACK. `brokered_connections` is the whole of the + * permission a brokered call is decided on, so what the re-insert restores is access to an + * account the person has just disconnected, drawn on every screen as connected. + * + * THE GUARD IS ON THE WRITE RATHER THAN A LOCK ACROSS THE CALL, because the call is somebody + * else's and may take as long as it likes. A connection this write did not find is one nothing + * happened to: the probe's answer is about an account the person no longer has here. + * + * `probeAction` IS PASSED IN, UNLIKE THE TIMESTAMP, BECAUSE ONLY THE CALLER KNOWS IT. It is the + * action this check SPENT — the probe's name, or null where none was spent — and it is required + * rather than optional so that a new path cannot record a connection while staying silent about + * what it tried. Every existing caller knows the answer without looking anything up: a consent + * confirm spent nothing and passes null, and both probing paths pass the name the probe returned + * them, which is null there too when the app published nothing safe to call. + * + * IT IS PART OF THE SAME SET AS THE FLAG AND THE STAMP, which is the reason it is written here + * and nowhere else. `verified` alone says a check did not pass and cannot say what it was; this + * column is what separates "the app published nothing to try" from "it ran and the vendor said + * no", and a row carrying one of the three without the others is a shape no reader downstream + * has reasoned about. It used to be derived on read instead, from the app's action listing as + * that listing stood at the moment of the read — see {@link brokeredConnectionsFor}, where the + * refresh that broke the derivation is written out. + */ + async recordBrokeredConnection(input: { + toolkit: string; + account: AccountRef; + connectedBy?: string; + verified: boolean; + probeAction: string | null; + /** See the paragraph on `only` above. Absent is the ordinary upsert. */ + only?: "a row that is still there"; + }): Promise<{ verifiedAt: Date | null; wrote: boolean }> { + const verifiedAt = input.verified ? new Date() : null; + // The one set, spelled once, so the conditional write below and the upsert cannot drift into + // two row shapes. `probeAction` is overwritten rather than left standing, for `verifiedAt`'s + // reason: the pair describes ONE check, and a row keeping the action an earlier check spent + // beside the verdict of a later one would name a call this row's own state did not come from. + const set = { + verified: input.verified, + verifiedAt, + probeAction: input.probeAction, + updatedAt: new Date(), + }; + + if (input.only === "a row that is still there") { + /* + * AN UPDATE AND NEVER AN INSERT, and `returning` is how "there was a row" is ANSWERED rather + * than assumed. An update that matches nothing is not a failure — it is the row having been + * deleted while the vendor was being asked — and the caller has a sentence for that. + */ + const written = await database + .update(brokeredConnections) + .set(set) + .where(accountRow(input.toolkit, input.account)) + .returning({ vendorUserId: brokeredConnections.vendorUserId }); + return { verifiedAt, wrote: written.length > 0 }; + } + + await database + .insert(brokeredConnections) + .values({ + provider: "composio", + app: input.toolkit, + holder: input.account.holder, + userId: + input.account.holder === "person" ? input.account.userId : null, + vendorUserId: input.account.vendorUserId, + connectedBy: + input.account.holder === "deployment" + ? (input.connectedBy ?? null) + : null, + verified: input.verified, + verifiedAt, + probeAction: input.probeAction, + }) + .onConflictDoUpdate( + input.account.holder === "person" + ? { + target: [ + brokeredConnections.provider, + brokeredConnections.app, + brokeredConnections.userId, + ], + targetWhere: sql`${brokeredConnections.holder} = 'person'`, + set, + } + : { + target: [brokeredConnections.provider, brokeredConnections.app], + targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, + // WHO CONNECTED IT MEANS THE LATEST CONNECTION. A deployment account is + // shared, so a second administrator reconnecting it supersedes the first — + // which `set` otherwise never said, leaving the row crediting whoever + // connected it the first time. Added only when this call names a + // `connectedBy` at all, so a re-check or a confirm that passes none does not + // blank out the administrator an earlier connect already recorded. + set: input.connectedBy + ? { ...set, connectedBy: input.connectedBy ?? null } + : set, + }, + ); + return { verifiedAt, wrote: true }; + }, + + /** + * 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. + * + * FOUR ANSWERS AND NOT A BOOLEAN, because `verified: false` means several different things + * about somebody's key and the flag separates none of them. They are enumerated on + * {@link BrokeredProbe}, which is where a reader should go; the one worth repeating at the call + * this method makes is the fourth. + * + * A VENDOR THAT WAS NOT REACHED IS NOT A VENDOR THAT SAID NO, and this is the one place that + * can tell. `callTool` answers with a result rather than throwing, and `isError` is true for all + * three of the failures it documents — so read as a verdict here, a Composio outage, a socket + * that closed, a `@composio/core` that cannot parse what came back and a run attributed to + * nobody were every one of them "the vendor refused this key". What that cost is at the callers + * and it was the worst thing this feature did: on connect the account somebody had just made + * was deleted at the vendor and they were told what they entered did not work; on re-check a + * live, working connection was stripped of its verification and marked as holding a bad key. + * {@link ActionAnswer} is what the transport now answers beside the result, and this method + * asks for it by calling `askAction` rather than `callTool`. + * + * 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; + account: AccountRef; + /** + * THE ACCOUNT TO SPEND IT IN, where the caller has one in mind. + * + * {@link connectBrokeredWithFields} has just made an account and is asking about THAT key; a + * person and an app do not name it, because one person may hold several accounts for one app + * and the vendor picks which a call runs in. Unpinned, a bad key was verified by the person's + * other, working account — and the same defect the other way round condemned a good key, and + * deleted the account it made, on the strength of some other account of theirs being broken. + * + * ABSENT FOR A RE-CHECK, AND THAT IS A GAP RATHER THAN A CHOICE. `brokered_connections` + * records no account id — a person's row is keyed on the person and the app, a deployment + * row on the app alone — so a press of Re-check has nothing to pin to and asks the vendor + * the same app-level question it always did. It is + * the milder half: a re-check writes a verdict but takes nothing away, and its refusal tells + * the person their key is wrong rather than removing anything they hold. + */ + accountId?: string; + }): Promise { + /* + * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. + * + * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence + * that the credential works — and a row written on that acceptance is a gate every later + * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the + * call safe: the vendor must have labelled the action a read and it must take no arguments, + * and both matter because the first argument-less action on Stripe's own list creates a + * billing session. It answers the VERSION beside the name, because Composio refuses an + * execution without a specific one — so an action recorded without a version is one the + * chooser passes over rather than one this method discovers it cannot call. Null is an + * ordinary answer — see that method — and it is the FIRST of the three states above. + * + * NOTHING IS ASKED A SECOND TIME HERE, AND THAT IS THE POINT. Every condition on whether an + * action can be spent on a key lives in the chooser, so the action this method sends is the + * action the chooser said was sendable, whole. A version re-read here would be a second + * predicate for one question, and the weaker of two predicates is what once had a reloaded + * page tell somebody their untried key had been rejected — in the days when the connections + * listing answered by asking the chooser too. It no longer does: what a page says about a + * check is what the check recorded, and what it recorded is the name this method returns. + * + * AND THE APP IS RESOLVED BY ITS URL, THE WAY EVERY OTHER BROKERED LOOKUP HERE IS — through + * {@link brokeredAppRow}, which is the one read that decides it. The url is where a brokered + * row records which app it is; `mcp_servers.id` is a display name and nothing holds the two + * equal — which is exactly why {@link connectBrokeredWithFields}, {@link + * recheckBrokeredConnection} and {@link disconnectBrokered} all key on the url, and why {@link + * brokeredConnectionsFor} resolves on it rather than spelling `composio-${toolkit}` by hand. + * Composing it here made this method the one place that re-derived the id from a convention, + * and it put the two halves of the `checkable`/`probe` split back into disagreement on any + * row where they differ: the listing answered `checkable` off the app's real row while this + * answered off an id addressing nothing, so the button was offered and the press could never + * find anything to spend — the Re-check deadlock the split exists to prevent, reached from + * the other end. Worse where the composed id DID hit something: a row called `composio-gmail` + * at `composio://slack` would have this choose a stranger's probe off another app's listing + * and spend their key on it. A row this deployment has since removed drops out here as null, + * which is the same honest answer the listing gives for it: there is no app left to check. + * + * AND THE SHARED READ RATHER THAN A URL QUERY OF ITS OWN, because `mcp_servers.url` has no + * unique index and an unordered `limit(1)` over it is not an answer. A second row at the app's + * url — the ordinary state of any database where a fixture sits beside the `composio-` row an + * administrator really added — had this resolve to whichever row the scan met first while the + * listing named the app by the lower id, which is the SAME deadlock one step along: Re-check + * offered off the app's own row, and the press landing on a row that publishes no action and + * reporting there was nothing to try. One function decides which row answers for an app, so + * nothing here can name one row while something else names another. + */ + const app = await brokeredAppRow(input.toolkit); + + const candidate = app ? await this.probeActionFor(app.id) : null; + if (candidate === null) { + return { outcome: "nothing", 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 — 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 { result, answered } = await composioAskAction( + { + url: `composio://${input.toolkit}`, + actorId: input.account.vendorUserId, + // Spread rather than passed as `undefined`, so "any account of theirs" reaches the wire + // as a body with no such key. See {@link ComposioActions.execute}. + ...(input.accountId === undefined + ? {} + : { accountId: input.accountId }), + }, + candidate.name, + { [VERSION_ARG]: candidate.version }, + ); + + /* + * THE READING, AND IT IS THE WHOLE OF WHAT THIS METHOD DECIDES. `answered` is the transport's + * own statement that Composio ran the action in the account and said how it went; `isError` + * then says what it said. Anything else is an outage or an answer this deployment could not + * read, and neither is evidence about a key — so the action's NAME is withheld from those, + * because the name beside `verified: false` is the accusation and only a call that can be + * shown to have run may make it. + */ + if (!answered) { + return { + outcome: "unreachable", + probe: null, + attempted: candidate.name, + sentence: result.text, + }; + } + + /* + * AND `isError` SAYS THAT THE CALL WENT BADLY, NOT THAT THE KEY IS BAD. The envelope this is + * read out of is `{ data, error, successful }` and nothing more: a 429, a scope this one + * action needs and the key legitimately lacks, and a 404 for a resource the read action names + * all arrive here identically to a credential the vendor rejected. So the outcome is named + * for what was observed — the app complained — and the two readings that treated it as a + * verdict about the credential are gone from both callers. See {@link BrokeredProbe}. + */ + return result.isError + ? { + outcome: "complained", + probe: candidate.name, + sentence: result.text, + } + : { outcome: "answered", probe: candidate.name }; + }, + + /** + * 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. + * + * AND THE YES WRITES A VERDICT ONLY WHERE CONSENT IS THE CHECK, which is the one thing a reader + * of this method has to carry away. `verified: true` beside a null `probeAction` is not a + * neutral heal: it is a NAMED one of the four states {@link composioConnections.probeAction} + * enumerates — the consent state — and this method runs from an effect on mount, so writing it + * unconditionally meant every page load restated it over whatever a real check had recorded. + * For a key connection that erased the record, worst of all over "a named probe beside + * `verified: false`", the live account with a bad key behind it that is the one state somebody + * must act on; and it re-dated `verified_at` to the page load, so the row claimed a check on a + * day nothing was checked. The yes itself does not bear on a key: {@link + * ComposioBroker.isConnected} says an account is attached, Composio takes a key when it is + * typed and never tests it again, so for a key app that answer is what the row's existence + * already said. The branch is on the scheme recorded on the app's row, classified through {@link + * brokeredAppKind} as {@link connectBrokeredWithFields} and {@link recheckBrokeredConnection} + * classify theirs, and a key row already here is left untouched — the evidence about a key is a + * call, and those two are the only writers of this row's verdict. A key app the vendor holds an + * account for with no row here still gets one, written UNCHECKED, because the row is the gate + * every later brokered call passes through and the only thing Disconnect works off. + * + * AND THE FLAG IS WRITTEN FOR A CONSENT APP RATHER THAN FOR ANYTHING THAT IS NOT A KEY APP. A + * scheme this deployment cannot read — a null, or a literal nothing here writes — is neither + * kind, and it takes the key app's treatment: nothing recorded is overwritten, and a person the + * vendor holds an account for still gets the row that is their permission. See {@link + * SchemeKind} for why that third answer has to travel rather than be flattened into the second. + * + * `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; + account: AccountRef; + by: 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({ + account: input.account, + 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(brokeredConnections) + .where(accountRow(input.toolkit, input.account)); + 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. + * + * AND THE VERDICT IS READ BESIDE THE ROW'S EXISTENCE, which is the consent arm's business. + * `verified` here is the difference between "this deployment has already recorded the + * vendor's yes" and "it has not", and the write below is conditioned on it so that a mount + * cannot re-date a consent that was recorded days ago. The column is read directly rather + * than through {@link brokeredConnection}, which answers with the connection date alone. + */ + const [held] = await database + .select({ verified: brokeredConnections.verified }) + .from(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .limit(1); + const existing = held !== undefined; + + /* + * THE SCHEME ON THE APP'S ROW, WHICH IS WHAT DECIDES WHETHER THE YES ABOVE IS A CHECK. + * + * Keyed on the url and on the one row that answers for it — see {@link brokeredAppRow} — which + * is the read {@link connectBrokeredWithFields} and {@link recheckBrokeredConnection} both + * make: `mcp_servers.id` is a display name and nothing holds the two equal, so a row called + * `gmail` at `composio://slack` would decide a Slack confirm on Gmail's scheme, and a second + * row at the app's own url would have this confirm branch on a scheme the re-check beside it + * disagrees with. Asked through {@link brokeredAppKind} rather than compared as a string, so + * the schemes this branches on cannot drift from the schemes that have a key behind them. + * + * AND THE QUESTION IS "IS THIS A CONSENT APP", NOT "IS THIS NOT A KEY APP", which are the same + * question only if the column can always be read. + * + * CRITERION. `verified: true` is written here for an app this deployment KNOWS connects by + * consent, and for no other. + * + * REASON. This branched on {@link isFieldScheme} alone, so every other answer — a consent + * scheme, a literal from a deployment that knew other names, a NULL — fell into the consent + * arm by elimination. A null is not a consent app: it is a column this deployment cannot read, + * which {@link mcpServers.authScheme} calls a row that is not brokered and which the row that + * ANSWERS for an app is perfectly free to carry — no unique index stands behind that url, so + * the row an enable wrote its scheme onto is not always the row found here. Confirm runs from + * an effect on mount, so the elimination wrote `verified: true` with a fresh `verified_at` and + * a null probe over that row on every page load: a verdict about evidence nobody has, dated to + * the day somebody opened a page, over whatever a real check had recorded. The other two + * readers of this column already fail closed on the null — {@link recheckBrokeredConnection} + * refuses the press, {@link disconnectBrokered} claims no revocation — so the one caller that + * could not survive being wrong was the only one failing open. + * + * SO AN UNREADABLE SCHEME IS TREATED AS A KEY APP IS, and that is the cautious half in both + * directions: nothing already recorded is overwritten, and the row that is the gate is still + * written where the vendor holds an account nothing here has a row for. + */ + const kind = await brokeredAppKind(input.toolkit); + + /** + * WHAT THIS CONFIRM ANSWERS FOR EACH KIND OF APP, WRITTEN DOWN BECAUSE THE CHAIN CANNOT BE. + * + * Type-only and erased; see {@link Decides}. The chain below tests ONE member and then tests + * something else — `kind === "none"`, `kind === "consent"`, then `!existing` — so there is no + * position where the compiler has this vocabulary narrowed away and nothing here would fail + * for a fifth member. + * This is the caller that WRITES, and it is the one that could not survive being wrong: it + * runs from an effect on mount, so whatever it decides for a member nobody named is decided + * again on every page load. + * + * EVERY CELL IS NOW ASSERTED RATHER THAN DECLARED. `consent` used to be written as what the + * code did rather than as what it should do — the re-stamp of `verified_at` on an + * already-consented row was a live finding — and `none` did not exist as a member at all, so + * a no-auth app was classified `consent` and got that same write on every mount. See the + * table in `tests/composio-connection-kinds.test.ts`, which drives each of these four against + * the running code. + */ + type _ConfirmDecides = Decides< + SchemeKind, + { + key: "leaves an existing row exactly as it is; records a new one as unchecked"; + consent: "records the vendor's yes as a verification the first time, and leaves an already-consented row alone"; + none: "writes nothing and files nothing — there is no account here for a row to be about"; + unreadable: "treated as a key app is — nothing already recorded is overwritten"; + } + >; + + /* + * AND THE CONSENT WRITE HAPPENS ONCE, WHICH IS THE OTHER HALF OF "A MOUNT DOES NOT RE-DECIDE". + * + * CRITERION. `verified: true` is written for a consent app only where this deployment has not + * already recorded it. A row already carrying that verdict is left exactly as it is, its + * `verified_at` included. + * + * REASON. The arm was unconditional, and this method runs from an effect on mount — so every + * page load re-stamped `verified_at` to the moment of the load. The flag never changed, so + * nothing looked wrong; what was destroyed was the DATE, which for a consent connection is + * the day somebody finished at the vendor's own screen and is a fact nothing else in this + * deployment records. The row's own sentence, "last checked 1 Sep", became "last checked + * today" on a day nothing was checked, and the real date could not be recovered from anywhere. + * {@link recheckBrokeredConnection} refuses to probe a consent app in order to protect exactly + * that date; a confirm that re-stamped it on every mount destroyed from the inside what that + * refusal protects from the outside. Two earlier fixes made this arm conditional for the key + * case and then for the unreadable case and left the consent case — the one the arm is + * actually FOR — writing on every load. + * + * AND A ROW NOT YET CARRYING THE VERDICT IS STILL HEALED, once. A consent app whose row was + * written by some other path — a key-era connect, an enable that changed the app's scheme + * afterwards — reads `false` with a null date, which is the pair "nobody has checked" and is + * not true of a consented account. The vendor's yes above is the check for this kind of app, + * so it is recorded, with the stamp of the moment it was first recorded here, and the next + * mount finds the verdict already present and writes nothing. + */ + /* + * AN APP THERE IS NOTHING TO CONNECT TO GETS NO ROW, WHICH IS THE OTHER TABLE'S RULE APPLIED + * HERE FOR THE FIRST TIME. + * + * CRITERION. Nothing is written and nothing is filed for a `none` app, whatever the vendor + * answered about it. + * + * REASON. `NO_AUTH` used to be a member of the CONSENT list, so the classifier called it + * consent and the arm below wrote `verified: true` with a fresh `verified_at` for it — on + * every page load, because this runs from an effect on mount. That is precisely the row the + * connect route refuses to create for these apps and the row the per-person gate is written to + * do without: `composio_connections` is the whole of the permission for a brokered call, and + * every row in it means one thing, that this person granted this deployment access to their + * account at this app. There is no account — Composio refuses even to hold an authorization + * config for a no-auth toolkit — and there is no grant, so a year on, offboarding, the trail + * and the Disconnect button could not tell those rows from ones somebody really made. + * + * THE NEGATIVE HEAL ABOVE STILL RAN, and deliberately: a row left behind by the version that + * wrote them is removed by the first mount that finds the vendor holding no account, which is + * every mount for an app like this. + */ + if (kind === "none") return { connected: true }; + + if (kind === "consent" && !held?.verified) { + // 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, + account: input.account, + connectedBy: input.by, + verified: true, + // NOTHING WAS SPENT TO EARN THAT FLAG, and that is what the null records rather than an + // absence of information. A consent connection is verified by the vendor's own yes at its + // own screen; no action of the app's is ever called against it, here or later, so there is + // no name to write and there never will be. The derived field could not say so — it + // answered with whatever the app happened to publish — and a consent row was listed as + // having been checked with an action nothing had called. + probeAction: null, + }); + } else if (kind !== "consent" && !existing) { + /* + * A KEY APP THE VENDOR HOLDS AN ACCOUNT FOR AND NOTHING HERE HAS A ROW FOR: recorded as + * UNCHECKED, which is the honest one of the four states for it. The account was made + * somewhere this deployment did not watch — in Composio's own dashboard, or by a connect + * whose row was lost — so no key of theirs has ever been tried from here, and null beside + * `false` is exactly "nothing was spent". The row still has to exist: it is the gate every + * later brokered call passes through, and the only thing Disconnect works off. + * + * AND AN APP WHOSE SCHEME CANNOT BE READ IS WRITTEN THE SAME WAY, for the same sentence + * one word weaker: nothing here has ever checked this account, and nothing here knows what + * checking it would even mean. `false` beside a null claims neither a check nor a refusal, + * which is the only pair that is true of it — and the row is still the permission, so a + * person whose account Composio holds does not lose their access to a column nobody wrote. + */ + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + connectedBy: input.by, + verified: false, + probeAction: null, + }); + } + /* + * AND AN EXISTING KEY ROW IS LEFT EXACTLY AS IT IS, which is the whole of what this branch + * does and the reason there is a branch at all. + * + * THIS RUNS FROM AN EFFECT ON MOUNT. Both brokered account screens confirm on every page + * load, so whatever is written here is written again every time somebody opens the page — + * and `verified: true` beside a null `probeAction` is not a neutral heal. It is one of the + * four states {@link composioConnections.probeAction} enumerates, and specifically the + * CONSENT one: "the vendor's own yes is the evidence and no call was ever made against the + * account". Written over a key row it erased the record of the last check and replaced it + * with that sentence, including over the worst state this feature has — a named probe beside + * `verified: false`, "it ran, the vendor refused the key, and the account is still standing" + * — which is the one state an operator has to act on. It also moved `verified_at` to the + * moment of the page load, so the row's own sentence, "last checked 13 Sep", named a day on + * which nothing was checked. + * + * BECAUSE THE YES IS NOT EVIDENCE ABOUT A KEY. {@link ComposioBroker.isConnected} answers + * that an account is attached, which for a key app is what the row's existence already said: + * Composio takes a key when it is typed and never tests it again — the whole reason the probe + * exists — so nothing in that answer bears on whether the key still works. Consent is the one + * scheme where the vendor's yes IS the check, and it earns the flag above for that reason + * alone. The evidence about a key is a call, and the two places that make one — {@link + * connectBrokeredWithFields} and {@link recheckBrokeredConnection} — are the only writers of + * this row's verdict. This one records what it learned by not writing. + * + * THE NEGATIVE HEAL IS UNTOUCHED, for all three kinds. A vendor answering NO still deletes the + * row above, which is what a confirm on a key app is still worth running for. + * + * AND AN EXISTING ROW UNDER AN UNREADABLE SCHEME IS LEFT ALONE FOR A STRICTLY WIDER REASON. + * For a key app the yes is not evidence; for an app whose scheme nothing here can read, it is + * not known WHAT the yes is evidence of. Both answers are the same act — write nothing — and + * it is the only act available that cannot claim more than was learned. + */ + + 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.by, + server: input.toolkit, + scope: "", + reconnected: false, + holder: input.account.holder, + }, + }); + } + + return { connected: true }; + }, + + /** + * Connect this person with the secret they typed, and write down everything except the secret. + * + * THE VALUES TRAVEL IN ONE DIRECTION AND THE WHOLE METHOD IS BUILT AROUND THAT. They arrive on + * the request, they are handed to {@link ComposioBroker.connectWithFields}, and they reach + * Composio. Nothing else here is given them: not the row, not the audit payload, not a log + * line, not a thrown error — the broker's own doc comment is where that promise is kept on the + * far side, and it is the one call in this tree that rethrows with no `cause` precisely because + * the vendor's error object holds the key. Every other participant in this method is a + * long-lived, widely-readable record, so a credential landing in one is not a leak somebody can + * clean up afterwards; it is a leak with a retention schedule. + * + * THE SCHEME IS THE ONE RECORDED ON THE APP'S ROW, never a fresh read of the catalogue and + * never a value a caller passed. It is what this deployment's authorization config was created + * AS, and a connection is attached to that config: a second derivation is a second answer — a + * key sent as `BASIC` against a config made for `API_KEY` — which is the reasoning {@link + * ComposioBroker.connectionFields} gives for taking the scheme rather than resolving it, one + * step earlier in the same flow. + * + * AND AN APP WHOSE SCHEME IS NOT A FIELD SCHEME IS REFUSED BEFORE THE KEY TRAVELS. A consent + * app, a `NO_AUTH` app and an app this deployment could not resolve at all have no form and + * nothing to attach typed values to, so sending them on would spend somebody's credential on a + * config that cannot hold it — and would do it having already taken the secret out of the + * request. {@link isFieldScheme} is asked rather than the string compared, for the reason it + * exists: one list, read by the guard and by the type, so the schemes this admits cannot come + * apart from the schemes the broker's signature takes. + * + * `connected` IS THE LITERAL `true` BECAUSE THERE IS NO OTHER WAY OUT OF HERE. Unlike {@link + * confirmBrokeredConnection}, which asks a question the vendor may answer no to, this performs + * an act: it either made the connection or it threw. A `boolean` would invite a caller to + * branch on a `false` this method cannot produce. + * + * `probe` IS RETURNED BESIDE `verified` BECAUSE THE FLAG ALONE NOW MEANS THREE DIFFERENT THINGS, + * AND THE BROWSER READS THIS FIELD TO CHOOSE ITS SENTENCE. While the row was written `false` + * unconditionally the flag had one meaning — nobody has checked — and a screen could say so from + * the flag alone. With a probe that can fail there are three states, and two of them share the + * flag: + * + * null probe, `verified: false` — nothing was tried, so nothing is known about the key. + * Either this app publishes nothing safe to call, or the + * check could not be made — Composio unreachable, or an + * answer this deployment could not read. "It was accepted + * without being checked" is true of both, which is why + * they share the state; `checkable` on the listing is what + * tells the person whether pressing Re-check can ever help, + * and the trail carries the outage under `unreachable`. + * 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; + account: AccountRef; + by: string; + values: Record; + }): Promise<{ connected: true; verified: boolean; probe: string | null }> { + // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to + // connect anybody at, and the refusal must happen before the values are touched at all. + if (!broker) throw new BrokerUnconfiguredError(); + + // Keyed on the url and on the one row that answers for it — see `brokeredAppScheme`. 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. + /** + * WHAT THIS CONNECT ANSWERS FOR EACH KIND OF APP, AND FOR EACH OUTCOME OF ITS OWN CHECK. + * + * Type-only and erased; see {@link Decides}. Two vocabularies meet in this one method — the + * scheme decides whether it will run at all, and {@link BrokeredProbe} decides what the run + * leaves behind — and each is read here by an equality test that a new member would sail + * past. `isFieldScheme` is asked rather than {@link schemeKind}, so the two answers that are + * not `key` reach one refusal by elimination; the roster is what says so out loud. + */ + type _ConnectWithFieldsDecides = Decides< + SchemeKind, + { + key: "connects, then checks the key it was just handed"; + consent: "refuses — not an app this deployment connects with values somebody types"; + none: "the same refusal — an app that needs no credential has nowhere to put one"; + unreadable: "the same refusal, reached by elimination rather than by decision"; + } + >; + type _ConnectWithFieldsRecords = Decides< + BrokeredProbe["outcome"], + { + nothing: "the account stands, recorded unchecked with a null probe"; + answered: "the account stands, recorded verified under the probe's name"; + complained: "the account stands, recorded unchecked under the probe's name, and the press reports what the app said"; + unreachable: "the account stands, recorded unchecked, with the attempt on the trail"; + } + >; + const authScheme = await brokeredAppScheme(input.toolkit); + if (!isFieldScheme(authScheme)) { + throw new BrokerRefusalError( + `${input.toolkit} is not an app this deployment connects with values somebody types, so nothing was sent. Open the app on the Plugins page and connect it the way it asks for; if it is not listed there at all, an administrator has to enable it first.`, + ); + } + + const { accountId } = await broker.connectWithFields({ + account: input.account, + toolkit: input.toolkit, + authScheme, + values: input.values, + }); + + /* + * THE CHECK, WHICH IS THE SAME ONE A RE-CHECK MAKES AND IS SPELLED ONCE FOR THAT REASON. + * + * {@link probeBrokeredConnection} chooses the action out of what the app published — with the + * version the listing recorded for it, which is part of what makes it choosable — calls it + * with no arguments and reads what came back. + * What belongs to THIS path and to no other is what happens next: an account this call has + * just made, which a key the vendor rejects must not be allowed to leave standing. A re-check + * runs the identical probe against an account that already existed and leaves it alone, and + * those two undo behaviours are exactly why the shared part stops where it does. + * + * `probe: null` IS THE FIRST OF THE THREE STATES THIS METHOD REPORTS — the app published + * nothing safe to call, or nothing at a version this deployment recorded, so the key was + * never tried. It is an ordinary answer and not a failure; see that method. + */ + const probed = await this.probeBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + /* + * THE ACCOUNT THIS CALL JUST MADE, which is the only account this check is about. A person + * and an app do not name one: Composio takes an account per key, somebody may hold several + * for one app, and the vendor picks which a call runs in. So an unpinned probe verified a + * key that does not work against the person's OTHER account — writing `verified` on the + * strength of a call the new key never touched — and, the same defect pointing the other + * way, condemned a perfectly good key and deleted the account it had just made because + * some older account of theirs was broken. The undo below has always been keyed on this id; + * this is what makes the check about the same account as the withdrawal. + */ + accountId, + }); + + // 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, + account: input.account, + }); + + /* + * THE CONNECT IS ON THE TRAIL BEFORE THE OUTCOME IS READ, AND THAT IS WHAT MOVING IT BUYS. + * + * The account exists by this line — `connectWithFields` above either made it or threw — and + * from here the method has four ways out, one of which is the throw in the branch below. + * Filed after that branch, this row was written on three of the four: a `complained` check + * left a live account at Composio, a `composio_connections` row, and an + * `mcp.connection_verified` row saying a check did not come back clean, with NOTHING saying + * the account had ever been connected. `complained` is the ordinary outcome of a rate limit + * or of one refused scope rather than an exotic one, so the trail lost the connect on the + * path most likely to be walked — and the `mcp.account_disconnected` row filed when that + * person later presses Disconnect had no counterpart to pair against. + * + * ABOVE `recordBrokeredConnection` RATHER THAN BELOW IT, because what this row asserts is + * that the ACCOUNT was made, which the broker call above has already established, and not + * that this deployment finished writing its own row about it. The two arms below disagree + * about what that row says and agree about this. + */ + 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.by, + server: input.toolkit, + /* + * EMPTY, AND PRESENT, which is the whole of what this field does on a brokered row. + * + * All three writers of `mcp.account_connected` now agree on the key. {@link + * recordConnection} carries what a vendor granted a `user-oauth` grant, and both brokered + * writers carry `""` — a brokered connection has no scopes at all, because Composio holds + * the grant and never tells this deployment what it covers. + * + * WRITING NOTHING IS NOT THE SAME AS WRITING THAT. This one wrote no key, so the same app + * connected by two people came back as `''` from {@link confirmBrokeredConnection} and as + * NULL from here, in a table whose entire purpose is being queried — and with nothing in + * either row saying which writer made it, a reader cannot tell an absent scope from an + * empty one, or either from a row written before the field existed. + */ + scope: "", + reconnected: existing !== null, + holder: input.account.holder, + /* + * 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(), + }, + }); + + if (probed.outcome === "complained") { + /* + * THE ACCOUNT THIS CALL MADE IS LEFT STANDING, AND THAT REVERSES WHAT THIS BRANCH USED TO DO. + * + * It read the outcome as "the vendor refused the key" and acted on it: `revokeAccount` on + * the id just created, then a refusal telling the person what they entered did not work. + * The premise was that nothing should be left behind on a key that does not work — which is + * a good rule for the fact it names and was being applied to a fact nobody had established. + * A complaint is Composio saying the CALL failed, out of an envelope with no status and no + * error code in it (see {@link BrokeredProbe}), so a 429 on the identity read, a scope this + * one action wants, or a 404 for a resource it names all arrived here as "your key is wrong" + * and destroyed a connection that had just been made correctly. + * + * THE TWO MISTAKES DO NOT COST THE SAME, WHICH IS WHAT SETTLES IT. Keeping the account of a + * genuinely bad key leaves a live row at Composio that can do nothing, that every screen + * here draws as unverified with the app's own words beside it, and that Disconnect ends in + * one press. Deleting the account of a good key costs the person the key itself: it is gone + * from Composio, it was never stored here, and getting back is fetching it from the vendor + * and typing it again. One is a press, the other is an errand — so the recoverable mistake + * is the one this branch is willing to make. + * + * SO THE STATE THIS WRITES IS THE ONE THE `probe` FIELD WAS ADDED FOR, reached now by the + * ordinary path rather than only by a failed undo: the row exists, it is unverified, it + * names the action the check was spent on, and the sentence carries what the app said. What + * it no longer carries is a verdict about the credential, here or on any screen that draws + * it. + */ + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + connectedBy: input.by, + verified: false, + // THE ACTION THAT WAS TRIED, which is the half of this state the flag cannot hold. It is + // the whole of what separates this row on a later page load from a key nobody ever tried, + // and it is the same name the audit row below carries under `action`, for a reader of the + // trail rather than of a screen. + probeAction: probed.probe, + }); + + /* + * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The refusal + * is told to one person in one moment; what outlives it is an unverified row and a live + * account at the vendor, and the person who most needs to know both exist is an operator + * reading this trail a week later. Filed BEFORE the throw for the only reason that matters + * here: every way out of this branch is that throw, so a row written after it is a row + * never written. + * + * `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 and the app answered with a failure". + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + holder: input.account.holder, + action: probed.probe, + verified: false, + }, + }); + + /* + * AND THE SENTENCE REPORTS RATHER THAN ACCUSES. It said "what you entered did not work", + * which is the one thing this branch cannot know. What it can say is all true: a check was + * spent, here is what the app answered, the account is still there, and here are the two + * presses that end it either way. + */ + throw new PluginRefusedError( + `${input.toolkit} was connected, and the check this deployment then ran against it did not come back clean: ${probed.sentence} That may be the key, and it may be the app — what came back does not say which — so the account is left standing and recorded as unchecked. Re-check it on the Plugins page once, and if it still will not answer, disconnect it and connect it again with a fresh key.`, + null, + ); + } + + /* + * ONE OUTCOME EARNS THE FLAG AND THE OTHER TWO REACHING HERE DO NOT, which is the whole of + * what is decided below. 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}. An app that published nothing safe to call is the honest + * unchecked state {@link composioConnections.verified} documents. And a vendor that could + * not be reached is the SAME unchecked state, arrived at a different way — which is the one + * thing a reader of this branch has to take away, so it is written out rather than left to + * the union. + * + * AN OUTAGE IS AN ABSENCE OF EVIDENCE AND THE ROW HAS A STATE FOR THAT. It is not a fourth + * thing to store: `probe_action` records the action a check SPENT, and a call that reached + * nobody spent none, so null beside `verified: false` says exactly what is true — this key + * has not been checked. What it must never become is the other `verified: false`, the one + * with a name beside it, because that pair is the accusation "it ran and the vendor refused + * your key" and the settings page draws it as such. {@link BrokeredProbe} withholds the name + * from this outcome so the writer below cannot record it even by accident. + * + * AND THE ACCOUNT STAYS, which is the half the person would not get back. The undo above is + * for a key the vendor REFUSED; a key nobody could ask about is very probably fine, and + * deleting somebody's account because Composio was down destroys the thing they just made to + * tell them something that was never established. The Re-check button is what settles it + * afterwards — the app publishes an action, so the settings page offers one — and this is why + * the connect answers rather than refusing: nothing went wrong with what they typed. + * + * 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 probe = probed.probe; + const verified = probed.outcome === "answered"; + + await this.recordBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + connectedBy: input.by, + verified, + // What was spent, which is the name on a probe that ran and the null that IS the first of + // the three states: this app published nothing safe to try the key on. `verified` is + // derived from this same value a few lines above, so the row cannot claim a check with an + // action beside a flag that says nothing checked it, or the other way about. + probeAction: probe, + }); + + /* + * THE CHECK ITSELF, ON THE TRAIL, WHETHER OR NOT ONE HAPPENED. + * + * Filed on every connection rather than only where a probe ran, because "this app published + * nothing safe to call, so the key was never tried" is the fact a reader most needs and the + * one nothing else records. `action` is the null the response field is: it separates an + * unchecked connection from a checked one, which `verified: false` alone cannot. + * + * THE FAILED UNDO ABOVE FILES THE SAME ROW BEFORE IT THROWS, so the worst state this feature + * has — a live account, an unverified row, and a withdrawal the vendor refused — is no longer + * named only in a sentence one person read once. The clean undo files nothing, and the reason + * is written out at that branch: this trail records state that persists, and a row there + * would cost the failed-undo row the one meaning that makes it worth reading. + * + * UNDER THE APP SLUG, which is the id `mcp.account_connected` above, {@link + * confirmBrokeredConnection}, {@link disconnectBrokered} and {@link retireConnectionsFor} all + * file under — so the two rows this method can write about one person's access to one app + * come back in ONE query, however that access began and however it ended. This row was filed + * under the SERVER row's id instead, on the reasoning that it is about an ACTION of a server + * rather than about access; that is true and it cost the reader the only question anybody + * asks this trail, which was answered half by one id and half by the other. Nothing goes with + * the change: the server id is `composio-` and the slug, and the action is in the payload. + * + * AND THE OUTAGE IS NAMED HERE BECAUSE THE ROW CANNOT NAME IT. Two different things leave + * this method with a null `action` — an app that publishes nothing safe to call, and a check + * that could not be made — and on `composio_connections` they are one state, correctly: both + * mean the key is unchecked, and neither says anything about the key. A reader of the TRAIL + * is asking a different question, "was a check attempted and what became of it", and + * `unreachable` is the whole of the difference, in Composio's own words. Present only on the + * outage, so its absence is as informative as its value. + * + * AND CAPPED WHERE EVERY OTHER QUOTED FOREIGN STRING IN THIS FILE IS CAPPED, which this one + * alone was not. `passableSentence` decides whether a candidate is worth repeating and says + * nothing whatever about length; `askAction` then caps at `MAX_RESULT_CHARS`, which is 20_000 + * and is a bound written for a model's context window rather than for a row. So the sentence + * arriving here can be fifty times what `refreshTools`' two `lastError` writes, the failed + * undo's own reason a hundred lines up, and `callTool`'s two `failure` fields each allow + * themselves — and `@composio/client` builds an `APIError` message out of an entire response + * body, so a multi-kilobyte one is the ordinary arrival and not a contrived one. + * + * WHY THIS ROW AND NOT ANOTHER. `audit_events` is append-only by trigger, it is exported, and + * it is kept for the deployment's whole retention window: a `lastError` written too long is + * overwritten by the next refresh, and this is not. Whatever lands here cannot be cleaned up + * afterwards, which makes the one uncapped write in the file the one that could least afford + * to be. + */ + await recordAuditEvent(auditStore, { + eventType: "mcp.connection_verified", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + holder: input.account.holder, + action: probe, + verified, + ...(probed.outcome === "unreachable" + ? { unreachable: probed.sentence.slice(0, 400) } + : {}), + }, + }); + + return { connected: true, verified, probe }; + }, + + /** + * Try a key this deployment already holds, because somebody pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT. Composio never re-checks a key: it accepts one when it + * is typed and says nothing about it again, so a row that was verified in March goes on saying + * so after the key behind it was rotated, revoked or let expire. Nothing but this can correct + * that — which is exactly the argument somebody will use for calling it from an effect when the + * settings page mounts, and it is the wrong conclusion. The call this makes is spent against the + * PERSON'S OWN rate limit at the vendor, on their account, so verifying on every render would + * burn somebody's quota at Linear to redraw one word on a page they were only passing through. + * {@link confirmBrokeredConnection} is the one that runs on mount, and it asks Composio a + * question about its own records; this one goes out to the app. + * + * A RE-CHECK IS NOT A CONNECT, AND THE DIFFERENCE IS THE WHOLE METHOD. It runs against an + * account that already exists: it must not create one, it must not withdraw one when the probe + * fails — the person's account stays, it is their KEY that is wrong — and it must change + * nothing here but the verification and its timestamp. {@link connectBrokeredWithFields} does + * undo its account on a bad key, and it is right to: the account is a thing it had just made, + * seconds earlier, for a key that turned out not to work. Here the account predates the press by + * days, the person asked to have it CHECKED, and taking it away to tell them their key is wrong + * would destroy the thing they are trying to repair. The shared probe stops short of both + * behaviours for that reason. + * + * A PROBE THAT RAN AND FAILED RAISES, AND DOES NOT COME BACK AS `verified: false`. Those two + * answers are not different spellings of one outcome. `false` is also what an app that publishes + * nothing safe to call produces, and a row handed the flag alone cannot tell "the vendor + * rejected your key" from "there was nothing here to try" — so it would draw the unchecked + * sentence, and drop the Re-check button, for the one person who most needs it: somebody who has + * just fixed their key and pressed it. The refusal carries Composio's own sentence, which is the + * whole of what they can act on. The ONLY legitimate `verified: false` from here is the one that + * arrives with `probe: null` saying there was nothing to check with. + * + * NOTHING TO PROBE WRITES NOTHING AT ALL, and answers with the row as it stands. A check that + * could try nothing has learned nothing, and writing `false` on that would take the date off a + * connection verified at a consent screen — a fact nothing else in this deployment records, + * erased by a button that claims to check one. So the answer is what the row says after the + * press, and `probe` is what says whether the press was able to try anything. + * + * THE ROW IS READ BEFORE THE VENDOR IS CALLED, and its absence is a refusal. The writer below is + * an upsert, so a re-check that probed first and recorded the answer would INSERT a connection + * for somebody who has none — the row that is the whole of the gate every later brokered call + * passes through, created by a button that only asks a question. The probe itself would be spent + * on an account the vendor does not hold, and would come back "no connected account found": this + * deployment's own state, shown to somebody as though their key had been rejected. + * + * NO BROKER IS REFUSED BEFORE ANY OF IT, though nothing here calls the broker. The broker and the + * transport are built from the same key, so a deployment without one has neither — and the probe + * would come back as the transport's "Composio is not configured for this deployment", which + * this method would otherwise report as the vendor rejecting a perfectly good key, and would + * write the row unverified on the strength of it. + * + * AND A CONNECTION WITH NO KEY BEHIND IT IS REFUSED HERE RATHER THAN IN THE BROWSER. There is + * nothing to re-check on a consent connection: the person authenticated at the vendor's own + * screen, this deployment holds no credential of theirs, and the row's `verified_at` is the + * date that screen earned — a fact nothing else here records. A probe spent on it would be a + * call against their account that this method then reads as evidence about a key that does not + * exist, and the likely failure would write `verified: false` with a null timestamp: a button + * that claims to CHECK a connection, destroying the only record that one was ever checked. That + * is precisely what the nothing-to-probe branch above is written to protect, and an app that + * happens to publish a safe action walks straight past it. The screen does not offer the button + * for a consent app, but a route takes POSTs and not only button presses, so the refusal + * belongs where {@link connectBrokeredWithFields} puts its own: on the SCHEME RECORDED ON THE + * APP'S ROW, asked through {@link isFieldScheme} so the schemes this admits cannot drift from + * the schemes that have a key to admit. + */ + async recheckBrokeredConnection(input: { + toolkit: string; + account: AccountRef; + by: string; + }): Promise<{ + verified: boolean; + verifiedAt: string | null; + probe: string | null; + }> { + if (!broker) throw new BrokerUnconfiguredError(); + + // Keyed on the url and on the one row that answers for it — see `brokeredAppKind`. It is the + // read `connectBrokeredWithFields`, `confirmBrokeredConnection` and `disconnectBrokered` all + // make, for the same stake: a row called `gmail` at `composio://slack` would decide a Slack + // re-check on Gmail's scheme. Anything but a key refuses, a scheme nothing here can read + // included — there is no key recorded to re-check, and the sentence below is the same one + // either way. + /** + * WHAT THIS RE-CHECK ANSWERS FOR EACH KIND OF APP, AND FOR EACH OUTCOME OF THE CHECK. + * + * Type-only and erased; see {@link Decides}. `!== "key"` is a boolean read of a three-member + * vocabulary: it happens to fail closed for both of the other two today, which is why nothing + * has gone wrong here yet and also why a fourth member would inherit that answer without + * anybody choosing it. The probe roster below is the vocabulary this method reads FOUR ways, + * and it is the one that has already been added to once. + */ + type _RecheckDecides = Decides< + SchemeKind, + { + key: "spends a call against the key this deployment holds"; + consent: "refuses — there is no key here to re-check"; + none: "the same refusal — there is no account, let alone a key, to check"; + unreadable: "the same refusal, which is the closed direction"; + } + >; + type _RecheckRecords = Decides< + BrokeredProbe["outcome"], + { + nothing: "writes nothing and files nothing; answers the held row beside a null probe"; + answered: "records verified under the probe's name, and files the check"; + complained: "records unchecked under the probe's name, files the check, then reports what the app said"; + unreachable: "writes nothing and files nothing; refuses with Composio's own sentence"; + } + >; + if ((await brokeredAppKind(input.toolkit)) !== "key") { + throw new PluginRefusedError( + `${input.toolkit} is not an app this deployment holds a key for, so there is nothing here to re-check. It was connected at ${input.toolkit}'s own sign-in screen, and if it has stopped working, disconnecting it on the Plugins page and connecting it again is what fixes it.`, + null, + ); + } + + const [held] = await database + .select({ + verified: brokeredConnections.verified, + verifiedAt: brokeredConnections.verifiedAt, + }) + .from(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .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 probed = await this.probeBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + }); + + /* + * THE VENDOR WAS NOT REACHED, SO THE RECORD OF THE LAST CHECK IS LEFT EXACTLY WHERE IT IS. + * + * This is the branch the whole four-state reading exists for on this path. A failed probe + * used to mean one thing here — "the vendor rejected the key it is holding" — so an outage, + * a socket that closed, or a `@composio/core` that could not parse an answer cleared + * `verified`, dropped `verified_at` (the only record anywhere that this connection was ever + * checked, and the date the page prints) and wrote the named probe beside the `false`, which + * is the accusation. Every person who pressed the button while Composio was down was told + * their key had been refused, over a row that had been verified minutes earlier. + * + * NOTHING IS WRITTEN, WHICH IS STRONGER THAN WRITING SOMETHING HONEST. A press that learned + * nothing may not move a record: there is no state for "we could not ask" on the row, and + * there should not be — the row says what is known about the key, and an outage changes + * nothing about that. It is the same restraint as the nothing-to-probe branch below, and + * nothing is filed on the trail for the same reason it gives. + * + * AND IT RAISES RATHER THAN ANSWERING, which is where it parts from that branch. Somebody + * pressed a button and is owed the truth about what happened to their press: the check did + * not happen, and here is Composio's own sentence about why. Answering with the held row + * instead would report the state as though the press had confirmed it. + */ + if (probed.outcome === "unreachable") { + throw new PluginRefusedError( + `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when Composio is answering is the whole of the retry.`, + null, + ); + } + + /* + * 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 (probed.outcome === "nothing") { + return { + verified: held.verified, + verifiedAt: iso(held.verifiedAt), + probe: null, + }; + } + + // What the press spent, which from here on is a name: both outcomes left are a call that ran + // and a vendor that answered about it. + const probe = probed.probe; + + /* + * 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 = probed.outcome === "answered"; + const { verifiedAt, wrote } = await this.recordBrokeredConnection({ + toolkit: input.toolkit, + account: input.account, + verified, + // The action this press spent. Never null on this path: the nothing-to-probe branch above + // returns before reaching the writer, precisely so that a check which could try nothing + // writes nothing at all. + probeAction: probe, + /* + * AND IT MAY NOT CREATE THE CONNECTION IT IS RECORDING A CHECK OF. The read at the top of + * this method refuses a person with no row, deliberately, because this writer is an upsert — + * but that read and this write straddle the vendor call above, which is live and takes + * seconds. A Disconnect landing in that window revoked the grant at Composio and deleted the + * row, and this upsert then put the row back: `composio_connections` is the whole of the + * permission a brokered call is decided on, so the re-insert restored access to an account + * the person had just ended, and drew the app as connected again. + */ + only: "a row that is still there", + }); + + /* + * THE ROW WENT WHILE COMPOSIO WAS BEING ASKED, SO NOTHING IS RECORDED AND NOTHING IS FILED. + * + * The two ways here are a Disconnect in another tab and a concurrent confirm that Composio + * answered `false` for and which deleted the row. Both are somebody or something ENDING this + * connection, and a verification row filed afterwards would be the trail recording a check of + * an account that no longer exists — beside `mcp.account_disconnected` for the same app, in + * whichever order the two happened to land. + * + * AND IT SAYS SO RATHER THAN ANSWERING QUIETLY, for the reason the outage branch above does: + * somebody pressed a button and is owed the truth about their press. The answer is not the + * probe's verdict, because the thing it was a verdict about is gone. + */ + if (!wrote) { + throw new PluginRefusedError( + `Your connection to ${input.toolkit} was disconnected while this check was still running, so nothing was recorded about it: what the check found is about an account you no longer have here. Connect ${input.toolkit} again on the Plugins page if that was not what you meant.`, + null, + ); + } + + /* + * 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.by, + holder: input.account.holder, + action: probe, + verified, + }, + }); + + if (probed.outcome === "complained") { + /* + * THE APP'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 this + * button to press again once whatever the sentence names has passed. + * + * AND IT REPORTS WHAT CAME BACK RATHER THAN RULING ON THE KEY. This said "would not answer + * with the key it is holding", which reads as the vendor having rejected the credential — + * and the envelope the outcome is decided from carries no status and no error code, so a + * rate limit and a bad key are the same answer here. See {@link BrokeredProbe}. Moving the + * row to unchecked is still right and is still the thing this button exists to do: whatever + * the failure was, the check did not come back clean, so a standing `verified: true` is no + * longer supported and must not keep standing. + */ + throw new PluginRefusedError( + `The check this deployment ran against ${input.toolkit} did not come back clean: ${probed.sentence} That may be the key and it may be the app — what came back does not say which — so your connection here is recorded as unchecked until a check does come back clean. Nothing was disconnected: press Re-check again in a few minutes, and fix the key at ${input.toolkit} if it keeps answering the same way.`, + null, + ); + } + + return { verified: true, verifiedAt: iso(verifiedAt), probe }; + }, + + /** + * End this person's brokered account at the vendor, and then forget where it was. + * + * REVOKE BEFORE DELETE, AND THAT ORDER IS THE WHOLE METHOD. The row is the only thing in this + * deployment that says which app this person connected: the app is read off + * `composio_connections`, and a revoke needs it. Delete first and a revoke that then fails + * leaves a live grant on somebody's mailbox that no operation here can reach, because the one + * value it would have to be revoked under is gone. The other order costs nothing by + * comparison — a revoke that throws leaves the row standing, the person presses disconnect + * again, and the second attempt has everything the first one had. + * + * WHICH ALSO MEANS THE FAILURE IS LOUD. Nothing is caught here: a broker that will not answer + * ends this call, and no row and no trail entry claims an account was disconnected when the + * account is still live. + * + * `vendorRevocationRequested` IS WHAT WAS ASKED FOR, NOT THAT A CALL WAS MADE — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was found and its + * withdrawal asked for, false where there was none to withdraw, and the value of the field is + * exactly that a reader can tell an account this deployment acted on from one that outlives it + * somewhere else. + * + * IT SAYS "REQUESTED" BECAUSE THE VENDOR'S ANSWER SUPPORTS NOTHING STRONGER, and the field was + * renamed from `vendorRevoked` when that turned out to be false in the plainest way: the + * adapter behind it was soft-deleting the account and asking for no upstream revocation at all, + * so every row saying a grant had been withdrawn described one still live at Google. The ask is + * now made; what a broker can promise synchronously is that the account is gone at Composio and + * that the provider has been asked, because the withdrawal itself runs as a background job with + * no supported way to poll it. + * + * EXCEPT WHERE THE APP IS ONE SOMEBODY TYPED A KEY INTO, AND THERE IT IS FALSE BY CONSTRUCTION + * RATHER THAN BY WHAT THE VENDOR FOUND. `revoke_on_delete` asks the PROVIDER to end a grant, + * which is a real request for a consent account — Google or Slack acts on it — and a + * meaningless one for an API key. There is no grant behind a key to withdraw: the value is + * still valid at the app and still works for anyone holding it, so the account ends at Composio + * and nothing was asked of anybody else. Saying otherwise would be the one row in this trail + * nobody could rely on, which is the failure the paragraph above describes arriving a second + * time by a different road — a withdrawal recorded for something that was never granted. So the + * scheme recorded on the app's row decides this field for a field connection, and the broker's + * own answer decides it for every other. + * + * THE SCHEME IS THE ONE ON THE APP'S ROW, for {@link connectBrokeredWithFields}' reason: it is + * what this deployment's authorization config was created AS and what the account was attached + * to, and a fresh read of the catalogue is a second answer — a key connection described as + * consent because the vendor has since started publishing managed OAuth for the app. The + * person's half of this fact is already written on the disconnect row they are shown: their key + * still works at the app, and rotating it there is what ends it. This is the trail's half of + * the same sentence. + * + * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer + * and never the account itself (see {@link brokeredConnection}), so its absence is not + * evidence that the grant is gone: the confirm above deletes it on any `false` from the + * vendor, and a person whose row was cleared that way can still be holding a live account at + * Composio with nothing left here pointing at it. Asking anyway is the only operation in this + * deployment that can end such a grant, and it costs nothing where there is genuinely nothing + * to withdraw — the broker answers `false` and says so. Skipping the revoke for want of a + * local row would make the safe half of disconnect unreachable for exactly the person who + * needs it, on the strength of a cache we already know drifts. + * + * BUT THE TRAIL RECORDS ONLY A DISCONNECT THAT HAPPENED. The event is filed where something + * actually ended — a row deleted here, or a grant withdrawn at the vendor — and not otherwise. + * A call that found no row and withdrew no grant disconnected nothing, and an + * `mcp.account_disconnected` row for it tells whoever reads the trail that somebody's account + * ended at a moment when nobody's did. It is the criterion + * {@link confirmBrokeredConnection} files its own event under, one act the other way round: + * the trail records acts, and a call that changed nothing performed none. + * + * WHICH IS NOT THE SAME QUESTION AS `vendorRevocationRequested`. A row here with no grant at + * the vendor is a disconnect — the gate this deployment decides every brokered call on was + * open, and this call closed it — so the event is filed, saying + * `vendorRevocationRequested: false`. A grant at the vendor + * with no row here is a disconnect too, and the weightier of the two, because somebody's live + * account was ended; the event is filed for that as well. Only where both are absent is there + * no act to record, and the two cases stay legible in the trail because the field still says + * which of them happened. + * + * SO THE FILING IS DECIDED ON THE BROKER'S OWN ANSWER AND NOT ON THE FIELD, because for a field + * connection the two part company on purpose. A key account the vendor found and ended with no + * row here is an act — somebody's live connection stopped existing — and gating the event on a + * value that is false by construction would leave exactly that act unrecorded. The field + * answers what was asked of the provider; `ended` answers whether anything was there. + */ + async disconnectBrokered(input: { + toolkit: string; + /** Whose account ends — a person's own, or the one the deployment holds for a Shared app. */ + account: AccountRef; + by: string; + /** + * Why the account ended, which is a closed set and not free text. A brokered account ends + * in exactly four ways — the person disconnecting their own; the person being removed from + * the People screen, which is the word {@link retireConnectionsFor} already files its own + * rows under; an administrator disconnecting the deployment's account for a Shared app; and + * the app being switched between Personal and Shared, which retires the accounts the old + * mode held. A reader asking the trail which of them happened can be answered only if it is + * the same word every time, so the type is the set rather than whatever sentence a caller + * happened to spell. + */ + reason: "self" | "person_removed" | "admin" | "mode_switched"; + }): Promise<{ vendorRevocationRequested: boolean }> { + if (!broker) throw new BrokerUnconfiguredError(); + + /* + * Keyed on the url and on the one row that answers for it — see {@link brokeredAppKind}. + * It is the read {@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, AND NEITHER IS ONE WHOSE SCHEME CANNOT BE READ. + * 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 both of those fall through + * to: the field below is a claim that this deployment asked the vendor to withdraw something, + * and an app it cannot say holds a key is one whose withdrawal it has to report as asked. + */ + /** + * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR EACH KIND OF APP. + * + * Type-only and erased; see {@link Decides}. `=== "key"` collapses three answers into two, + * and the collapse is deliberate here rather than accidental: the field below is a claim that + * this deployment asked the VENDOR to withdraw something, and an app it cannot say holds a key + * is one whose withdrawal it has to report as asked. That is a decision about `unreadable`, + * and this roster is where it is written down as one. + */ + type _DisconnectDecides = Decides< + SchemeKind, + { + key: "claims no vendor revocation — nothing at the vendor holds this key"; + consent: "reports the vendor's own withdrawal as asked for"; + none: "reports whatever the vendor says it ended, which for an app with no account is nothing"; + unreadable: "reports it as asked for too, which is the claim that cannot be too weak"; + } + >; + const fieldScheme = (await brokeredAppKind(input.toolkit)) === "key"; + + // 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({ + account: input.account, + toolkit: input.toolkit, + }); + + const vendorRevocationRequested = fieldScheme ? false : ended; + + // `returning` because whether a row was here is half of what decides if anybody was + // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .returning({ app: brokeredConnections.app }); + + if (deleted || ended) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + // Whose account this was, which is not always who ended it: an administrator + // offboarding somebody and a person disconnecting themselves write the same shape of + // row, and only these two fields tell them apart. The deployment's own account has no + // person behind it, so it is named as the deployment rather than as whoever pressed. + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + vendorRevocationRequested, + }, + }); + } + + return { vendorRevocationRequested }; + }, + + /** + * Retire every connector credential belonging to one person. + * + * WHAT THIS IS FOR. "We removed their access" has to be true of the thing that matters, which is + * the refresh token sitting at the vendor. Removing somebody from the People screen used to end + * their sessions and add them to the deny list, and leave their Google grant entirely intact in + * this deployment's vault. They could not exercise it — the actor comes from a session they no + * longer get — but the deployment still held a usable secret for a person who had been removed, + * which is not what an administrator was told they did, and is the first thing a customer asks + * about a per-person connector. + * + * LOOKED UP IN THE VAULT, NOT THROUGH THE JOIN TABLE. `mcp_user_credentials.user_id` cascades on + * a user row being deleted, so by the time somebody is gone the join row can be gone too and the + * credential is orphaned: unrevoked, referenced by nothing, reachable from no screen and by no + * code path. `credentials.key_id` holds the user id for an `mcp_user_token`, so the vault can + * still be asked directly — which makes this work for the person who was removed and for the one + * whose row was deleted underneath it. + * + * The join rows go too, so the account pages stop claiming a connection this deployment can no + * longer use. + * + * AND THE BROKERED CONNECTIONS, which are neither a credential nor a join row. Composio holds + * the account, so there is no secret in the vault to find and the person's `brokered_connections` + * row is itself the permission — the only thing deciding whether a call may go out as this person. + * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. + * + * NOT VENDOR-SIDE REVOCATION FOR THE VAULT HALF. That needs the OAuth client and the vendor's + * revoke endpoint, and it belongs with disconnect. Those rows are the half that stops us + * holding the secret; the grant at Google outlives it until somebody revokes it there. Said + * plainly rather than implied, because the difference matters to whoever has to answer for it. + * + * THE BROKERED HALF DOES END IT AT THE VENDOR, because there is no secret of ours to stop + * holding: clearing the row alone would shut the gate this deployment owns and leave the + * mailbox attached at Composio, which is "we removed their access" being untrue of the only + * thing that matters, for the person it matters most about. So every app this person connected + * is revoked through the broker, exactly as {@link disconnectBrokered} revokes for one and + * {@link removeServer} for a whole app. + * + * REVOKE BEFORE DELETE, ALWAYS. The row is the only thing that names which apps this person + * had, and it outlives the `users` row precisely so offboarding can still find them — which + * was the table's whole justification and until now was theoretical. A delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on a departed + * person's mailbox that no operation in this deployment can reach. The other order costs a + * repeat of an act nobody minds repeating. Nothing is caught around the revokes either, so a + * broker that will not answer ends this method with the rows still standing rather than + * letting it report an ending that did not happen. + */ + async retireConnectionsFor( + userId: string, + by: string, + ): Promise<{ retired: number }> { + if (!userId) return { retired: 0 }; + + const owned = await database + .select({ + id: credentialRows.id, + provider: credentialRows.provider, + revokedAt: credentialRows.revokedAt, + }) + .from(credentialRows) + .where( + and( + eq(credentialRows.kind, "mcp_user_token"), + eq(credentialRows.keyId, userId), + ), + ); + + let retired = 0; + for (const credential of owned) { + // Already revoked is not a failure. Retiring twice is something an administrator can + // legitimately do, and the second time should be quiet rather than an error. + if (credential.revokedAt) continue; + await credentials.revoke(credential.id); + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: credential.provider, + payload: { + actor: by, + server: credential.provider, + owner: userId, + /* + * Why, because the two reasons are not the same event to a reader. Somebody disconnecting + * their own account is a person changing their mind; an administrator removing somebody + * is an offboarding, and an auditor asking "what happened to their access" wants to see + * which one this was. + */ + reason: "person_removed", + vendorRevocationRequested: false, + }, + }); + } + + await database + .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 the person's + * `brokered_connections` row is the entire gate. Reading only the vault therefore retired nothing for somebody whose only + * connector was brokered, reported that as a retirement, and left the `(toolkit, user_id)` gate + * passing for a person who no longer exists — their access outliving them, which is the first + * thing anybody asks about a per-person connector. The table's own docblock justifies its shape + * by this path, so the shape was carrying a promise nothing kept. + * + * FOUND HERE AND NOWHERE ELSE, which is what the missing foreign key buys. The row survives the + * `users` row precisely so this can still name what the person had after they are gone — the + * same argument the vault lookup above makes, from the side that has no vault row. It is also + * why the guard at the top of this method is load-bearing rather than defensive: `not null` + * admits the empty string, so a row at `(toolkit, "")` is legal, and retiring "nobody" must not + * be what deletes it. + * + * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays + * quiet on its own: the rows are gone, so the second call finds none. + * + * READ BEFORE ANYTHING IS DELETED, because the revokes below need the apps and the rows are + * where the apps are — the reason the docblock gives for revoking first. Sorted, so two + * retirements of the same person revoke in the same order and write their rows in the same + * order. + */ + const brokered = await database + .select({ toolkit: brokeredConnections.app }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + ), + ) + .orderBy(asc(brokeredConnections.app)); + + /* + * What the broker was actually asked for each app, kept so the trail below records the answer + * rather than the call. False where there is no broker at all: a deployment whose key has + * since been unset can still offboard somebody, and it could not have been calling Composio + * either way — but nothing was asked there and the row must not claim otherwise. + * + * ONE APP'S REFUSAL IS ONE APP'S REFUSAL, and until now it was everybody's. A throw out of + * `revoke` left this loop before the delete and before the trail, so three accounts already + * withdrawn at Composio kept their rows and got no row on the trail. That was survivable while + * repeating the act did nothing — and #574 made repeating it the documented recovery, so the + * second pass asks again for those three, Composio answers `false` because the accounts are + * gone, and each writes `vendorRevocationRequested: false` about a withdrawal this deployment + * asked for and got. That field exists to tell an account we acted on from one that outlives + * us somewhere else; those three rows say the wrong one. + * + * So the answer is kept per app and the refusal is held rather than thrown. Every app is still + * asked — a later one is not punished for an earlier one — and the first refusal is rethrown + * below, so the act still fails loudly and the administrator still gets a 500. + */ + const withdrawn: { toolkit: string; requested: boolean }[] = []; + const refusals: unknown[] = []; + for (const connection of brokered) { + try { + withdrawn.push({ + toolkit: connection.toolkit, + requested: broker + ? await broker.revoke({ + /* + * THE PERSON'S OWN ACCOUNT, NAMED AS ONE. Offboarding somebody ends what they + * held and never the deployment's shared account, whoever connected it — so the + * holder is fixed here rather than read off anything, and the vendor id is the + * person's own, which is what a personal account is filed under at Composio. + */ + account: { holder: "person", userId, vendorUserId: userId }, + toolkit: connection.toolkit, + }) + : false, + }); + } catch (error) { + /* + * Held, and the row deliberately left standing. + * + * "An offboarding the vendor refuses leaves the connection standing" is the existing + * criterion and it is unchanged: the row is the only thing naming which app this person + * connected, repeating the act is the recovery, and repeating it is only possible while + * the row is there. What changes is that the rule now applies to the app it is about + * rather than to every app in the same act. + */ + refusals.push(error); + } + } + + /* + * Only the apps that answered, which is the other half of the same correction. + * + * Deleting by user id would take the rows of apps that were refused or never reached, and + * those are exactly the rows the recovery needs. Deleting none — what a throw used to do — + * leaves a row and an open `(toolkit, user_id)` gate for an account that is already gone at + * Composio, so the table claims a connection this person does not have. + */ + if (withdrawn.length > 0) { + await database.delete(brokeredConnections).where( + and( + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, userId), + inArray( + brokeredConnections.app, + withdrawn.map((entry) => entry.toolkit), + ), + ), + ); + } + + for (const connection of withdrawn) { + retired += 1; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + // The app, which for a brokered connection is all the row records. The `mcp_servers` row + // it belongs to may have been removed already, and the connection outlives that too. + targetId: connection.toolkit, + payload: { + actor: by, + server: connection.toolkit, + owner: userId, + reason: "person_removed", + /* + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through, and the one place this half + * differs from the vault loop above. There the grant at Google outlives our copy of + * the secret and nothing was asked of anybody, so the field can only say false; here + * there was no secret of ours and the account itself was deleted at Composio with its + * withdrawal asked for, or there was nothing to ask about, or there was no broker to + * ask. The value of the field is exactly that a reader can tell those apart, so a + * constant here would be worse than none. + */ + vendorRevocationRequested: connection.requested, + }, + }); + } + + /* + * Loud, after every app has been asked and every answer recorded. + * + * The first, because the route turns this into a 500 and one sentence is what reaches the + * administrator; the rest are the same act failing more than once, and the trail above already + * says which apps did not end. Thrown last rather than first so a refusal on one app cannot + * cost the record of another — which is the whole of this change. + */ + if (refusals.length > 0) throw refusals[0]; + + return { retired }; + }, + }; +} diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts new file mode 100644 index 000000000..4d001db4f --- /dev/null +++ b/server/src/plugins/plugin-store/context.ts @@ -0,0 +1,1615 @@ +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { recordAuditEvent } from "../../audit"; +import { + type CredentialExecutor, + CredentialUnusableError, + decryptCredentialForUse, + decryptSecret, + encryptSecret, +} from "../../credentials"; +import { + brokeredConnections, + credentials as credentialRows, + mcpServers, + mcpTools, + mcpUserCredentials, + pluginGrants, + skillTools, +} from "../../db/schema"; +import { accessFor, type ServerAccess } from "../access"; +import { type Decides, type SchemeKind, schemeKind } from "../broker"; +import { type CatalogueEntry, catalogueEntry } from "../catalogue"; +import { toolkitOf } from "../composio"; +import { registerDynamicClient } from "../oauth"; +import { + type AccountAnswer, + type AccountMode, + type AccountRef, + accountFor, +} from "../shared-accounts"; +import { + CatalogueEntryUnknownError, + CLIENT_REREGISTRATION_BACKOFF_MS, + CustomServerRefusedError, + exchangeRefreshTokenOverHttp, + INVALID_CLIENT, + isUsableClient, + type OAuthClient, + PluginInvariantError, + type PluginKind, + PluginRefusedError, + type PluginStoreOptions, + type StoredClient, + TokenRefusedError, + type Transaction, +} from "./model"; + +/** + * What every group of plugin store methods shares: the options it was made with, and the helpers + * and state (the token-exchange chains, the OAuth client locks) that used to live in one closure. + * Made once per store by `createPluginStore`, so that state is still one per store. + */ +export function createStoreContext(options: PluginStoreOptions) { + const { database, auditStore, credentials, encryptionKey } = options; + /* + * Held rather than resolved, because the transport is a property of the entry and is not known + * until a call names one. An injected vendor still wins over both, which is what keeps a test able + * to assert what a call was about to go out with. + */ + const injectedVendor = options.callVendor; + 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 + * token it was shown, so two concurrent calls that both present the old one would have the + * second refused through no fault of anybody's. The chain serialises them; the map entry is + * removed when the chain drains so the map cannot grow past the set of active connections. + */ + const exchangeChains = new Map>(); + function serialized(key: string, work: () => Promise): Promise { + const previous = exchangeChains.get(key) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(work); + exchangeChains.set(key, next); + /* + * The refusal belongs to the caller, who is holding `next` and will see it. This branch exists + * only to forget the key, so it swallows before it cleans up: `next.finally(…)` on its own + * derives a SECOND rejected promise that nobody is holding, and a refused call — a withdrawn + * credential, say — then surfaces as an unhandled rejection somewhere unrelated. + */ + void next + .catch(() => {}) + .finally(() => { + if (exchangeChains.get(key) === next) exchangeChains.delete(key); + }); + return next; + } + + async function grantsFor(kind: PluginKind, refs: string[]) { + if (refs.length === 0) return new Map(); + const rows = await database + .select() + .from(pluginGrants) + .where(and(eq(pluginGrants.kind, kind), inArray(pluginGrants.ref, refs))); + const byRef = new Map(); + for (const row of rows) { + byRef.set(row.ref, [...(byRef.get(row.ref) ?? []), row.agentId]); + } + return byRef; + } + + /** + * Every MCP grant belonging to these servers, whether or not the tool is still advertised. + * + * {@link grantsFor} asks about refs somebody already has, which is the wrong question when the + * point is to find the ones nothing else knows about: called with the advertised refs it can only + * ever return a subset of them, so a grant on a withdrawn tool is invisible by construction. + * + * Matched on the server half in the query rather than by reading every grant and splitting here. + * `split_part` rather than a `LIKE` prefix, because a server id is text a person can choose for a + * custom server and `%` in one would silently widen the match. + */ + async function mcpGrantsForServers(serverIds: string[]) { + if (serverIds.length === 0) return new Map(); + const rows = await database + .select({ ref: pluginGrants.ref, agentId: pluginGrants.agentId }) + .from(pluginGrants) + .where( + and( + eq(pluginGrants.kind, "mcp"), + inArray(sql`split_part(${pluginGrants.ref}, '/', 1)`, serverIds), + ), + ); + const byRef = new Map(); + for (const row of rows) { + byRef.set(row.ref, [...(byRef.get(row.ref) ?? []), row.agentId]); + } + return byRef; + } + + /** The refs each of these skills declares, keyed by skill id. Skills with none are absent. */ + async function toolsDeclaredBy(skillIds: string[]) { + if (skillIds.length === 0) return new Map(); + const rows = await database + .select() + .from(skillTools) + .where(inArray(skillTools.skillId, skillIds)) + .orderBy(asc(skillTools.ref)); + const bySkill = new Map(); + for (const row of rows) { + bySkill.set(row.skillId, [...(bySkill.get(row.skillId) ?? []), row.ref]); + } + return bySkill; + } + + /** + * Which of these refs name a tool this deployment has actually seen. + * + * Asked when a skill is saved, so a typo is refused where it was written rather than becoming a + * skill that quietly selects nothing. Not asked at run time: a refresh deletes and rewrites a + * server's tool rows, so a ref can be legitimately absent for a moment, and a run must read that as + * "load nothing" rather than as a failure. + */ + async function knownToolRefs(refs: string[]) { + if (refs.length === 0) return new Set(); + // Narrowed in the query to the servers actually named, rather than reading the whole catalogue + // and filtering here. A deployment aiming at a thousand tools should not scan all of them to + // check three. + const servers = [...new Set(refs.map((ref) => ref.split("/")[0] ?? ""))]; + const rows = await database + .select({ serverId: mcpTools.serverId, name: mcpTools.name }) + .from(mcpTools) + .where(inArray(mcpTools.serverId, servers)); + const known = new Set(rows.map((row) => `${row.serverId}/${row.name}`)); + return new Set(refs.filter((ref) => known.has(ref))); + } + + /** + * Who did it goes in the payload, never in `actorUserId`. + * + * That column is a foreign key to `users.id`, and everything here holds an email. Writing one + * there does not fail loudly: the insert violates the constraint and the entire audit row is lost. + */ + + /** + * A credential out of the vault, decrypted for one call and never held. + * + * A revoked credential is turned into a refusal rather than left as the vault's thrown error. The + * two reach a person very differently: an error becomes "that tool could not be called", which is + * what a vendor being down looks like, while a withdrawn grant is nobody's fault and has an + * obvious next step. `reconnect` says which of the two to name. + * + * WHICH OF THE TWO IS DECIDED BY CLASS. {@link CredentialUnusableError} is the one thing the vault + * raises that means the credential is gone, and everything else that can come out of this call is + * a fault: a query this database refused, a connection it would not open, an envelope that would + * not decrypt. Those are rethrown untouched, which puts a query failure on the + * {@link isDeploymentFault} shelf where the four audiences already agree about it. + * + * WHAT THIS USED TO BE, and why the difference is not cosmetic. It asked whether `error.message` + * CONTAINED "revoked" or "not found". drizzle's message for a failed query begins `Failed query: + * select "encrypted_value", "revoked_at" from "credentials" …` — the column this very read selects + * — so every database fault on the vault read matched the first substring and was converted into + * `onRevoked`: a `PluginRefusedError`, which is the one class this codebase relays VERBATIM. A + * Postgres that was down told the model the credential had been withdrawn, told a browser the same + * through the routes that pass a refusal out as a 400, and wrote it into `mcp_servers.last_error` + * for an operator to act on — sending somebody to re-add a credential that was never the problem + * while the real fault was reported nowhere. The same argument {@link TokenRefusedError} carries a + * `code` for: a decision that reads prose is one rewording away from being wrong in silence, and + * this one did not even need the rewording. + */ + async function secretFor( + credentialId: string, + onRevoked: string, + ): Promise { + try { + return await decryptCredentialForUse( + encryptionKey, + credentials, + credentialId, + ); + } catch (error) { + if (error instanceof CredentialUnusableError) { + throw new PluginRefusedError(onRevoked, null); + } + throw error; + } + } + + /** + * The account a call, a connect or a check acts for, from the row and the person — never from a + * request. A Shared app answers with the deployment's stored identity whoever asks. + */ + async function accountRefFor( + serverId: string, + actorId: string, + ): Promise< + | { ref: AccountRef; title: string; mode: AccountMode; appId: string } + | { refusal: string } + > { + const [dialled] = await database + .select({ + title: mcpServers.title, + provenance: mcpServers.provenance, + url: mcpServers.url, + authScheme: mcpServers.authScheme, + accountMode: mcpServers.accountMode, + sharedVendorUserId: mcpServers.sharedVendorUserId, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!dialled) + return { + refusal: `${serverId} is not an app this deployment has added.`, + }; + /* + * THE NAME IS THE DIALLED ROW'S; THE ACCOUNT IS THE APP'S. + * + * CRITERION. For a brokered row whose url names an app, the scheme, the mode and the deployment + * identity are read off the row {@link brokeredAppRow} answers for that app, and `appId` names + * that row. Only the title — the words a person is shown — comes from the row they dialled. + * + * REASON. The url carries no unique index, so a second row can sit at an app's url carrying + * account columns of its own. Read off the dialled row, those columns let a duplicate decide + * the account for the app: `personal` at a Shared app's url took a non-administrator through + * Connect to a personal account on the team's app and took calls round the audience gate, and + * `shared` at a Personal app's url sent a person's call out as the deployment. The connection + * this decides is then looked up by toolkit, which is the app — so the decision has to be the + * app's too, or its two halves are about different rows. + * + * A BROKERED ROW WITH NO ANSWERING ROW IS NOT AN APP ANY MORE. The dialled row sits at the url, + * so only a removal racing this read gets here, and it is refused as the missing row is above. + */ + const toolkit = + dialled.provenance === "composio" ? toolkitOf(dialled.url) : null; + const app = toolkit ? await brokeredAppRow(toolkit) : null; + if (toolkit && !app) + return { + refusal: `${serverId} is not an app this deployment has added.`, + }; + const row = app + ? { + title: dialled.title, + provenance: dialled.provenance, + authScheme: app.authScheme, + accountMode: app.accountMode, + sharedVendorUserId: app.sharedVendorUserId, + } + : dialled; + const appId = app?.id ?? serverId; + const answer = accountFor(row, actorId); + type _RefDecides = Decides< + AccountAnswer["kind"], + { + none: "refused here — there is no account to connect, check or end"; + person: "the actor, under their own id"; + deployment: "the deployment, under the identity minted when the app became Shared"; + ambiguous: "refused with accountFor's own sentence"; + } + >; + if (answer.kind === "none") + return { refusal: `${row.title} needs no account.` }; + if (answer.kind === "ambiguous") return { refusal: answer.message }; + if (answer.kind === "person") { + if (!actorId) + return { + refusal: `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, + }; + return { + ref: { holder: "person", userId: actorId, vendorUserId: actorId }, + title: row.title, + mode: "personal", + appId, + }; + } + if (!row.sharedVendorUserId) { + throw new PluginInvariantError( + `${appId} is Shared and holds no deployment identity; switch it to Personal and back.`, + ); + } + return { + ref: { holder: "deployment", vendorUserId: row.sharedVendorUserId }, + title: row.title, + mode: "shared", + appId, + }; + } + + /** The WHERE clause for one account's row, by holder. */ + const accountRow = (toolkit: string, account: AccountRef) => + account.holder === "person" + ? and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + eq(brokeredConnections.holder, "person"), + eq(brokeredConnections.userId, account.userId), + ) + : and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + eq(brokeredConnections.holder, "deployment"), + ); + + /** + * The token one call goes out with, and whose it is — decided from `access.credential`, so that + * this function and the audit row cannot disagree about whose account a call ran in. + * + * For a `deployment-token` server this is what it always was: the one credential an administrator + * gave the server, used for everybody. A `none` server reaches the same branch and finds nothing + * to decrypt, which is the right answer for an endpoint that takes no credential at all. + * + * For a `brokered` server there is no token here AT ALL. The deployment's one key belongs to the + * transport and never travels through this function, so nothing here can leak it into a connection + * object, an error or an audit row. What this function contributes instead is the two refusals + * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an + * asker who has not connected the app — so a person is told their own next step rather than shown + * the broker's error about an account it cannot find. A third refusal sits between those two, for + * a brokered row whose url names no Composio app; nothing in the product creates such a row, so no + * person's situation reaches it. + * + * For a `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 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; + title: string; + credentialId: string | null; + /* + * NO `authScheme` HERE, DELIBERATELY. The scheme that decides whether a brokered call needs a + * connection row is the APP'S, read through `brokeredAppScheme` below — and this row's own + * column is the near-miss that reading replaced. A field kept here for convenience would be + * the wrong answer sitting in the parameter list of the function that must not use it. + */ + }, + entry: CatalogueEntry | null, + actorId: string, + access: ServerAccess, + /** + * The mode a call's audience gate was decided about, where one was; see the check beside + * {@link accountRefFor} below. Absent for a path with no such gate, the tool refresh. + */ + gatedMode?: AccountMode | null, + ): Promise<{ token?: string; vendorUserId?: string }> { + /* + * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. + * + * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below + * is: a person gets a sentence naming the step they can take, and no call is spent at the + * vendor finding out. The transport refuses an unattributed run again as a last line, so + * deleting either that guard or this one has to turn a test red. The unconnected case has no + * such twin: the transport has no notion of a connection at all, so the last line there is + * Composio itself — which is what refusing locally earns its place for, since it turns the + * broker's error about an account it cannot find into a sentence naming the person's own next + * step. + * + * The throw between the two is a third refusal, but not one anybody can act on: it fires only + * for a brokered row whose url names no Composio app, which nothing in the product can create. + * It is what keeps this gate keyed on the app the url names, and its own comment says why + * neither fallback is available. + * + * There is no token. The key belongs to the transport and never travels through this function, so + * nothing here can leak it into a connection object, an error or an audit row. + */ + if (access.credential === "brokered") { + if (!actorId) { + throw new PluginRefusedError( + `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, + null, + ); + } + + /* + * Narrowing, and a refusal that is genuinely reachable. + * + * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is NULL + * whenever that url does not name a Composio app — `accessFor` still answers `brokered` for + * any row whose provenance column says composio, so `{ credential: "brokered", toolkit: null }` + * is a state a hand-edited or restored row really produces. The test beside `accessFor` + * asserts it, and `plugin-store.integration.test.ts` gates this branch end to end. + * + * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both + * alternatives fail open. Falling back to `row.id` checks the connection against a spelling + * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose + * whole purpose is to keep one person's account out of another's. The compiler forces SOME + * narrowing here — drizzle's `eq` will not take `string | null` — but only the test named + * above stops that narrowing from being the fallback. + */ + if (!access.toolkit) { + throw new PluginInvariantError( + `${row.id} resolves to a brokered credential with no Composio app in its url.`, + ); + } + + /* + * AN APP THAT NEEDS NO AUTHENTICATION HAS NO ROW TO FIND, AND CANNOT EVER HAVE ONE. + * + * `brokered_connections` is the whole of the permission for a brokered call, and every row in + * it means one thing: an account at this app was granted to this deployment — a person's own, + * or for a Shared app the team account an administrator connected. 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. + * + * AND IT IS THE APP'S RECORDED SCHEME, NOT THIS ROW'S, WHICH IS THE SAME KEYING THE GATE + * BELOW ALREADY USES. + * + * CRITERION. Whether a brokered call needs a connection row is decided by the app the url + * names, out of the one row that answers for it — see {@link brokeredAppRow}. + * + * REASON. This read `row.authScheme`, the column on whichever row the call was dialled + * through, while the gate two lines down looks the connection up by TOOLKIT. Two rows may + * name one app, so the two halves of one decision were about different rows — and this half + * fails open: a duplicate row recording `NO_AUTH` at a key app's url skips the per-person gate + * entirely, and the deployment's own Composio key runs a call for somebody who connected + * nothing. The other direction merely refuses a call that could have gone through. A gate and + * its exemption have to be keyed on the same thing, and the gate's key is the app. + * + * ONE SMALL READ PER BROKERED CALL, which is the price of that. `mcp_servers` holds one row + * per connector on any deployment, and the call it guards is a network round trip to the + * vendor. + */ + if ((await brokeredAppKind(access.toolkit)) === "none") return {}; + + /** + * WHAT THAT GATE ANSWERS FOR EACH KIND OF APP, WRITTEN DOWN BECAUSE THE LINE ABOVE DOES NOT. + * + * Type-only and erased; see {@link Decides}. This was the ONE scheme read in the file that + * compared a raw literal instead of asking {@link schemeKind}, so the vocabulary it decided on + * was not the vocabulary it read: `NO_AUTH` was a CONSENT-kind scheme, and this line exempted + * it from inside that member while every other consumer of `consent` demanded a row. One + * member answered two ways is the drift a closed vocabulary exists to make impossible, and the + * cost was paid next door — {@link confirmBrokeredConnection} asked the classifier, was told + * `consent`, and wrote the verified connection row this gate and the connect route both exist + * to keep out of that table. `none` is now its own member, and this asks for it by name. + */ + type _NoAuthGateDecides = Decides< + SchemeKind, + { + key: "demands a connection row, because a key app has an account to hold one"; + consent: "demands a connection row, because somebody consented and the row is that grant"; + none: "lets the call through with no row at all — there is no account, so there is nothing anybody could have granted"; + unreadable: "demands a connection row, which is the closed direction and the right one"; + } + >; + + /* + * Keyed on the app the call will run in, which is the one the url names, and on the account + * the ROW says acts — the asker's own for a Personal app, the deployment's for a Shared one. + * + * `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 resolved = await accountRefFor(row.id, actorId); + if ("refusal" in resolved) + throw new PluginRefusedError(resolved.refusal, null); + /* + * THE ACCOUNT MUST BE THE ONE THE AUDIENCE GATE WAS ASKED ABOUT. + * + * `callTool` decides whether to run the Shared audience gate off the app's mode as it read it, + * and this reads the mode again. An administrator switching the app to Shared between the two + * reads would otherwise send an ungated call out on the team account. Refused rather than + * re-gated: the switch is rare, asking again is cheap, and a refusal cannot be the hole. + */ + if (gatedMode && resolved.mode !== gatedMode) + throw new PluginRefusedError( + `${row.title} changed how it is shared while this call was being checked, so it was not called. Ask again.`, + null, + ); + const [connected] = await database + .select({ vendorUserId: brokeredConnections.vendorUserId }) + .from(brokeredConnections) + .where(accountRow(access.toolkit, resolved.ref)) + .limit(1); + if (!connected) { + throw new PluginRefusedError( + resolved.ref.holder === "deployment" + ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.` + : `You have not connected your ${row.title} account. Connect it in Settings and ask again.`, + null, + ); + } + /* + * THE ROW'S IDENTITY, NOT A RECOMPUTED ONE. A shared account was connected under the id minted + * when the app became Shared; reading it back here is what makes a later change to + * DEPLOYMENT_ID unable to move the account a call lands in. + */ + return { vendorUserId: connected.vendorUserId }; + } + + if (access.credential !== "person-oauth") { + const token = row.credentialId + ? await secretFor( + row.credentialId, + `${row.id} needs a credential this deployment no longer holds. An administrator has to add it again.`, + ) + : undefined; + return { token }; + } + + /* + * Narrowing, not a second decision. + * + * `access.credential === "person-oauth"` is derived in `access.ts` from exactly this auth kind, + * so the branch above has already established it — but the derivation runs through a lookup + * table the compiler cannot follow back to `entry`. Nothing below re-decides whether this is a + * per-person server; it only reads the OAuth details that kind carries. + * + * A throw rather than a fallback. If the descriptor and the entry ever did disagree, answering + * out of the deployment's own credential is precisely the failure the comment above this function + * says must be impossible. + */ + if (entry?.auth.kind !== "user-oauth") { + throw new PluginInvariantError( + `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, + ); + } + + /* + * The anonymous actor is the empty string, and an empty string must never match a row. + * + * `identifyActor` answers with `{ id: "" }` when it cannot resolve who is asking. Letting that + * reach the lookup would mean a run nobody can be held accountable for picking up whichever + * grant sorted first, so it is refused before the query rather than trusted to miss. + */ + if (!actorId) { + throw new PluginRefusedError( + `${row.id} answers as the person asking, and this run is not attributed to anybody.`, + null, + ); + } + + /* + * Whether this person has connected at all, which is a refusal worth reaching before anything + * queues behind another call. WHICH credential they hold is read again inside the critical + * section below, because a reconnection can move it while this call waits its turn — and even + * when the row stays put, the secret inside it does not. + */ + const [held] = await database + .select({ credentialId: mcpUserCredentials.credentialId }) + .from(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, row.id), + eq(mcpUserCredentials.userId, actorId), + ), + ) + .limit(1); + + if (!held) { + throw new PluginRefusedError( + `You have not connected your ${entry.title} account. Connect it in Settings and ask again.`, + null, + ); + } + + // Held before the critical section, because narrowing does not survive into a closure and this + // is where the entry is known to be a `user-oauth` one. + const { tokenUrl } = entry.auth; + const { title } = entry; + /* + * Where to register again, for a vendor that issues its own clients — and undefined for one an + * administrator registered with by hand, where there is nothing this deployment could do about a + * client the vendor no longer honours. + */ + const registrationUrl = + entry.auth.clientRegistration === "dynamic" + ? entry.auth.registrationUrl + : undefined; + + /* + * What to tell the person when the deployment holds no client they can be called on, in the + * words of whoever can actually change that. + * + * A hand-registered client is an administrator's paperwork — they pasted it in from the vendor's + * console, and only they can paste one in again. A self-registered one is nobody's paperwork: + * there is no console entry to re-create, and the deployment introduces itself again the next + * time somebody connects. Naming an administrator there would send the person to somebody with + * no step to take, which is worse than saying nothing. + */ + const noClient = registrationUrl + ? `${title} has no OAuth client for this deployment, so this cannot be called. Connect ${title} again in Settings: the deployment registers itself with the vendor when somebody connects.` + : `${title} has no OAuth client registered for this deployment, so this cannot be called. An administrator has to add one.`; + const unusableClient = registrationUrl + ? `${title} has no usable OAuth client for this deployment. Connect ${title} again in Settings: the deployment registers itself with the vendor on the next connect.` + : `${title} has no usable OAuth client for this deployment. An administrator has to add one again.`; + /** + * What to say when the vendor has forgotten the client this person's grant was issued under. + * + * The same register as the two above, and the same instruction, because it is the same situation + * from the person's side: nothing they can be called on. What is different is that the + * deployment CAN do its half — introduce itself again — and has, by the time this is thrown. So + * the sentence says that too, otherwise "connect again" reads as a thing to keep trying. + * + * Their refresh token is not carried across. A grant belongs to the client it was issued to (RFC + * 6749 §6, §10.4), so re-presenting it under the new client is a request a conforming vendor + * refuses — and one that only ever appears to work against a vendor whose acceptance would + * itself be the vulnerability. A new consent is the only thing that produces a usable grant. + */ + const clientReplaced = `${title} no longer recognises this deployment's OAuth client, so this cannot be called. The deployment has registered itself again — connect ${title} again in Settings.`; + + if (!row.credentialId) { + // The person did their part; the deployment has not. Refused before anything queues, because + // a deployment holding no client has the same answer for everybody asking. + throw new PluginRefusedError(noClient, null); + } + + /** + * The client as the deployment holds it right now, or the refusal for holding none. + * + * Read from the server row each time rather than from the row this call came in with: a retry + * that registered again — this connection's own, a moment ago — replaced it, and the pointer + * carried in from before the queue names the evicted one. + */ + async function currentClient(): Promise { + /* + * When the client was stored comes back with it, from the vault row itself rather than from a + * column of our own. It is what the retry below measures its backoff against, and a left join + * keeps it one query: a server pointing at nothing is the refusal on the next line, and a + * pointer to a row that is no longer there is `secretFor`'s to refuse. + */ + const [server] = await database + .select({ + credentialId: mcpServers.credentialId, + registeredAt: credentialRows.createdAt, + }) + .from(mcpServers) + .leftJoin( + credentialRows, + eq(credentialRows.id, mcpServers.credentialId), + ) + .where(eq(mcpServers.id, row.id)) + .limit(1); + if (!server?.credentialId) { + throw new PluginRefusedError(noClient, null); + } + /* + * Decrypted outside the guard below, so that `secretFor`'s own refusal for a revoked or + * missing row is not caught here and relabelled. Only the parse is guarded. + */ + const decrypted = await secretFor(server.credentialId, unusableClient); + let parsed: unknown; + try { + parsed = JSON.parse(decrypted); + } catch { + /* + * Unreadable is the same as none, exactly as it is for {@link heldOAuthClient} and + * {@link storedOAuthClient}: there is nothing here to present to a vendor. Those two answer + * null because their callers are deciding whether a consent flow can start; this one is + * already mid-call, and its every caller would have to turn a null into this same refusal + * on the next line — so it raises it. + * + * THE PARSER'S OWN WORDS ARE NEVER CARRIED. `JSON.parse` reports failure by quoting the + * input it choked on, and the input here is the DECRYPTED OAuth client. Rethrowing it puts + * a fragment of the client secret — under Bun's parser, the whole of it when the stored + * value is a bare token — into the `mcp.call_failed` payload and into + * `mcp_servers.last_error`, two durable stores that the Plugins page draws for anybody who + * can read it. + * + * A reader should conclude that the signal is not lost, only the bytes: `unusableClient` + * says the deployment holds a client it cannot use, which is distinct from `noClient`'s + * holding none, and it names connecting again as what replaces it. An operator can tell + * the credential is broken; nobody learns what was in it. + */ + throw new PluginRefusedError(unusableClient, null); + } + /* + * The same refusal for a value that parsed and is not a client — see {@link isUsableClient}, + * which is where the criterion and the reason live, because all three readers share both. + * + * Raised rather than answered null, for the reason above: this caller is mid-call. It is the + * third throw in a row here — `noClient`, then the parse, then this — and that is the shape + * of the contract rather than a repetition to collapse. Each names a different state of the + * deployment's credential, and only the sentence is shared between the last two. + */ + if (!isUsableClient(parsed)) { + throw new PluginRefusedError(unusableClient, null); + } + return { client: parsed, registeredAt: server.registeredAt }; + } + + /* + * The exchange, one call at a time for this connection, reading the connection fresh inside. + * + * Both halves of what goes out are read in here rather than carried in from above, because both + * can move while this call waits its turn. The refresh token rotates, and the token read a + * moment ago is then already spent — presenting it would be refused by the vendor for no reason + * the person could act on. The client is replaced by a re-registration, and presenting the + * evicted one would have every queued call discover that separately and register around it. + * + * TWO things serialise this, and they are not redundant. The map above queues calls made in THIS + * process; the row lock below serialises the whole deployment. Only the lock is a correctness + * property — a second replica has a map of its own and is not in ours — and the map is what + * keeps a burst of calls on one connection from piling N transactions onto that one row lock, + * each of them holding a pooled connection while it waits its turn. + * + * An evicted client is handled AFTER the transaction rather than inside it. Nothing about + * re-registering needs this person's lock — the client is per server — and doing it inside would + * have a second pooled connection opened while this one holds a row lock, which is the shape the + * pool note in `db/client.ts` is about. + */ + return await serialized(`${row.id}:${actorId}`, async () => { + /* + * The client, read before the transaction opens rather than inside it. + * + * It is per SERVER and is not what the lock protects, and reading it here keeps the vault read + * off a second pooled connection while this call holds one open for the whole exchange. Still + * inside the critical section, so the ordering that matters is unchanged: a queued call reads + * the client after whatever ran before it replaced it. + */ + const stored = await currentClient(); + + /* + * The vault row, locked for as long as the token it holds is being spent. + * + * A rotating vendor kills the refresh token it was shown, so two replicas that both read the + * stored token and both present it do not merely race: the second presentation looks to the + * vendor like a stolen token being replayed, and refresh-token-reuse detection answers it by + * revoking the whole token family. The connection is then bricked, and nobody did anything + * wrong. `SELECT … FOR UPDATE` is what makes the second replica wait for the first, exactly as + * a person reconnecting already waits (`credentials.rotate`). + * + * Yes, the lock is held across an HTTP call to the vendor — bounded by the exchange's own + * timeout. That is the point rather than an oversight: the lock IS the cross-replica + * serialisation, and a lock released before the exchange would serialise nothing. + * + * The read comes AFTER the lock, never before. A replica that woke from the lock and used a + * token it had read on the way in would present the one the first replica just spent, which is + * the very double-spend this exists to prevent. + */ + try { + return await database.transaction(async (transaction) => { + const [current] = await transaction + .select({ + credentialId: mcpUserCredentials.credentialId, + scope: mcpUserCredentials.scope, + }) + .from(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, row.id), + eq(mcpUserCredentials.userId, actorId), + ), + ) + .limit(1); + + // Disconnected while this call was queued. The same sentence as above: nothing is broken, + // and connecting again is the thing to do. + if (!current) { + throw new PluginRefusedError( + `You have not connected your ${title} account. Connect it in Settings and ask again.`, + null, + ); + } + + const [locked] = await transaction + .select({ + encryptedValue: credentialRows.encryptedValue, + revokedAt: credentialRows.revokedAt, + }) + .from(credentialRows) + .where(eq(credentialRows.id, current.credentialId)) + .for("update"); + /* + * A row that is gone or revoked, said the way `secretFor` says it: withdrawn access is + * nobody's fault and connecting again is the step. Reached by a replica that waited here + * while somebody disconnected, as well as by one that was told after the fact. + */ + if (!locked || locked.revokedAt) { + throw new PluginRefusedError( + `Your ${title} access was withdrawn. Connect it again in Settings.`, + null, + ); + } + const refreshToken = await decryptSecret( + encryptionKey, + locked.encryptedValue, + ); + + const minted = await exchangeRefreshToken({ + tokenUrl, + client: stored.client, + refreshToken, + }); + + /* + * A vendor that sent nothing back, or sent back the token we presented, rotated nothing — + * and writing either would be inventing a rotation, at the cost of a needless + * re-encryption of every connection on every call. + */ + if (minted.refreshToken && minted.refreshToken !== refreshToken) { + /* + * The vendor rotated the grant: the token we were just shown is now the only valid one. + * Persisting it is not optional bookkeeping — failing to would strand the connection on + * the next call — so a failure here refuses THIS call rather than returning an access + * token whose refresh token is already spent. + * + * In this transaction, so it commits with the lock that made the exchange ours: written + * outside it, the next replica in line would wake to the token this one just spent. + */ + await rotateConnectionToken( + { + credentialId: current.credentialId, + refreshToken: minted.refreshToken, + }, + transaction, + ); + } + + return { token: minted.accessToken }; + }); + } catch (error) { + /* + * Outside the transaction, so the row lock is already released and the vault read this does + * is not a second connection held behind this one's. + * + * Rethrows anything that is not the vendor disowning our client, which is every ordinary + * failure: a withdrawn grant, a vendor being down, a disconnect mid-queue. + */ + return await refuseAndReplaceEvictedClient({ + error, + clientRegisteredAt: stored.registeredAt, + registrationUrl, + serverId: row.id, + refusal: clientReplaced, + }); + } + }); + } + + /** + * The vendor has disowned this deployment's client: fix the deployment, refuse the call. + * + * `invalid_client` is the vendor saying the CLIENT is the problem, and for a client the deployment + * issued to itself there is nobody to tell — no console entry an administrator could re-create, so + * every connection to that server would otherwise sit behind a refusal nothing here can act on. + * Introducing itself again is the same act as the first registration, and it is worth doing: it is + * what makes the next CONSENT possible. + * + * IT DOES NOT MAKE THIS CALL POSSIBLE, and this function used to pretend otherwise. It registered a + * new client and re-presented the same refresh token under it. A refresh token is bound to the + * client it was issued to — RFC 6749 §6 has the token endpoint verify exactly that, and §10.4 is + * why — so a conforming vendor refuses the retry, and the only vendor it can work against is one + * whose acceptance would itself be the vulnerability. So the grant is never carried across, and the + * person is told the one thing that helps: connect again. + * + * The re-registration is still bounded by {@link CLIENT_REREGISTRATION_BACKOFF_MS}, and that bound + * is the whole protection here rather than a nicety. This runs for any non-admin's tool call, and + * it REPLACES the client every other connection in the deployment is bound to; a vendor that is + * simply down answers every exchange `invalid_client`, so without the window one outage would have + * each call in turn rotate the deployment-wide client. A client younger than the window is the + * product of the last refusal's re-registration, and is left exactly alone. + * + * Always throws. The refusal it raises when it did register is the caller's answer; anything it + * cannot act on is rethrown untouched, because the vendor's own words are better than ours. + */ + async function refuseAndReplaceEvictedClient(input: { + error: unknown; + /** When the client that was just refused was stored. */ + clientRegisteredAt: Date | null; + registrationUrl: string | undefined; + serverId: string; + /** What to tell the person once the deployment has registered itself again. */ + refusal: string; + }): Promise { + const { error, registrationUrl, serverId } = input; + /* + * The code, off the error itself. Never the sentence: that is written for a person, and a + * recovery that read it would be one rewording away from silently never running again. + */ + const code = error instanceof TokenRefusedError ? error.code : null; + const { redirectUri } = options; + if (code !== INVALID_CLIENT || !registrationUrl || !redirectUri) { + throw error; + } + + const registeredAt = input.clientRegisteredAt; + if ( + registeredAt && + Date.now() - registeredAt.getTime() < CLIENT_REREGISTRATION_BACKOFF_MS + ) { + throw error; + } + + const fresh = await registerClient({ registrationUrl, redirectUri }); + // The vendor would not have us either. The first refusal is the one worth reporting: it says + // what actually stopped the call, where this one says what stopped the recovery. + if (!fresh) throw error; + + await persistOAuthClient({ serverId, client: fresh, by: "deployment" }); + throw new PluginRefusedError(input.refusal, null); + } + + /** + * Point one person's connection at a new refresh token, revoking the one it replaces. + * + * For a person connecting, which is where a new row earns its keep: what they held before this is + * still a live grant at the vendor, and the revocation is how it stops being one. A vendor's own + * rotation is the other case entirely, and goes through {@link rotateConnectionToken}. + * + * Upserted on the pair, so it is the same act whether they are connecting or reconnecting. The + * credential the row used to point at is revoked in the same breath: a refresh token nothing + * points at is still a live grant at the vendor, and leaving it behind would mean somebody had two + * valid grants and could only ever see one of them to withdraw it. + * + * Says whether it replaced something, which is the one fact the caller writing the trail needs + * and cannot recover afterwards. + * + * ONE TRANSACTION, because these are two writes and one decision. The secret goes into the vault + * and the connection row is pointed at it; separately, a failure between them leaves the pointer + * naming the credential the rotation had just revoked — a connection that reads as live on the + * settings page and refuses every call, with the person's actual grant retired and no way back to + * it. `credentials.rotate` and `credentials.create` already accept the caller's executor for + * exactly this, and the pointer write runs on the same one. + */ + async function swapUserCredential(input: { + serverId: string; + userId: string; + refreshToken: string; + scope: string; + }): Promise<{ replaced: boolean }> { + const key = { + kind: "mcp_user_token" as const, + provider: input.serverId, + keyId: input.userId, + }; + const value = { + ...key, + metadata: { server: input.serverId, scope: input.scope }, + // Encrypted before the transaction opens: it is arithmetic, and it has no business happening + // while a pooled connection is held open behind row locks. + encryptedValue: await encryptSecret(encryptionKey, input.refreshToken), + }; + + return await database.transaction(async (transaction) => { + /* + * `credentials_active_key_idx` holds one live credential per key, so a second insert for the + * same person and server would be refused. Asked of the key rather than of the connection row, + * because the row can name a credential that has already been revoked while the key itself is + * free, and it is the key the index constrains. + */ + const live = await credentials.findLiveByKey(key, transaction); + const stored = live + ? await credentials.rotate( + { ...value, previousCredentialId: live.id }, + transaction, + ) + : await credentials.create(value, transaction); + + await transaction + .insert(mcpUserCredentials) + .values({ + serverId: input.serverId, + userId: input.userId, + credentialId: stored.id, + scope: input.scope, + }) + .onConflictDoUpdate({ + target: [mcpUserCredentials.serverId, mcpUserCredentials.userId], + set: { + credentialId: stored.id, + scope: input.scope, + updatedAt: new Date(), + }, + }); + + return { replaced: live !== null }; + }); + } + + /** + * Carry a connection over to the refresh token the vendor rotated to. + * + * In place, in the vault row the connection already points at — deliberately NOT the swap + * {@link swapUserCredential} performs. A rotating vendor issues a new refresh token on every + * exchange, so a swap here would mint a row and revoke a row per tool call, forever, on the + * hottest path there is. And the revocation would have nothing to withdraw: the token just spent + * was dead at the vendor the moment it answered, so the only live grant is the one being written. + * + * Deliberately WITHOUT the `mcp.account_connected` row, for the same reason as ever: rotation is + * the vendor's plumbing, not a person's act, and a trail that records it as one reads as a + * re-consent that nobody performed. + * + * The scope and the row are left alone. Nothing about what the vendor granted has changed — only + * which token presents it. + */ + async function rotateConnectionToken( + input: { + credentialId: string; + refreshToken: string; + }, + /** + * The transaction the caller spent the token in, and this write belongs to it. + * + * The caller holds a `FOR UPDATE` lock on the very row being written. On its own pooled + * connection this write would be a second session waiting for a lock only the caller can + * release, and the caller cannot release it while awaiting this — so it would hang to the + * statement timeout rather than rotate. + */ + executor?: CredentialExecutor, + ): Promise { + await credentials.updateSecret( + input.credentialId, + await encryptSecret(encryptionKey, input.refreshToken), + executor, + ); + } + + /** The one vault key a server's OAuth client is ever stored under. */ + const oauthClientKey = (serverId: string) => ({ + kind: "mcp_oauth_client" as const, + provider: serverId, + keyId: `oauth-client-${serverId}`, + }); + + /** + * One writer at a time for one server's OAuth client, across the whole deployment. + * + * Everything that stores a client reads "is there a live one" and then writes accordingly, and the + * gap between those two used to be unserialised. `POST /connect` is `requireUser`, so two people + * pressing Connect on a fresh connector is not a rare interleaving: both read no live client, and + * then the second `create` meets the first on `credentials_active_key_idx` as a raw 23505 — a 500 + * where a consent URL belonged — or, when there was a client to replace, the second `rotate` finds + * its own predecessor already revoked and says so. + * + * An ADVISORY lock rather than a row lock, because the thing being protected is the ABSENCE of a + * row as much as a row: there is nothing to lock `FOR UPDATE` on a first registration. Held for + * the transaction, so it is released by the commit or the rollback and never by us forgetting. + * + * `hashtext` collisions are harmless here. Two servers sharing a hash would take turns registering + * clients, which is slower and not wrong. + */ + async function withOAuthClientLock( + serverId: string, + work: (transaction: Transaction) => Promise, + ): Promise { + return await database.transaction(async (transaction) => { + await transaction.execute( + sql`select pg_advisory_xact_lock(hashtext(${`oauth-client-${serverId}`}))`, + ); + return await work(transaction); + }); + } + + /** + * {@link storedOAuthClient}'s question, asked on the caller's own transaction. + * + * The same question deliberately — the server row's pointer, and the row it names being live — + * because the callback redeems against `oauthClientFor`, which reads exactly that. A read that + * accepted a live client the server row does NOT name would hand somebody a consent screen for a + * client the callback then cannot find, and the connect would fail after the vendor said yes. + * + * On the transaction rather than through `storedOAuthClient` because the caller is inside + * {@link withOAuthClientLock} and holding a pooled connection: a read on a second connection would + * be a session queueing behind sessions that cannot finish until it returns. + */ + async function heldOAuthClient( + transaction: Transaction, + serverId: string, + ): Promise { + const [server] = await transaction + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!server?.credentialId) return null; + + const [held] = await transaction + .select({ + encryptedValue: credentialRows.encryptedValue, + revokedAt: credentialRows.revokedAt, + }) + .from(credentialRows) + .where(eq(credentialRows.id, server.credentialId)) + .limit(1); + if (!held || held.revokedAt) return null; + + try { + const parsed: unknown = JSON.parse( + await decryptSecret(encryptionKey, held.encryptedValue), + ); + // A value that parsed and is not a client is unreadable in the same way and for the same + // caller — see {@link isUsableClient}. Null, because that is what this reader's caller acts + // on: it goes and registers one, which is the answer to holding none. + return isUsableClient(parsed) ? parsed : null; + } catch { + // Unreadable is the same as none: there is nothing to send anybody to consent with. + return null; + } + } + + /** + * The two writes that store a client, on one transaction: the vault row, and the pointer to it. + * + * One transaction because they are one decision. Separately, a failure between them leaves + * `mcp_servers.credential_id` naming the credential the rotation had just revoked — a connector + * that looks configured on every screen and cannot complete a consent flow. + * + * The caller is expected to hold {@link withOAuthClientLock}, which is what makes the read below + * safe to act on. + */ + async function writeOAuthClient( + input: { serverId: string; client: OAuthClient }, + transaction: Transaction, + ): Promise<{ replaced: boolean }> { + const key = oauthClientKey(input.serverId); + const value = { + ...key, + metadata: { server: input.serverId, clientId: input.client.clientId }, + encryptedValue: await encryptSecret( + encryptionKey, + JSON.stringify(input.client), + ), + }; + + const live = await credentials.findLiveByKey(key, transaction); + const stored = live + ? await credentials.rotate( + { ...value, previousCredentialId: live.id }, + transaction, + ) + : await credentials.create(value, transaction); + + await transaction + .update(mcpServers) + .set({ credentialId: stored.id, updatedAt: new Date() }) + .where(eq(mcpServers.id, input.serverId)); + + return { replaced: live !== null }; + } + + /** + * The trail row for a client this deployment now holds. + * + * Written AFTER the transaction that stored it, not inside. The audit store has its own handle on + * the database, so writing from inside would open a second pooled connection while the first is + * held — the shape the pool note in `db/client.ts` warns about, and the one that turns a busy + * deployment into a hang. A trail row lost to a crash in that window is a worse trade than a + * deadlock, but only just, and this way round the client is at least the thing that is certain. + */ + async function recordClientRegistered( + input: { serverId: string; client: OAuthClient; by: string }, + replaced: boolean, + ): Promise { + await recordAuditEvent(auditStore, { + eventType: "mcp.oauth_client_registered", + targetType: "mcp_server", + targetId: input.serverId, + payload: { + actor: input.by, + server: input.serverId, + // The id, never the secret. It identifies the client that was registered, which is what + // somebody reading the trail needs in order to check it against the vendor's console. + clientId: input.client.clientId, + replaced, + }, + }); + } + + /** + * Store the deployment's OAuth client for a `user-oauth` server, whoever obtained it. + * + * Both halves go into one encrypted value, so a single vault read yields a usable client. The id is + * copied into `metadata` as well — it is not a secret, and a page listing what the deployment holds + * should be able to name it without decrypting anything. + * + * Replacing a client revokes the previous one rather than orphaning it, so "what does this + * deployment hold" keeps having one answer per server. Nobody's connection breaks in the sense that + * matters here — a refresh token is the person's — but nobody's connection SURVIVES either: a grant + * belongs to the client it was issued to, so replacing the client is asking everybody to connect + * again. That is why the two callers that replace one both say so to whoever is listening. + * + * Shared by an administrator pasting one in and by the deployment registering its own, so `by` is + * the only difference between the two in the trail — which is the honest one. + */ + async function persistOAuthClient(input: { + serverId: string; + client: OAuthClient; + by: string; + }): Promise { + const { entry } = await requireServer(input.serverId); + if (entry?.auth.kind !== "user-oauth") { + throw new CustomServerRefusedError( + `${input.serverId} is not reached with an OAuth client.`, + ); + } + + const { replaced } = await withOAuthClientLock( + input.serverId, + (transaction) => + writeOAuthClient( + { serverId: input.serverId, client: input.client }, + transaction, + ), + ); + + await recordClientRegistered(input, replaced); + } + + /** + * The deployment's OAuth client for a server as it stands, or null if there is none to read. + * + * Decrypted, because both halves are needed: the id to build a consent URL and the secret to + * redeem the code it comes back with. Held for the length of one request, like every other secret + * this module reads. + */ + async function storedOAuthClient( + serverId: string, + ): Promise { + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!row?.credentialId) return null; + + try { + const parsed: unknown = JSON.parse( + await decryptCredentialForUse( + encryptionKey, + credentials, + row.credentialId, + ), + ); + // A client that parsed and is not one is as unusable as the revoked or missing row the catch + // below answers for, and is the same none to every caller — see {@link isUsableClient}. + return isUsableClient(parsed) ? parsed : null; + } catch { + // A revoked, missing or unreadable client is the same as none for every caller: there is + // nothing to send anybody to consent with, and the answer is to obtain one again. + return null; + } + } + + /** + * The credential a server is being pointed at is of the kind that server can spend. + * + * Both add paths dereference the pointer before they return, so this is checked where the pointer + * is accepted rather than where it is used. `mcp` is the only kind that answers "this server's own + * token". A `mcp_user_token` is one person's grant and a `mcp_oauth_client` identifies the + * deployment to a vendor; spending either here uses a credential on behalf of somebody who never + * agreed to it, which is the same objection `POST /api/admin/credentials` already makes when it + * refuses to mint those two by hand. + * + * The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a value + * that is not one makes the query itself fail rather than return no rows, and the caller gets a + * database error where a refusal belongs. + * + * One message for both "wrong kind" and "no such credential", deliberately. A caller who can tell + * those apart can ask this endpoint which credential ids are real. + */ + async function requireCredentialOfKind( + serverTitle: string, + serverId: string, + credentialId: string, + kind: "mcp" | null, + ): Promise { + /* + * A server that takes no credential when it is added is refused here rather than at the caller, + * so that offering an id is one question with one answer wherever it is asked. The wording says + * what is true of both kinds that reach it: a `user-oauth` server's client arrives through the + * call that mints it, and a server needing no credential has nothing to be given. + */ + if (!kind) { + throw new CustomServerRefusedError( + `${serverTitle} takes no credential when it is added.`, + ); + } + + const looksLikeId = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + credentialId, + ); + /* + * Live, as well as the right kind and the right owner. + * + * A revoked credential cannot be decrypted, so attaching one only ever produced a server that + * fails on its next call. Refusing it here says so at the moment somebody can still act on it, + * and it closes the case where a token was retired precisely because it should stop being used. + */ + const [named] = looksLikeId + ? await database + .select({ + kind: credentialRows.kind, + provider: credentialRows.provider, + }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, credentialId), + isNull(credentialRows.revokedAt), + ), + ) + : []; + + /* + * Whose it is, as well as what it is. + * + * `provider` is the server a token was minted for: `storeMcpToken` sets it to the server id and + * is the only way the plugins screen makes one. Without this, any `mcp` row in the vault could + * be attached to any server, and since the refresh spends it against that server's address, a + * token given to one vendor was deliverable to another. Reading a credential back is otherwise + * impossible by design, so this closes the one field that accepts a reference to a secret rather + * than the secret itself. + */ + if (named?.kind !== kind || named.provider !== serverId) { + throw new CustomServerRefusedError( + "That is not a credential this server can use. Add the server's own token instead.", + ); + } + } + + /** + * A brokered app's row is not something another add path may write through. + * + * CRITERION. `addServer` and `addCustomServer` refuse outright when the id they are about to + * upsert already holds a row {@link accessFor} answers `brokered` for. Both of those paths write a + * url of their own choosing, and neither may write one over an app somebody is connected to. + * + * REASON. The url is the ONLY place the app slug is written down. `connectionTokenFor` reads it to + * decide whose account a call runs in, `removeServer` reads it to find the accounts to end at + * Composio, and `retireConnectionsFor` finds a person's rows by the same string — so the moment + * another path rewrites that url, every `composio_connections` row behind it stands for an app + * nothing in this deployment can name any more. Consent that no operation can withdraw is the one + * state this feature must not reach, and it is reachable by two ordinary administrative acts. + * + * REFUSED RATHER THAN REPAIRED, WHICH IS NOT THE ANSWER THE OTHER DIRECTION GETS. + * {@link addBrokeredApp} converts a row it lands on, because there the url it writes is the app + * and the row comes out saying so. Here the opposite is true: writing `provenance = "custom"` + * alongside the new url would make the row self-consistent and lose the accounts just the same. + * Consistent and orphaned is no better than contradictory and orphaned, so there is nothing to + * write — only an act to decline. + * + * AND THE ADMINISTRATOR IS LEFT A REAL STEP. Removing the app ends every account at the vendor on + * the way out and takes the row with it, after which the name is free for an endpoint. The + * refusal says that, because the alternative reading — "this name is taken forever" — is not true + * and would send somebody editing the database. + * + * ASKED WITH NO ENTRY, the reading {@link removeServer} uses, and for its reason: an entry can + * only ever SUPPRESS the brokered answer, so passing one a colliding id looked up would hide the + * brokered state of the very row most in need of protecting. + */ + function requireNotBrokered( + serverId: string, + existing: + | { provenance: string; url: string; authScheme: string | null } + | undefined, + ) { + if (!existing) return; + if (accessFor(existing, null).credential !== "brokered") return; + throw new CustomServerRefusedError( + `${serverId} is a Composio app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at Composio — and the name is then free.`, + ); + } + + async function requireServer(serverId: string) { + const [row] = await database + .select() + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .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 + // deployment still had it added. Refused rather than reached: the pinned host that made it + // admissible no longer exists to check against, so there is nothing left that says this URL + // is one we agreed to talk to. + throw new CatalogueEntryUnknownError(row.id); + } + /* + * 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. + * + * AND RESOLVED AGAINST THE APP'S ACCOUNT COLUMNS, NOT THE DIALLED ROW'S. `reachedAs` is read off + * the scheme and the mode, both of which belong to the app — see {@link BrokeredAppRow} — so a + * brokered row is handed to {@link accessFor} carrying the answering row's two columns. Done + * here rather than at each consumer because this is the one resolution every store path reads, + * which is fewer places to remember than the callers; `listServers` reports the same two columns + * off the same row for the one reader outside the store, the memory ingestion. `app` goes back + * with it, so the audience gate decides on and keys its approval by the row that answers rather + * than the one that was dialled. Null for every row that is not a brokered app. + */ + const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; + const app = toolkit ? await brokeredAppRow(toolkit) : null; + const access = accessFor( + app + ? { ...row, authScheme: app.authScheme, accountMode: app.accountMode } + : row, + entry, + ); + return { row, entry, access, app }; + } + + /** + * What a row that answers for an app is asked for: which row it is, how the app connects, and + * whose account it runs in. + * + * THE ACCOUNT COLUMNS TRAVEL WITH THE SCHEME FOR THE SCHEME'S OWN REASON. Whether an app is Shared + * and which deployment identity it holds are facts about the APP, exactly as its scheme is, and a + * duplicate row at the same url is not a second app. Read off whichever row a call was dialled + * through, a duplicate recording `personal` at a Shared app's url skipped the audience gate and + * let anybody connect a personal account to the team's app, and one recording `shared` at a + * Personal app's url sent a person's call out as the deployment. Every account decision — + * {@link accountRefFor}, the audience gate in `callTool`, the trail's `reachedAs`, the listing — + * now reads these from here, so none of them can be about a different row than the others. + */ + type BrokeredAppRow = { + id: string; + authScheme: string | null; + accountMode: AccountMode | null; + sharedVendorUserId: string | null; + }; + + /** + * The one `mcp_servers` row that answers for the app a url names: its id, and its scheme. + * + * 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. A row called `gmail` at `composio://slack` would + * have somebody's Slack key attached to a scheme read off Gmail's row — and, through the id this + * also answers, a probe chosen off Gmail's action list and spent on their Slack key. That stake is + * shared by every caller: {@link connectBrokeredWithFields}, {@link recheckBrokeredConnection}, + * {@link confirmBrokeredConnection} and {@link disconnectBrokered} read the scheme, + * {@link probeBrokeredConnection} reads the id. + * + * AND ORDERED, BECAUSE THE URL IS NOT A KEY. `mcp_servers.url` has no unique index behind it, so + * two rows may name one app and `limit(1)` over them is the planner's choice rather than an + * answer. Each caller used to take it unordered and separately: the same person, the same app, and + * five readings free to disagree with each other and with themselves between two page loads. What + * that costs is a live key connection refused in words about a sign-in screen nobody used — + * "connect it the way it asks for", over an app connected exactly the way it asked — and, on the + * id, a Re-check the listing offers off the app's own row whose press resolves to the OTHER row, + * finds no action published there and reports that nothing could be tried: the `checkable`/`probe` + * deadlock, reached through the duplicate rather than through a composed id. + * + * SO THE READ IS ONE FUNCTION AND NOT A RULE EACH CALLER REPEATS. The lower id answers, which is + * the rule {@link brokeredConnectionsFor} names the app by, so the row the page shows an app under + * is the row every one of these reads off — and a sixth caller cannot re-derive the choice + * differently, because there is nothing here to re-derive. + * + * NULL FOR AN APP WITH NO ROW AT ALL, and that is an answer rather than a gap: a person can hold + * an account at an app this deployment has since removed, nothing names the scheme it was + * connected under any more, and every caller treats the null as "not an app we hold a key for" — + * or, for the probe, as "there is no app left to check". + */ + async function brokeredAppRow( + toolkit: string, + ): Promise { + const url = `composio://${toolkit}`; + return (await brokeredAppRowsAt([url])).get(url) ?? null; + } + + /** + * The same answer for several urls at once, and the ONE PLACE the ordering rule is written. + * + * THE RULE IS SQL'S `order by id` AND THE FIRST ROW SEEN PER URL, which is what + * {@link brokeredAppRow} asks for one app and what {@link brokeredConnectionsFor} and + * {@link listServers} ask for many. Those three had their own spellings of it, and one of them — + * the listing — spelled it as a JavaScript `<` over rows it had already fetched. That is UTF-16 + * code unit order; this is the deployment's collation. They agree for ASCII on a `C` database and + * are free to disagree anywhere else, and where they disagreed the settings page named an app + * under one row while every read behind its buttons was about another. So the rule is a function + * and the callers have nothing left to re-derive. + * + * A URL WITH NO ROW IS SIMPLY ABSENT from the map, which is the null {@link brokeredAppRow} + * answers and the connection {@link brokeredConnectionsFor} drops: a person can hold an account + * at an app this deployment has since removed, and there is no row to name it by. + * + * EMPTY IN, EMPTY OUT AND NO QUERY, because `inArray` with no values is a statement no database + * needs to be asked. + */ + async function brokeredAppRowsAt( + urls: string[], + ): Promise> { + const answering = new Map(); + if (urls.length === 0) return answering; + const rows = await database + .select({ + id: mcpServers.id, + url: mcpServers.url, + authScheme: mcpServers.authScheme, + accountMode: mcpServers.accountMode, + sharedVendorUserId: mcpServers.sharedVendorUserId, + }) + .from(mcpServers) + .where(inArray(mcpServers.url, urls)) + .orderBy(asc(mcpServers.id)); + for (const { url, ...row } of rows) { + if (!answering.has(url)) answering.set(url, row); + } + return answering; + } + + /** {@link brokeredAppRow}'s scheme, for the callers that ask only what the app is connected with. */ + async function brokeredAppScheme(toolkit: string): Promise { + return (await brokeredAppRow(toolkit))?.authScheme ?? null; + } + + /** + * What that scheme decides, which is what every caller actually branches on. + * + * ONE CLASSIFICATION OVER ONE ROW, and the second half of what {@link brokeredAppRow} is for. + * That function ends the disagreement about WHICH ROW answers for an app; this one ends the + * disagreement about what its column MEANS. They were separate questions and were answered + * separately: four callers each asked {@link isFieldScheme} and treated everything else — a + * consent scheme, a literal from another deployment, a null — as one answer, and only three of + * them could survive being wrong about it. The confirm is the fourth, and it WRITES. + * + * SO THE THIRD ANSWER TRAVELS, rather than being flattened at the call site. See + * {@link SchemeKind}: `unreadable` is what a caller needs in order to fail closed, and a boolean + * cannot carry it. + */ + async function brokeredAppKind(toolkit: string): Promise { + return schemeKind(await brokeredAppScheme(toolkit)); + } + + return { + options, + database, + auditStore, + credentials, + encryptionKey, + injectedVendor, + exchangeRefreshToken, + registerClient, + broker, + exchangeChains, + serialized, + grantsFor, + mcpGrantsForServers, + toolsDeclaredBy, + knownToolRefs, + secretFor, + accountRefFor, + accountRow, + connectionTokenFor, + refuseAndReplaceEvictedClient, + swapUserCredential, + rotateConnectionToken, + oauthClientKey, + withOAuthClientLock, + heldOAuthClient, + writeOAuthClient, + recordClientRegistered, + persistOAuthClient, + storedOAuthClient, + requireCredentialOfKind, + requireNotBrokered, + requireServer, + brokeredAppRow, + brokeredAppRowsAt, + brokeredAppScheme, + brokeredAppKind, + }; +} + +export type StoreContext = ReturnType; diff --git a/server/src/plugins/plugin-store/grants.ts b/server/src/plugins/plugin-store/grants.ts new file mode 100644 index 000000000..86e8f6fd3 --- /dev/null +++ b/server/src/plugins/plugin-store/grants.ts @@ -0,0 +1,156 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { recordAuditEvent } from "../../audit"; +import { mcpTools, pluginGrants, skills } from "../../db/schema"; +import type { StoreContext } from "./context"; +import { + type GrantedPlugins, + grantTargetType, + type PluginKind, + toolNameFor, +} from "./model"; + +/** Granting and revoking a server, skill or Bot on a Bot, and what a Bot holds. */ +export function grantMethods(context: StoreContext) { + const { database, auditStore, toolsDeclaredBy } = context; + return { + async grant( + kind: PluginKind, + ref: string, + agentId: string, + by: string, + ): Promise { + await database + .insert(pluginGrants) + .values({ kind, ref, agentId, grantedBy: by }) + .onConflictDoUpdate({ + target: [pluginGrants.kind, pluginGrants.ref, pluginGrants.agentId], + set: { grantedBy: by, updatedAt: new Date() }, + }); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: grantTargetType(kind), + targetId: ref, + payload: { + actor: by, + change: "plugin_granted", + kind, + ref, + bot: agentId, + }, + }); + }, + + async revoke( + kind: PluginKind, + ref: string, + agentId: string, + by: string, + ): Promise { + await database + .delete(pluginGrants) + .where( + and( + eq(pluginGrants.kind, kind), + eq(pluginGrants.ref, ref), + eq(pluginGrants.agentId, agentId), + ), + ); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: grantTargetType(kind), + targetId: ref, + payload: { + actor: by, + change: "plugin_revoked", + kind, + ref, + bot: agentId, + }, + }); + }, + + /** Everything one Bot may use. The runtime asks this and offers exactly what comes back. */ + async listForAgent(agentId: string): Promise { + const held = await database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.agentId, agentId)); + if (held.length === 0) return { tools: [], skills: [] }; + + const toolRefs = held + .filter((row) => row.kind === "mcp") + .map((row) => row.ref); + const skillSlugs = held + .filter((row) => row.kind === "skill") + .map((row) => row.ref); + + /* + * Narrowed in the query to the servers this Bot is actually granted something from, the same way + * `knownToolRefs` does it and for the same reason: a deployment aiming at a thousand tools should + * not read all of them to offer a handful. This is the run-time path, so it ran on every run of + * every Bot, selected every row in `mcp_tools`, and then discarded almost all of them here — and + * it sits underneath tool selection, so its cost is paid before the narrowing that was added to + * make large catalogues work. + * + * The exact ref is still matched below rather than in the query. Narrowing by server is a + * predicate the composite primary key can use; naming every (server, tool) pair would be exact + * and is not worth a clause per grant, because a server's own tool list is the bound on what + * comes back. + */ + const grantedServers = [ + ...new Set(toolRefs.map((ref) => ref.split("/")[0] ?? "")), + ]; + const toolRows = + grantedServers.length === 0 + ? [] + : await database + .select() + .from(mcpTools) + .where(inArray(mcpTools.serverId, grantedServers)) + .orderBy(asc(mcpTools.name)); + // A set, so this is a lookup per row rather than a walk of the grants per row. + const granted = new Set(toolRefs); + const grantedTools = toolRows + .filter((row) => granted.has(`${row.serverId}/${row.name}`)) + .map((row) => { + const ref = `${row.serverId}/${row.name}`; + return { + ref, + toolName: toolNameFor(ref), + description: row.description, + inputSchema: row.inputSchema as Record, + }; + }); + + const skillRows = + skillSlugs.length === 0 + ? [] + : await database + .select() + .from(skills) + .where(inArray(skills.slug, skillSlugs)); + + /* + * What each skill says it needs, carried alongside rather than folded into `tools`. + * + * `tools` above is what this Bot may call, and nothing here may widen it. Selection, when it is + * built, intersects the two; handing the runtime a union instead would make writing a skill a + * way to grant a tool, which is the one thing this must never be. + */ + const declared = await toolsDeclaredBy(skillRows.map((row) => row.id)); + + return { + tools: grantedTools, + skills: skillRows.map((row) => ({ + slug: row.slug, + title: row.title, + summary: row.summary, + instructions: row.instructions, + tools: declared.get(row.id) ?? [], + })), + }; + }, + }; +} diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts new file mode 100644 index 000000000..69ef78b10 --- /dev/null +++ b/server/src/plugins/plugin-store/model.ts @@ -0,0 +1,1187 @@ +import type { ApprovalGate } from "../../approvals/types"; +import type { AuditStore } from "../../audit"; +import type { ActionPolicy } from "../../computer/policy"; +import type { + CredentialSecretReader, + CredentialStore, +} from "../../credentials"; +import type { Database } from "../../db/client"; +import { isQueryFailure, withoutStatement } from "../../db/query-failure"; +import type { CheckPrivateShare } from "../../proactive/private-share"; +import { type ServerAccess, ServerUnresolvableError } from "../access"; +import type { + BrokerConnection, + ComposioBroker, + Decides, + RecordedScheme, +} from "../broker"; +import { type CatalogueEntry, resolveServerUrl } from "../catalogue"; +import { type ListedTool, McpServerError } from "../mcp"; +import type { AccountMode } from "../shared-accounts"; +import type { SharedUseGate } from "../shared-use-store"; + +/** + * The plugin store's model: its record types, errors, how a tool schema is stored, the token + * exchange and the OAuth client shape. Split out of `../store.ts`; that file re-exports what it + * always exported, so its importers did not change. + */ + +/** + * Plugins: what this deployment has added, which Bots may use it, and the one path a call takes. + * + * The grant and the policy are two different questions and both are asked on every call. The grant + * answers "is this Bot allowed this tool at all", which an operator decides on the Plugins page. The + * policy answers "is this particular call permitted right now", which is written as a rule and can + * say things a grant cannot: not on this host, not this argument, not a write. Collapsing them would + * mean an operator who granted a Bot a server had also, invisibly, waived every rule about it. + */ + +/** + * What a grant is a grant OF. + * + * `bot` is one Bot's permission to hand work to another, and it lives here rather than in a table of + * its own on purpose: an administrator already understands "this Bot may use that", a fork's policy + * layer already applies to grants, and reachability between Bots is the same kind of decision as + * reachability to a vendor's tools. A second table would be a second thing to reason about and a + * second thing for a fork to reimplement. + */ +export type PluginKind = "mcp" | "skill" | "bot"; + +/** + * What an audit row about a grant is a row ABOUT. + * + * A mapping rather than a ternary, because a ternary quietly labelled everything that was not an MCP + * tool a skill. Adding a third kind made that wrong rather than merely terse: a grant letting one Bot + * address another would have been filed in the trail as a skill, which is the sort of small lie an + * investigation trips over months later. + */ +export function grantTargetType(kind: PluginKind): string { + if (kind === "mcp") return "mcp_tool"; + if (kind === "bot") return "agent"; + return "skill"; +} + +export type ToolRecord = { + serverId: string; + name: string; + description: string; + inputSchema: Record; + /** `/`. 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[]; +}; + +/** + * A grant naming a tool this server does not currently advertise. + * + * Held and not offered. `listForAgent` reads the grant against the tool list, so nothing reaches a + * model — but that is a property of what the vendor is advertising today rather than of the grant, and + * it changes the moment the vendor advertises the name again. Google's Drive entry says so in its own + * comment: the REST transport is one line from being swapped back to MCP, and "tool names match + * Google's MCP server exactly, so grants survive the swap in either direction". + * + * So it is reported rather than pruned. A grant is the record of a decision somebody made, and the + * refresh that would have deleted it is not a safe place to decide from: the tool list is replaced by + * a `delete` and then an `insert`, and a vendor answering with an empty list is a success, so one bad + * answer would revoke every grant on that server and stamp the refresh as healthy. + */ +export type WithdrawnGrant = { + /** `/`, exactly as the grant is stored. */ + ref: string; + /** The tool half, for a screen that already has the server. */ + name: string; + grantedTo: string[]; +}; + +export type ServerRecord = { + id: string; + title: string; + logo: string | null; + vendor: string; + url: string; + summary: string; + docsUrl: string; + /** `first-party` or `custom`. Shown wherever the server is, never inferred by a reader. */ + provenance: string; + hasCredential: boolean; + toolsRefreshedAt: string | null; + lastError: string | null; + addedBy: string | null; + /** + * Whether the catalogue entry registers its own OAuth client (RFC 7591) rather than waiting on + * an administrator to paste one in. So the admin screen can hide the paste-a-client form where + * 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; + /** + * Whose account a brokered app acts in: each person's own, or the one the deployment holds. + * Null on a row that needs no account at all, and on every row that is not brokered. + */ + accountMode: "personal" | "shared" | null; + /** + * Whether a Shared app's deployment-held account is connected yet. + * + * ALWAYS FALSE WHILE {@link accountMode} IS NOT `"shared"`, even where a deployment row happens + * to stand at the app's url: a Personal app never acts in that account, so reporting it as + * connected would tell the page something that changes nothing a call does. + */ + sharedVendorConnected: boolean; + tools: ToolRecord[]; + /** + * Grants on tools this server no longer advertises. + * + * Empty for a healthy connector. Non-empty is the discrepancy an administrator should be reading + * about, which is why it is here rather than inferred by a screen comparing two lists. + */ + withdrawn: WithdrawnGrant[]; +}; + +/** + * A server row as the surfaces that only need to know where it is see it. + * + * Four columns of {@link ServerRecord} and none of what hangs off it, because the callers this is + * for ask one question: which vendor is this row addressed at. The title travels with the url + * because their refusals name it — "You already have an account connected to Linear" is the app's + * name, which is the only one of the two a person has ever seen on a screen. + */ +export type ServerAddress = { + id: string; + title: string; + url: string; + /** + * How this row's authorization config was created, for the brokered rows that have one. + * + * THE RECORDED SCHEME, NEVER A FRESH CATALOGUE READ. An app's config was created as one + * particular scheme and every connection standing against that config depends on it, so the + * connect path has to open the flow this column names rather than the one the catalogue + * publishes for the app today. Re-derived from a listing instead, a vendor that starts + * advertising a new scheme would silently move live connections onto a different flow — minting + * a consent link against a config that holds keys, or asking for a key where a consent screen is + * waiting. + * + * The same vocabulary the column holds, which the schema comment spells out: the vendor's own + * scheme literals, never a {@link BrokerConnection} kind. Null is not an older brokered row; it + * is a row that is not brokered. + */ + authScheme: string | null; + /** + * Whose account the app this row names is used under: each person's own, or one the deployment + * holds for everybody. + * + * THE APP'S, READ OFF THE ROW THAT ANSWERS FOR IT, exactly as {@link authScheme} is. Two rows may + * name one app and only one of them decides whose account it is, so a page that drew the Shared + * treatment off the row it opened could show one answer while every connect, grant and call + * behind its buttons acted on the other. Null is a row that is not brokered, and also a brokered + * row whose app has no answering row left to say. + */ + accountMode: AccountMode | null; +}; + +export type SkillRecord = { + id: string; + slug: string; + /** Whose it is. Null means the deployment's, written by an administrator or shipped. */ + ownerUserId: string | null; + title: string; + summary: string; + instructions: string; + origin: string; + installedBy: string | null; + grantedTo: string[]; + /** + * The tools this skill says it needs, as `/` refs. + * + * A declaration, not a grant: what a Bot may call is `grantedTo` on the tool side and nothing here. + * See the comment on `skillTools` in the schema for why that separation is load-bearing. + */ + tools: string[]; +}; + +/** + * Who is asking, for the surfaces where the answer depends on it. + * + * An administrator sees and governs the whole deployment. Everybody else sees the deployment's + * skills and their own, and may act only on their own. + */ +export type SkillActor = { id: string; isAdmin: boolean }; + +/** What one Bot holds. Everything the runtime needs to offer it, and nothing it does not. */ +export type GrantedPlugins = { + tools: { + ref: string; + /** The name the model is offered, which is the ref with the separator a tool name allows. */ + toolName: string; + description: string; + inputSchema: Record; + }[]; + skills: { + slug: string; + title: string; + summary: string; + instructions: string; + /** + * What this skill says it needs, as refs. Never a superset of `tools` above in effect: selection + * intersects the two, because a skill naming a tool the Bot lacks must load nothing rather than + * make it callable. + */ + tools: string[]; + }[]; +}; + +export type PluginDecision = + | { allowed: true } + | { allowed: false; reason: string }; + +/** + * What one check of somebody's brokered account spent, and what came of it. + * + * FOUR OUTCOMES AND NOT A PAIR OF NULLABLE FIELDS, because the fourth one is what the pair could + * not say and its absence was destructive. `verified: false` used to cover both "the app published + * nothing safe to try" and "it ran and the vendor refused the key", which is why the action's name + * travels beside the verdict at all — and a THIRD thing was quietly landing in the second of those: + * a vendor nobody could reach. `callTool` never throws, so a Composio outage, a socket that closed + * and a package that cannot parse an answer all arrived as `isError: true` and were read as the + * vendor rejecting the key. See {@link ActionAnswer} in `./composio` for the reading that settles + * it, and {@link composioConnections.probeAction} for what each of these may be written down as. + * + * `probe` IS THE ACTION A CHECK CAN BE SHOWN TO HAVE SPENT, and it is null on both outcomes where + * nothing can be shown — which is the whole structural point of this shape rather than a nicety. + * That field is what the writer records in `probe_action`, and a name beside `verified: false` is + * the accusation "it ran and the vendor refused your key". An unreachable vendor therefore has no + * name to give the writer, and cannot make that claim even by mistake; what was attempted is + * carried separately, for the trail and for the sentence somebody reads. + */ +export type BrokeredProbe = + /** The app published nothing safe to call, or no row for it is left. Nothing was tried. */ + | { outcome: "nothing"; probe: null } + /** It ran in this person's account and the vendor took the key. */ + | { outcome: "answered"; probe: string } + /** + * It ran in this person's account and the app answered with a failure. `sentence` is Composio's + * own account of it. + * + * AND THAT IS THE WHOLE OF WHAT IT PROVES, WHICH IS WHY IT IS NOT CALLED `refused`. It was, and + * every reader took the name at its word: the connect path DELETED the account it had made + * seconds earlier and told the person what they entered did not work, and the re-check path + * wrote the named-probe-beside-`verified: false` pair, which the settings page draws as "your + * key was checked and rejected". + * + * NOTHING IN THE ENVELOPE SUPPORTS EITHER CLAIM. What arrives is Composio's + * `{ data, error, successful }` — no HTTP status, no error code, nothing structured about the + * credential — so a rate limit, a scope the key legitimately lacks for this one action, and a + * 404 for a resource the read action happens to name are all indistinguishable here from a key + * the vendor rejected. A person pasting a VALID key into an app that is rate-limiting had their + * brand-new working connection revoked with a message blaming them, and that is not recoverable + * by pressing anything: the key has to be fetched and typed again. + * + * SO THE TWO DESTRUCTIVE READINGS ARE GONE AND THE NAME NO LONGER INVITES THEM. The account + * stands, the row records the check as failed with the action it was spent on, and the sentence + * the person is shown is the app's own words rather than a verdict about their credential. If + * Composio ever publishes a status or an error code on this envelope, telling the two apart is + * a fifth outcome and the roster below is what will make somebody decide it everywhere. + */ + | { outcome: "complained"; probe: string; sentence: string } + /** + * The vendor was not reached, or answered something nothing here can read, so NOTHING WAS + * LEARNED — about the key, and about whether the action ran at all. `attempted` is what would + * have been spent, which is a fact about this deployment's intent rather than about the account. + */ + | { + outcome: "unreachable"; + probe: null; + attempted: string; + sentence: string; + }; + +/** + * The four outcomes as a ROSTER, which is what anything enumerating them is checked against. + * + * A union is a thing a fifth member can be added to in one line with nothing anywhere failing: + * every reader of this vocabulary asks `probed.outcome === "complained"` or `=== "unreachable"`, and a + * chain of equality tests has no opinion about the answer it was not written for. `unreachable` is + * itself the proof — it was added in round two and the screen that draws these outcomes went on + * rendering it with the sentence written for a different case. See {@link Decides}. + * + * PINNED TO THE UNION IN BOTH DIRECTIONS. `satisfies` holds this list inside the union, so a name + * misspelled here fails; {@link _ProbeRosterNamesTheWholeUnion} holds the union inside this list, so + * a member added to the union and not to this roster fails. Neither direction is worth having on its + * own: the pair is what makes the roster a restatement that cannot drift. + * + * SO A FIFTH OUTCOME COSTS TWO STEPS AND CANNOT SKIP EITHER. Adding it to the union fails `tsc` + * here and at every consumer's `Decides<…>` roster; adding it here to satisfy that then fails the + * table in `tests/composio-connection-kinds.test.ts`, which reads its rows off this list and has no + * cell for the new name. The compiler settles what must decide and the table settles what it does. + */ +export const BROKERED_PROBE_OUTCOMES = [ + "nothing", + "answered", + "complained", + "unreachable", +] as const satisfies readonly BrokeredProbe["outcome"][]; + +/** The other direction of {@link BROKERED_PROBE_OUTCOMES}' pin. Type-only; erased entirely. */ +export type _ProbeRosterNamesTheWholeUnion = Decides< + BrokeredProbe["outcome"], + Record<(typeof BROKERED_PROBE_OUTCOMES)[number], string> +>; + +/** + * THE FOUR STATES A CONNECTION ROW MAY BE LEFT IN, named so the pair can be enumerated. + * + * `verified` and `probe_action` are one fact recorded in two columns, and + * {@link composioConnections.probeAction} enumerates the four readings of that pair in prose. Prose + * is what the client then modelled as three — the outage and the never-checked case share the + * mildest pair, and the screen drew one of them with the other's sentence. This is the same four + * with names, so a consumer that has to decide on them can be held to naming all four. + * + * NOT A COLUMN, NEVER WRITTEN ANYWHERE, AND NOT EXPORTED. The row carries the pair; this is the + * reading of it, and its one job is to be the value type {@link _ProbeOutcomeLeaves} is checked + * against. + * + * `unchecked` — null probe, not verified. Nothing was tried, so nothing is known about the key. + * `consented` — null probe, verified. A consent connection; the vendor's own yes is the evidence. + * `checked` — a named probe, verified. It ran in this account and the vendor took the key. + * `failed` — a named probe, not verified. It ran and the app answered with a failure, and the + * account still stands. The one state an operator has to act on. + * + * `failed` RATHER THAN `refuted`, AND THE RENAME IS THE FIX RATHER THAN A TIDY-UP. It was + * `refuted` — "the vendor refused the key", described in this file as "the accusation" — and + * nothing that writes this pair can establish that. See {@link BrokeredProbe}'s `complained` + * member: the envelope the verdict is read out of carries no status and no error code, so the + * pair says a check was spent and did not come back clean, and it stops there. Everything that + * renders it says the same, in the app's own words. + */ +export type RecordedCheck = "unchecked" | "consented" | "checked" | "failed"; + +/** + * WHICH OF THE FOUR A PROBE OUTCOME MAY EVER LEAVE BEHIND, the seam the two vocabularies meet at. + * + * Type-only and erased. It is a statement about {@link BrokeredProbe} rather than about any one + * writer, and it is here because `failed` is the state the whole shape of that type exists to + * withhold: an outcome carrying no probe name has no name to record, so it cannot reach that pair + * even by mistake. A fifth outcome has to say which of the four it may leave behind before anything + * compiles, which is precisely the question that went unasked when `unreachable` was added. + * + * THE ANSWERS ARE NARROWED TO {@link RecordedCheck} rather than left as free text, which is what + * makes this a contract and not a comment: a state invented here that the schema's four do not + * include fails, and so does one of the four renamed on one side of the seam only. + */ +export type _ProbeOutcomeLeaves = Decides< + BrokeredProbe["outcome"], + { + nothing: "untouched"; + answered: "checked"; + complained: "failed"; + unreachable: "untouched"; + }, + RecordedCheck | "untouched" +>; + +export class PluginRefusedError extends Error { + constructor( + message: string, + readonly rule: string | null, + ) { + super(message); + this.name = "PluginRefusedError"; + } +} + +export class CatalogueEntryUnknownError extends Error { + constructor(key: string) { + super(`${key} is not a server this deployment will connect to.`); + this.name = "CatalogueEntryUnknownError"; + } +} + +/** A URL an administrator offered that this deployment will not point itself at. */ +export class CustomServerRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "CustomServerRefusedError"; + } +} + +/** + * A state this deployment's own code says cannot exist, found existing. + * + * CRITERION. Nothing here is a vendor's doing, a credential's doing or anything a person asking can + * act on, so no path may record one of these as though a vendor had misbehaved. + * + * REASON. `refreshTools` wrapped the listing, the replace and both audit writes in one `catch` that + * copied every message into `lastError` and answered `{ tools: 0 }`. A plain `Error` is what the + * narrowing throws in {@link createPluginStore}'s `connectionTokenFor` raise, so a row that resolved + * to a brokered credential with no app in its url — or to a per-person credential with no + * `user-oauth` entry — came out on the Plugins page as a sentence about the vendor, next to a + * refresh that looked like it had merely failed. An operator reading that is sent to somebody else's + * status page over a contradiction in our own tables. + * + * A class rather than a message, because telling these apart by prose is telling them apart by a + * substring that a reword would silently change. Distinct from {@link PluginRefusedError}, which is + * a refusal somebody CAN act on and which does belong in `lastError` — an administrator who has not + * connected their account is the honest reason a listing did not happen. + */ +export class PluginInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginInvariantError"; + } +} + +/** + * Whether a throw is this deployment contradicting itself, rather than anything anybody asked for. + * + * CRITERION. Every audience boundary asks THIS instead of listing classes of its own. A fault it + * answers true for reaches an operator as its own sentence, on a surface only an operator can + * reach, and reaches everybody else as the fact that the call did not happen — no message, no + * column names, no instruction about a row. + * + * REASON. The distinction already existed and was drawn by hand, once, in each place that + * remembered to draw it: {@link PluginRefusedError} is relayed verbatim because it is a refusal + * the asker can act on, and everything else fell into a branch that copies `error.message` + * onwards. {@link ServerUnresolvableError} was caught by none of them — the refresh route rethrew + * it into the framework's default handler, which answers a bodiless 500, so the admin page said + * "That did not work" and named nothing; `grantedTools` put its message in a model's context, + * where a sentence telling an operator to correct a provenance column became a Bot's explanation + * to an end user of why their tool failed. Two audiences, one refusal, neither served. + * + * {@link PluginInvariantError} is on the same shelf and answers true for the same reason: it is + * this deployment finding a state its own code says cannot exist. That is not a vendor + * misbehaving and not a person's to act on mid-call, and its own docblock has said so since it + * was written — what it lacked was anywhere that asked. + * + * A PREDICATE RATHER THAN A SHARED BASE CLASS, because the two live in different modules and must + * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared + * once without one of them importing the other back. + */ +/** + * The one character no PostgreSQL `text` or `jsonb` value can hold, whatever the vendor sent. + * + * Not a length limit and not an encoding preference: the server rejects the statement outright, + * mid-transaction, and the rejection arrives as a query error rather than as anything about the + * value. + */ +export const NUL = "\u0000"; + +/** + * A schema this deployment cannot turn into a row, whatever the column would have said. + * + * Its own class so the one place that raises it and the one place that contains it are joined by + * something other than a substring: {@link storableSchema} is the only thrower, and the `try` + * around the call that turns a listing into rows is the only catcher. Nothing branches on the class + * — it is recorded the way anything else a vendor's answer could not be made into would be — but + * naming it keeps a later reader from mistaking it for a fault of this deployment's own. + */ +export class SchemaUnstorableError extends Error { + constructor(message: string) { + super(message); + this.name = "SchemaUnstorableError"; + } +} + +/** + * The same JSON value with every U+0000 gone from it — out of the strings, and out of the keys + * above them. + * + * CRITERION ONE. What is removed is the CHARACTER. A string that merely SPELLS the escape — a + * backslash and then `u0000`, which is how a JSON Schema excludes control characters and is by far + * the commonest place those six letters legitimately appear — is handed back untouched. + * + * CRITERION TWO. Everything else is rebuilt identical: the same keys, the same nesting, the same + * scalars. + * + * WHAT THIS REPLACED, and why walking the DATA is not a stylistic preference. The strip used to run + * over the SERIALISED schema — `JSON.stringify(schema).replaceAll("\\u0000", "")` — which is + * escape-blind by construction. `JSON.stringify` writes a real backslash inside a string value as + * two of them, so a pattern such as `"[^\u0000-\u001f]"` reached the strip with its backslash + * doubled and had the TAIL of it eaten, leaving `\-`. That is not a JSON escape, so the + * `JSON.parse` wrapped around it threw a `SyntaxError` — from a line that sat outside BOTH `try` + * blocks in `refreshTools`. It left as a bodiless 500 with `lastError` still holding whatever it + * held before, and on the add path, which refreshes before it answers, it aborted the add AFTER the + * server row and its audit row had committed. + * + * Serialised text cannot tell the byte from the six letters that name it; parsed data can only ever + * hold one of them. So walking the data is what makes CRITERION ONE expressible at all. + * + * A SELF-REFERENTIAL VALUE IS REFUSED rather than quietly truncated, and `ancestors` holds only the + * chain currently being descended — so one object reached twice side by side is copied twice, which + * is what a plain JSON document does anyway. Nothing off a wire can be cyclic; a value built inside + * this process can, and it is a value `jsonb` would refuse in any case. Refused HERE it is a + * sentence the caller can record; left to the driver it is a statement dump. + */ +export function withoutNul(value: unknown, ancestors: Set): unknown { + if (typeof value === "string") return value.replaceAll(NUL, ""); + if (typeof value !== "object" || value === null) return value; + if (ancestors.has(value)) { + throw new SchemaUnstorableError( + "The schema refers back to itself, so it is not a value JSON can hold.", + ); + } + + ancestors.add(value); + const stripped: unknown = Array.isArray(value) + ? value.map((item) => withoutNul(item, ancestors)) + : Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key.replaceAll(NUL, ""), + withoutNul(nested, ancestors), + ]), + ); + ancestors.delete(value); + return stripped; +} + +/** + * {@link withoutNul} over a schema, answering in the type the column and the row builder use. + * + * The walk rebuilds an object as an object and a list as a list, so what comes back is the shape + * that went in — which is the same promise the `JSON.parse(JSON.stringify(...))` round trip this + * replaced made about everything except the escape it could not see. The narrowing says out loud + * what the parameter type already claims. + */ +export function storableSchema( + schema: Record, +): Record { + return withoutNul(schema, new Set()) as Record; +} + +/** + * 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 — + * and nothing else about what the vendor wrote is altered to achieve it, which is a criterion of + * its own because the first attempt at this one failed it. See {@link storableSchema}. + * + * CRITERION THREE. Whatever this raises, its caller catches — see the `try` around the one call + * site. Everything here runs OUTSIDE the vendor `try` and before the transaction's own, so a throw + * from here left `refreshTools` unhandled: a bodiless 500, a `lastError` still holding whatever it + * held before, and an add aborted after its server row and its audit row had already committed. + * + * 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. + */ +export function storableTools(serverId: string, listed: ListedTool[]) { + const byName = new Map< + string, + { + serverId: string; + name: string; + description: string; + inputSchema: Record; + effect: "read" | "write" | null; + destructive: boolean; + version: string | null; + } + >(); + + for (const tool of listed) { + const name = tool.name.replaceAll(NUL, ""); + if (byName.has(name)) continue; + byName.set(name, { + serverId, + name, + /* + * Defaulted where the COLUMN has a default, because that is what the previous mapping leaned + * on: it passed these two straight through, so a transport handing back undefined got the + * `""` and `{}` the schema declares. Reading a method off the value instead would turn the + * same absence into a TypeError thrown from outside the vendor `try`. Both fields are + * required by `McpTool` and supplied by every transport here; this keeps the tolerance the + * insert already had rather than adding a new answer. + */ + description: (tool.description ?? "").replaceAll(NUL, ""), + /* + * By walking the parsed schema rather than its serialised text, because only one of those two + * can tell the character from the six letters that name it. See {@link storableSchema}: this + * used to strip the escape out of `JSON.stringify`'s output, which ate the tail of every + * legitimately-escaped backslash and left a string `JSON.parse` refused. + */ + inputSchema: storableSchema(tool.inputSchema ?? {}), + /* + * What the vendor said, when the vendor said anything. + * + * Only Composio publishes an effect and a version, and an MCP server publishes a + * destructive hint — see `mcp.ts`. All three stay null or false for a transport that says + * nothing, and `classifyTool` reads null as silence rather than as a value, which is what + * leaves Notion and Drive classified by their reviewed write list exactly as they were. + */ + effect: tool.effect ?? null, + destructive: tool.destructive ?? false, + version: tool.version?.replaceAll(NUL, "") ?? null, + }); + } + + return [...byName.values()]; +} + +export function isDeploymentFault(error: unknown): error is Error { + return ( + error instanceof ServerUnresolvableError || + error instanceof PluginInvariantError || + /* + * A query this database refused is on the shelf for the reason the other two are: it is not a + * vendor's doing, it is not the asker's to act on, and its message is the one thing here that + * must not travel. The replace in `refreshTools` was fixed at its own site; every other query + * on the call path — the advertised-tool read, the connection gate, the vault read, the locked + * credential swap — throws the same shape into a `catch` that copies `error.message` onward, + * so answering it here is what makes the four audiences agree without four more branches. + * + * Callers that SHOW the sentence to an operator must still ask {@link withoutStatement} for + * it rather than reading `.message`; this predicate settles who may be told, not what. + */ + isQueryFailure(error) + ); +} + +/** The operator-facing sentence for a fault on that shelf, with no statement in it. */ +export function deploymentFaultSentence(error: Error): string { + return withoutStatement(error); +} + +/** + * The vendor's `error` code, when a token endpoint refuses an exchange. + * + * {@link INVALID_CLIENT} is the one code this module ACTS on rather than reports, so it has to + * survive as a value. It used to travel inside the sentence, which meant the recovery in + * {@link createPluginStore}'s `refuseAndReplaceEvictedClient` hung on a substring of prose written for a + * person to read: rewording the sentence — translating it, dropping the parenthesis — would have + * turned self-registration off with every test still green. A field cannot be reworded by accident. + */ +export class TokenRefusedError extends McpServerError { + constructor( + message: string, + readonly code: string | null, + ) { + super(message); + this.name = "TokenRefusedError"; + } +} + +/** + * The vendor saying the CLIENT is the problem, rather than the grant. RFC 6749 §5.2. + * + * Told apart from every other refusal because it is the only one a deployment can do anything about + * on its own: a client it issued to itself, it can issue again. + */ +export const INVALID_CLIENT = "invalid_client"; + +/** + * A transaction, as the writes in this module hand one to each other. + * + * Named because two writes here are one decision — a secret in the vault, and the pointer that says + * what it is for — and the only way to say that is to run both on the same executor. `select`, + * `insert` and `update` alone would do for {@link CredentialExecutor}; this needs `execute` too, for + * the advisory lock that serialises the client path. + */ +export type Transaction = Parameters[0]>[0]; + +/** + * A tool name the model can actually call. + * + * `/` is how a grant is stored, because a slash reads correctly to a person and cannot + * appear in either half. Model tool names may not contain one, so the offered name uses `__`. + * Converting in one place, both ways, keeps the two spellings from drifting. + */ +export const toolNameFor = (ref: string) => `mcp__${ref.replace("/", "__")}`; + +export function refFromToolName(toolName: string): string | null { + if (!toolName.startsWith("mcp__")) return null; + const rest = toolName.slice("mcp__".length); + const separator = rest.indexOf("__"); + if (separator <= 0) return null; + return `${rest.slice(0, separator)}/${rest.slice(separator + 2)}`; +} + +/** + * Advertised tool names this deployment's write list does not name, where that list is the whole + * barrier. + * + * WHY THIS IS WORTH A ROW. {@link classifyTool} reads an advertised name absent from `writeTools` as + * a READ. So under-inclusion is the failure mode of that list, and it is silent: a write the vendor + * offers and the entry forgot is offered to a model as a read, and nothing anywhere says so. Notion's + * entry says reconciling its list against the live tool list "is required, not cosmetic" — this is + * the mechanical half of that, so the reconciliation is somebody reading a trail rather than somebody + * remembering. + * + * Only where the list stands alone. A vendor that expresses SCOPES has something behind the list: a + * tool missing from Drive's `writeTools` still cannot write, because `drive.readonly` refuses it at + * 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 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. + */ +export function unlistedAdvertisedTools( + entry: CatalogueEntry | null, + advertised: readonly string[], +): string[] { + if (!entry || entry.writeTools.length === 0) return []; + if (entry.auth.kind !== "user-oauth" || entry.auth.scopes.length > 0) { + return []; + } + const writes = new Set(entry.writeTools); + return advertised.filter((name) => !writes.has(name)).sort(); +} + +export 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. + * + * 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. + */ +export const DEPLOYMENT_ACTOR = "deployment"; +export const UNATTRIBUTED_ACTOR = "unattributed"; + +/** + * Whose account this call went out as, for the trail. + * + * 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. + * + * 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}. + */ +export const reachedAsFor = (access: ServerAccess, actorId: string): string => + access.reachedAs === "person" + ? actorId || UNATTRIBUTED_ACTOR + : DEPLOYMENT_ACTOR; + +/** + * Where this server actually is, when the stored row and the catalogue disagree. + * + * `mcp_servers.url` is written once, when a server is added, by copying what the catalogue said at + * the time. That makes it a cache of a reviewed decision — and a cache nothing invalidates. Moving + * Google Drive from its preview MCP host to its GA REST host changed the catalogue and left every + * deployment that had already added Drive calling the old address, with no way to tell from any + * screen: the row looks exactly as intentional as it did the day it was written. + * + * So for an entry with a PINNED host, the catalogue wins. It is the reviewed source contract, and a + * host it no longer names is a host this deployment has decided not to talk to. Editing the + * catalogue is the act of changing where a first-party server is, and it should take effect. + * + * The stored value still wins for the two cases where it is the only truth: a custom server an + * administrator added by URL, which has no entry at all, and a per-instance vendor whose `host` is + * null because the customer's own hostname is the answer. + */ +export function effectiveUrl( + row: { id: string; url: string }, + entry: CatalogueEntry | null, +): string { + if (!entry || entry.host === null) return row.url; + return resolveServerUrl(row.id)?.url ?? row.url; +} + +/** + * Trade a refresh token for a short-lived access token, at the vendor's own token endpoint. + * + * `tokenUrl` comes from the catalogue entry and never from a caller, for the same reason the MCP + * host does not: this request carries the deployment's client secret and somebody's refresh token, + * so where it goes is a reviewed decision rather than a runtime one. + * + * The vendor's error body is deliberately not passed through — it is written for whoever registered + * the client, not for the person who asked a Bot a question, and it can name the client id. Its + * `error` CODE is, though, and only that: `invalid_client` is what tells a client the vendor has + * forgotten apart from a grant somebody withdrew, and those two have entirely different answers. + * It goes out as a field on {@link TokenRefusedError} as well as in the sentence, because the field + * is the copy the recovery reads. + * + * Exported for its own tests rather than for a caller. Every path through the store reaches it as + * the default `exchangeRefreshToken`, and the store's own suites inject a stub in its place — which + * leaves what this function does with a REAL vendor reply, honest or garbled, untested unless it can + * be called directly. + */ +export async function exchangeRefreshTokenOverHttp(input: { + tokenUrl: string; + client: OAuthClient; + refreshToken: string; +}): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: input.refreshToken, + client_id: input.client.clientId, + }); + // A public (DCR) client proves itself without one, and some vendors refuse an unexpected empty + // field outright. The same guard the authorization-code redemption in `oauth.ts` uses. + if (input.client.clientSecret) { + params.set("client_secret", input.client.clientSecret); + } + + const response = await fetch(input.tokenUrl, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: params, + /* + * A redirect is a refusal, not a detour to be followed. + * + * `tokenUrl` is pinned in the catalogue because this request carries the deployment's client + * secret and somebody's refresh token, and following a 302 would hand both to whatever address + * the answer named. Manual leaves the 3xx as the response, which is not `ok`, so it falls into + * the refusal below. The same guard the authorization-code redemption in `oauth.ts` uses. + */ + redirect: "manual", + signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), + }); + + if (!response.ok) { + /* + * The code, when the refusal is JSON and carries one. Read defensively: a token endpoint that + * is refusing may be refusing with an HTML error page, and a parse failure here would replace + * the vendor's status — the one fact we do have — with a syntax error. + */ + const refusal = (await response.json().catch(() => null)) as { + error?: unknown; + } | null; + /* + * Capped where it is read, because everything downstream of here shows it to somebody: the + * person who asked, the model, the connector's `lastError` on the admin page, and an audit + * payload. It is a short token in the protocol and vendor-controlled in fact, and nothing on + * those paths is a promise about length. + */ + const code = + typeof refusal?.error === "string" ? refusal.error.slice(0, 64) : null; + throw new TokenRefusedError( + `The vendor would not renew this access (${response.status}).${code ? ` (${code})` : ""}`, + code, + ); + } + + /* + * A 200 is not a promise of JSON, and this branch used to read it as one. + * + * The refusal above already parses defensively; the success branch did not, so a CDN interstitial + * or a maintenance page answering 200 with HTML threw a SyntaxError from here — out through + * `callTool`, which records the failure with the thrower's message, and the parser's message + * quotes the body it choked on. So a vendor's HTML reached an audit payload and the person who + * asked, as a crash rather than as the refusal every other unusable reply produces. + */ + const body = (await response.json().catch(() => null)) as { + access_token?: unknown; + expires_in?: unknown; + refresh_token?: unknown; + } | null; + if (!body) { + throw new McpServerError( + "The vendor answered this renewal with something other than a token.", + ); + } + if (typeof body.access_token !== "string" || !body.access_token) { + throw new McpServerError("The vendor renewed this access with no token."); + } + return { + accessToken: body.access_token, + expiresInSeconds: + typeof body.expires_in === "number" ? body.expires_in : undefined, + /* + * Only when the vendor sent one, and only a non-empty one. + * + * A rotating vendor replies with a new refresh token and invalidates the one it was shown; a + * vendor that does not rotate sends none. Reading an absent or empty field as a rotation would + * repoint a working connection at nothing. + */ + refreshToken: + typeof body.refresh_token === "string" && body.refresh_token + ? body.refresh_token + : undefined, + }; +} + +/** How long a vendor's token endpoint gets. Shorter than a call: it is one round trip, or nothing. */ +export const TOKEN_TIMEOUT_MS = 10_000; + +/** + * The deployment's OAuth client for one vendor, as it is held in the vault. + * + * Both halves live in the encrypted value rather than the id sitting in `metadata` and the secret + * here. One read gets a usable client, which keeps {@link CredentialSecretReader} the only vault + * interface this module needs. The id is also copied into `metadata` for the credentials page to + * show — a deliberate duplication of something that is not a secret, so that a screen listing what + * the deployment holds does not have to decrypt anything to name it. + */ +export type OAuthClient = { clientId: string; clientSecret: string }; + +/** + * Whether a value read back out of the vault is a client this deployment can actually present. + * + * Guarding the parse is only half of the question. `JSON.parse` answers for SYNTAX, and the + * `as OAuthClient` cast behind it answers for nothing at all — so a row holding + * `{"client_id":"","client_secret":""}`, which is what a hand-repair or a half-written row leaves, + * parses cleanly and yields a client whose `clientId` is `undefined`. + * + * SHAPE AND SYNTAX ARE ONE CONCERN, which is why all three readers ask this beside their parse + * rather than only around it: either way the deployment holds a client it cannot use, and the + * operator's signal has to survive both. `unusableClient` is that signal, and it is deliberately + * distinct from `noClient`'s holding none. + * + * The shape half earns the check by ending WORSE than the syntax half beside it. An `undefined` + * client id is sent to the vendor, the vendor answers `invalid_client`, and + * {@link refuseAndReplaceEvictedClient} reads that as the vendor having disowned our registration — + * so a corrupt LOCAL row replaces the deployment-wide client every existing consent was granted + * against, and reports it as the vendor's doing rather than naming the credential that broke. + * + * The id has to be there; the secret only has to be a string. A public client registered + * dynamically proves itself with PKCE and is stored with an empty secret ON PURPOSE — + * `registerDynamicClient` checks the id exactly this way and defaults the secret to `""` — so + * demanding a non-empty secret here would refuse every self-registering entry in the catalogue. + */ +export function isUsableClient(value: unknown): value is OAuthClient { + if (typeof value !== "object" || value === null) return false; + const { clientId, clientSecret } = value as Partial; + return ( + typeof clientId === "string" && + clientId !== "" && + typeof clientSecret === "string" + ); +} + +/** + * The client and when the vault row holding it was written. + * + * The date is not about the client: it is how long ago this deployment last introduced itself, which + * is the one thing that distinguishes a client the vendor has evicted from one a re-registration + * minted moments ago. Null only for a row that has since disappeared, which the read refuses first. + */ +export type StoredClient = { client: OAuthClient; registeredAt: Date | null }; + +/** + * How long a freshly stored OAuth client is left alone after `invalid_client`. + * + * Re-registering once per refusal is right for one call and wrong for a deployment: a vendor that is + * simply down answers every exchange `invalid_client`, and every tool call anywhere then mints a + * client of its own, because each of them is the first refusal IT has seen. A client younger than + * this was already the product of a re-registration, so registering again inside the window is + * amplification rather than recovery — the honest answer is the vendor's refusal, unedited. + */ +export 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. + */ +export 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; + expiresInSeconds?: number; + /** + * The refresh token to present next time, from a vendor that rotates. + * + * Absent from Google's replies and present in every one of Notion's. When it is here it is the + * only one that still works — the token just spent is dead at the vendor — so it has to be + * persisted before the access token beside it is used for anything. + */ + refreshToken?: string; +}; + +export type PluginStoreOptions = { + approvalGate?: ApprovalGate; + /** + * The owner's permission before a connector call sends content to other people. + * See plugins/share-target.ts and proactive/private-share.ts. Absent asks nothing. + */ + privateShareCheck?: CheckPrivateShare; + /** Who may steer a run that uses a Shared app's account. Absent, every shared call is refused. */ + sharedUse?: SharedUseGate; + database: Database; + auditStore: AuditStore; + /** + * The vault, read and write. + * + * Writing is here rather than left to the browser posting `/api/admin/credentials` first. An OAuth + * client belongs to the server registration and a refresh token belongs to a connection, so both + * are written by the code that owns those acts — otherwise the first of two calls can succeed and + * the second fail, leaving a secret in the vault that nothing points at and nobody knows to revoke. + * + * `revoke` is part of it because a key holds at most one live credential now. `removeServer` + * retires the server's token, and the two write paths here replace rather than add, so re-adding a + * server or re-authorizing a connection does not meet its own leftover on + * `credentials_active_key_idx`. + */ + credentials: CredentialSecretReader & CredentialStore; + encryptionKey: string; + /** Read at call time, never captured, so a policy changed a moment ago applies to this call. */ + policy: () => ActionPolicy; + /** + * Speaking MCP to the vendor. Defaults to the real client. + * + * Injected so a test can assert what a call was about to go out with. Whose credential is chosen + * is the security property of this module, and asserting it otherwise needs a vendor to be + * reachable, which means the property most worth testing would be the one thing never tested. + */ + callVendor?: ( + connection: { + url: string; + token?: string; + actorId?: string; + botId?: string; + }, + toolName: string, + args: Record, + ) => Promise<{ text: string; isError: boolean }>; + /** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */ + exchangeRefreshToken?: (input: { + tokenUrl: string; + client: OAuthClient; + refreshToken: string; + }) => Promise; + /** RFC 7591 self-registration, for entries whose clientRegistration is dynamic. */ + registerClient?: (input: { + registrationUrl: string; + redirectUri: string; + }) => Promise; + /** + * Composio the broker, absent on a deployment that has not configured one. + * + * OPTIONAL BECAUSE ITS ABSENCE IS A STATE RATHER THAN A MISCONFIGURATION. An unset + * `COMPOSIO_API_KEY` is the documented default: where it is unset there is nothing to connect, + * nothing to grant and no brokered tool for a Bot to call, and what is left on screen is one row + * that goes nowhere under More apps on the admin Plugins page. So the store is constructible + * without one and every path that needs one says so by raising {@link BrokerUnconfiguredError}. + * A required field would make every caller that never enables an app — the routes, the tests + * above — invent a broker to get a store. + */ + broker?: ComposioBroker; + /** Where the vendor sends people back; needed to (re)register a dynamic client. */ + redirectUri?: string; + /** + * What this deployment calls itself (`DEPLOYMENT_ID`, else the tenant package id). The prefix of + * a shared account's vendor identity, read only when an app is switched to Shared. + */ + deploymentId?: string; +}; + +/** + * The literal recorded on the row, which is the scheme a later call must keep using. + * + * TYPED AS {@link RecordedScheme} RATHER THAN `string`, so that what this writes and what + * {@link schemeKind} recognises are held to one set by the compiler. A literal spelled here that + * the reader does not know would be read as `unreadable` — a key app that connects nobody, or a + * consent app no confirm can verify — and nothing but this annotation would say so. + */ +export function schemeFor(connection: BrokerConnection): RecordedScheme | 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; + } +} + +/** + * Optional arguments the model filled in with an empty string, removed. + * + * A model handed a schema with many optional fields tends to fill them all, and where it has no + * value it writes "". Vendors reject that: an empty string is not a channel id, not a timestamp and + * not a cursor, so the call fails with a validation error that reads to the person as the tool being + * broken. + * + * Only optional fields, and only empty strings. A required field left empty is the model getting it + * wrong, and the vendor should say so rather than have us hide it. Anything other than "" is a value + * the model meant, including false and 0. + */ +export function withoutEmptyOptionals( + args: Record, + schema: Record | undefined, +): Record { + const required = new Set( + Array.isArray(schema?.required) ? (schema.required as string[]) : [], + ); + return Object.fromEntries( + Object.entries(args).filter( + ([key, value]) => required.has(key) || value !== "", + ), + ); +} diff --git a/server/src/plugins/plugin-store/servers.ts b/server/src/plugins/plugin-store/servers.ts new file mode 100644 index 000000000..cdb2f4a7a --- /dev/null +++ b/server/src/plugins/plugin-store/servers.ts @@ -0,0 +1,1907 @@ +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { recordAuditEvent } from "../../audit"; +import { + databaseComplaint, + isQueryFailure, + reasonWithoutStatement, + withoutStatement, +} from "../../db/query-failure"; +import { + brokeredConnections, + credentials as credentialRows, + mcpServers, + mcpTools, + pluginGrants, +} from "../../db/schema"; +import { accessFor } from "../access"; +import { + type BrokerConnection, + BrokerUnconfiguredError, + type Decides, + schemeKind, +} from "../broker"; +import { + catalogueEntry, + classifyTool, + customUrlRefusal, + resolveServerUrl, + serverCredentialKind, +} from "../catalogue"; +import { toolkitOf } from "../composio"; +import type { ListedTool } from "../mcp"; +import type { AccountMode, AccountRef } from "../shared-accounts"; +import { transportFor } from "../transport"; +import type { StoreContext } from "./context"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, + DEPLOYMENT_ACTOR, + effectiveUrl, + isDeploymentFault, + iso, + PluginInvariantError, + type ServerAddress, + type ServerRecord, + schemeFor, + storableTools, + unlistedAdvertisedTools, +} from "./model"; + +/** Adding, removing, refreshing and listing MCP servers and brokered apps, and an app's account mode. */ +export function serverMethods(context: StoreContext) { + const { + database, + auditStore, + credentials, + broker, + mcpGrantsForServers, + accountRefFor, + connectionTokenFor, + requireCredentialOfKind, + requireNotBrokered, + requireServer, + brokeredAppRow, + brokeredAppRowsAt, + brokeredAppScheme, + } = context; + return { + accountRefFor, + /** + * Add a server from the catalogue. + * + * The URL is resolved from the catalogue rather than accepted from the caller, so the only thing + * a person can influence is which entry and, for a per-instance vendor, their own instance + * hostname, which is then checked against that vendor's anchored pattern before anything is + * stored. + */ + async addServer(input: { + key: string; + instanceHost?: string; + credentialId?: string; + by: string; + }): Promise { + const resolved = resolveServerUrl(input.key, input.instanceHost); + if (!resolved) throw new CatalogueEntryUnknownError(input.key); + + /* + * The pointer is checked here for the same reason it is on the path below: the refresh that + * runs before this returns dereferences whatever it names. + * + * What that reaches is narrower on this path, because the URL is the catalogue's rather than + * the caller's, so a credential cannot be delivered to an address somebody chose. That is a + * property of today's catalogue rather than of this function: the one entry it holds is + * `user-oauth`, and the catalogue's own comment invites a fork to re-add the vendors that were + * taken out. The first `deployment-bearer` entry restores the full shape, so the check belongs + * here now rather than in the review that re-adds one. + */ + const credentialId = input.credentialId?.trim() || undefined; + if (credentialId) { + await requireCredentialOfKind( + resolved.entry.title, + resolved.entry.key, + credentialId, + serverCredentialKind(resolved.entry), + ); + } + + /* + * Not over a brokered row. See {@link requireNotBrokered} for what such a row would lose. + * + * Unreachable from the shipped product as it stands — `addBrokeredApp` mints `composio-` + * and no catalogue entry is spelled that way — and asked anyway, because the row the check is + * about is by definition one that arrived some other way: a hand edit, a restore, a build + * whose catalogue named an app a past build brokered. This path writes the catalogue's url + * over whatever is there, which is exactly the write that strands the accounts. + */ + const [existing] = await database + .select({ + provenance: mcpServers.provenance, + url: mcpServers.url, + // Part of what `accessFor` resolves a row with — see there. Nothing on this path reads + // the answer's `reachedAs`, but the column travels with the other two so no caller has + // to know which of the four answers the one it wants depends on. + authScheme: mcpServers.authScheme, + }) + .from(mcpServers) + .where(eq(mcpServers.id, resolved.entry.key)); + requireNotBrokered(resolved.entry.key, existing); + + await database + .insert(mcpServers) + .values({ + id: resolved.entry.key, + title: resolved.entry.title, + vendor: resolved.entry.vendor, + url: resolved.url, + credentialId: credentialId ?? null, + addedBy: input.by, + }) + .onConflictDoUpdate({ + target: mcpServers.id, + set: { + url: resolved.url, + /* + * THE WHOLE IDENTITY THE CATALOGUE DECIDES, not the url alone. + * + * These three columns and the url are one statement — this row is that reviewed entry — + * and an update that moved one of them and left the rest was a row describing two + * different servers at once. `provenance` is read by `requireServer`, which refuses a + * `first-party` row with no entry, and by `accessFor`, which reads it whenever there is + * no entry to overrule it; `vendor` is what the first-party rule is checked against. + * Left behind, a row that arrived by another path kept saying so at an address only the + * catalogue chose — so every surface that asks how a server got here answered with the + * way it USED to get here. + * + * The entry wins over the row everywhere else too (see `accessFor`), so this is that + * same order written down at the one moment the row is being established rather than + * read. + */ + title: resolved.entry.title, + vendor: resolved.entry.vendor, + provenance: "first-party", + /* + * Left alone when the caller sends none, rather than cleared. + * + * `registerOAuthClient` keeps the client it minted in this column, and adding the server + * again to change an instance host is not a statement about that client. Clearing it + * orphaned the credential row, which nothing then revokes, and told everybody who had + * connected that the deployment has no OAuth client registered. There is no longer a way + * to hand it back through this call either, since a `user-oauth` entry now refuses a + * credential id, so the pointer has to survive here. + */ + ...(credentialId ? { credentialId } : {}), + addedBy: input.by, + updatedAt: new Date(), + }, + }); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: resolved.entry.key, + payload: { + actor: input.by, + change: "mcp_server_added", + server: resolved.entry.key, + url: resolved.url, + }, + }); + + // Refreshed immediately so the page that added it can show what it offers, and so a bad + // credential is reported now rather than the first time a Bot tries to use it. + await this.refreshTools(resolved.entry.key); + const servers = await this.listServers(); + const added = servers.find((server) => server.id === resolved.entry.key); + if (!added) throw new CatalogueEntryUnknownError(input.key); + return added; + }, + + /** + * Add a server that is not in the catalogue, by URL. + * + * The administrator's path is different from pressing Add on a curated entry. That + * one picks a reviewed vendor at a pinned host; this one points the deployment at an address + * somebody typed. Both are useful and only one of them can be reviewed in advance, so this one + * is guarded at the URL, recorded with its provenance, and every tool it offers is treated as a + * write because nothing here knows otherwise. + */ + async addCustomServer(input: { + id: string; + title: string; + url: string; + credentialId?: string; + by: string; + }): Promise { + const refusal = customUrlRefusal(input.url); + if (refusal) throw new CustomServerRefusedError(refusal); + + // A custom server may not take a curated entry's slug. The slug prefixes tool names and is + // what a grant and a policy rule are written against, so allowing a shadow would let a custom + // server inherit rules an operator wrote about the vendor. + if (catalogueEntry(input.id)) { + throw new CustomServerRefusedError( + `${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.", + ); + } + + /* + * NOR THE NAMESPACE THE BROKERED ROWS ARE MINTED IN, which is the reservation above one step + * further out. + * + * CRITERION. No server added by URL may take an id beginning `composio-`, whether or not a row + * is sitting there today. + * + * REASON. {@link addBrokeredApp} composes its id as `composio-` from the app an + * administrator enabled, so that space is already spoken for by a path that writes + * `composio://` urls into it. Unreserved, the two paths write one row: enabling an app over a + * typed endpoint, or typing an endpoint over an app somebody is connected to. The second of + * those is the one that cannot be undone — see {@link requireNotBrokered} — and this is what + * stops either from arising in the first place, rather than catching them one row at a time. + * + * A PREFIX RATHER THAN A LOOKUP OF WHAT IS ENABLED TODAY. "Is there a brokered row at this id" + * is a question whose answer changes: an app removed this morning frees the name, and the next + * press of Add takes it back from whoever typed it in between. The namespace is what a reader + * of a grant or a policy rule can rely on without asking the database what year it is. + * + * It is the same objection the curated-slug refusal above makes, and the same one + * `addBrokeredApp` records for its own id: the id prefixes every tool name and is what a grant + * and a policy rule are written against, so a row shadowing another path's namespace inherits + * rules that were written about something else. + */ + if (input.id.startsWith("composio-")) { + throw new CustomServerRefusedError( + `Names beginning composio- belong to the Composio apps this deployment enables, so ${input.id} is not a name a server added by URL can take. 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.", + ); + } + + /* + * The pointer is checked here because the add is what dereferences it. + * + * `refreshTools` runs before this method returns, and for a custom server there is no + * catalogue entry, so `connectionTokenFor` decrypts whatever `credential_id` names and + * `listTools` sends it to the URL from this same request. An unchecked pointer therefore is + * not "a wrong token later", it is this call delivering that secret to an address the caller + * chose, before any grant, policy check or Bot exists. + * + * `mcp` is the only kind that answers "this server's own token". A `mcp_user_token` is one + * person's grant and a `mcp_oauth_client` identifies the deployment to a vendor; neither is + * this deployment's bearer token for this server, and spending either here would be using a + * credential on behalf of somebody who never agreed to it. `POST /api/admin/credentials` + * already refuses to mint those two by hand for that reason, and its comment says so; this is + * the same objection at the point they are referenced rather than created. + * + * One message for both "wrong kind" and "no such credential", deliberately. A caller who can + * tell those apart can ask this endpoint which credential ids are real. + */ + /* + * A credential is spent at the address it was given to, or not spent. + * + * Adding a server that is already here rewrites its URL, and the refresh that follows sends + * whatever credential it holds to the new one, in the same call. That is the same disclosure + * as naming another server's token and it needs no trick at all: the token really does belong + * to this server, and only the address moved. A check on whose credential it is cannot see it, + * which is why this rule is here and not folded into that one. + * + * Refused rather than repaired, because the two harmless readings of the request are both + * served by something else. Correcting a title or retrying an interrupted add sends the same + * URL and is unaffected, and genuinely moving a server means the vendor is at a new address, + * where the honest act is to remove it and add it again with the token that address is + * supposed to hold. + * + * Only this path. A curated server's URL comes from the catalogue rather than the request, so + * the most a caller can influence is an instance hostname, and that is matched against the + * vendor's own anchored pattern before anything is stored. Re-adding one cannot point it at an + * address of the caller's choosing, which is the whole of what this refuses. + */ + const credentialId = input.credentialId?.trim() || undefined; + const [existing] = await database + .select({ + url: mcpServers.url, + credentialId: mcpServers.credentialId, + // Read for the refusal below. The namespace rule above already keeps this path away from + // every id `addBrokeredApp` mints; this is the same rule asked of the ROW, which is what + // covers one that arrived before the namespace was reserved, or by restore. + provenance: mcpServers.provenance, + // The third of what `accessFor` resolves a row with. See there. + authScheme: mcpServers.authScheme, + }) + .from(mcpServers) + .where(eq(mcpServers.id, input.id)); + + /* + * Before the address rule below, because it is the stronger statement about the same write. + * + * That one is about a credential being carried to an address, and it lets an add through when + * no token is involved. This one is about the address ITSELF being the only record of which + * app a set of connections belongs to, and no absence of a credential makes that write + * survivable. See {@link requireNotBrokered}. + */ + requireNotBrokered(input.id, existing); + + if ( + existing && + existing.url !== input.url && + (existing.credentialId || credentialId) + ) { + throw new CustomServerRefusedError( + `${input.id} is already here at a different address and holds a credential. Remove it and add it again, with the token the new address is meant to have.`, + ); + } + + if (credentialId) { + // Always `mcp`: a server added by URL is reached with the one token the deployment holds for + // it, whatever the vendor is, because nothing here knows the vendor. + await requireCredentialOfKind( + input.title, + input.id, + credentialId, + "mcp", + ); + } + + await database + .insert(mcpServers) + .values({ + id: input.id, + title: input.title, + vendor: new URL(input.url).hostname, + url: input.url, + provenance: "custom", + credentialId: credentialId ?? null, + addedBy: input.by, + }) + .onConflictDoUpdate({ + target: mcpServers.id, + set: { + title: input.title, + url: input.url, + /* + * WHAT THE ROW NOW IS, written beside the address that made it that. + * + * `vendor` is derived from the url on the way in, so leaving it behind while the url + * moved left the column naming a host this row no longer addresses — and it is what the + * first-party rule is checked against, not a caption. `provenance` is the same fact one + * level up: a row whose entry a build removed still reads `first-party`, and + * `requireServer` refuses exactly that shape as a vendor it can no longer check a pinned + * host for. Adding it by URL is what makes it a typed address rather than a reviewed + * one, so this is the act that settles the column. + * + * The one conversion this cannot make is out of a brokered row, which is refused above + * rather than written here: there the url is the only record of which app a set of + * consents belongs to, so a rewrite loses them whatever else is written alongside. + */ + vendor: new URL(input.url).hostname, + provenance: "custom", + /* + * Kept when the caller names none, rather than cleared, for a reason beyond tidiness. + * + * Clearing it left the credential live with nothing pointing at it, and `removeServer` + * retires a token by reading it off the row: with the pointer gone it revoked nothing + * and deleted the server, so the token outlived the server it was minted for. It could + * then be attached to a freshly created server at any address, because the rule above + * compares against a row that no longer existed. Three ordinary acts, and the address + * this server was entrusted to stopped meaning anything. + * + * So the pointer survives, `removeServer` finds it, and a removed server's token is + * dead rather than loose. Detaching a token without removing the server is not a thing + * this endpoint does, and nothing asks it to. + */ + ...(credentialId ? { credentialId } : {}), + addedBy: input.by, + updatedAt: new Date(), + }, + }); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: input.id, + payload: { + actor: input.by, + change: "mcp_server_added", + server: input.id, + url: input.url, + // Named in the trail, because "who added a server nobody reviewed" is a question somebody + // will ask and the answer should not require reading the catalogue of a past build. + provenance: "custom", + }, + }); + + await this.refreshTools(input.id); + const added = (await this.listServers()).find( + (server) => server.id === input.id, + ); + if (!added) throw new CatalogueEntryUnknownError(input.id); + 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; + logo?: string | null; + by: string; + /** + * How this app connects, resolved from the catalogue row the administrator chose. + * + * Taken rather than derived here, because the caller has already read it off that row and a + * second derivation is a second answer — the one thing {@link BrokerConnection} exists to + * prevent. It decides what config is created at the vendor, and it is what this row records. + */ + connection: BrokerConnection; + }): Promise { + // Before anything at all. A deployment with no key has no catalogue for this app to have been + // chosen from, so there is nothing here to half-do and nothing to say but the setting. + if (!broker) throw new BrokerUnconfiguredError(); + + const url = `composio://${input.slug}`; + if (toolkitOf(url) !== input.slug) { + throw new CustomServerRefusedError( + `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, + ); + } + + /* + * WHAT THE ROW ALREADY SAYS, READ BEFORE ANYTHING IS WRITTEN, because after the upsert this + * question cannot be asked any more — the insert below would have answered it. + * + * It is the scheme the app's ANSWERING row records, which is what every brokered reader + * resolves and is not always the row the upsert names. See {@link brokeredAppRow}. + */ + const recorded = await brokeredAppScheme(input.slug); + + const configured = await broker.ensureAuthConfig({ + toolkit: input.slug, + name: input.title, + connection: input.connection, + }); + + /* + * THE SCHEME THIS ENABLE MAY WRITE DOWN, WHICH IS NOT ALWAYS THE ONE THE CATALOGUE RESOLVES. + * + * CRITERION. What this row records is what the authorization config standing at Composio was + * created as — never what today's catalogue says the app could be connected with. + * + * REASON. {@link ComposioBroker.ensureAuthConfig} reuses a config of ours whatever scheme it + * holds, and it used to say nothing about having done so, so every path here wrote the + * catalogue's answer. An app enabled while Composio published only a key, and later given + * managed OAuth, came out of a second press of Add recording `OAUTH2` beside a config that is + * still `API_KEY`: `brokeredAppKind` then says `consent`, so `connectBrokeredWithFields` + * refuses every submission while `authorize` mints consent links against a key config — the + * exact "sent to a consent screen that had nothing to ask them for" failure that method's own + * comment describes. And pressing Add again could never repair it, because the reuse is what + * caused it. + * + * `standing` IS THEREFORE THE ONE ANSWER THAT KEEPS WHAT WAS THERE, and it falls back to the + * catalogue only where nothing was recorded at all — a row restored without the column, or an + * app whose config was made before this deployment recorded one. A guess is the best available + * answer there and it is no worse than what stood before; everywhere else the recorded word + * wins. + * + * AND `created` MAY WRITE FREELY, connections or no connections: the config those connections + * were made against is gone from Composio — that is why one had to be created — so the new + * scheme is the only true thing to record about the app. + */ + const scheme = + configured === "standing" + ? (recorded ?? schemeFor(input.connection)) + : schemeFor(input.connection); + + /* + * THE COMPOSED NAME IS SPELLED WHERE IT IS MINTED AND IS GIVEN NO BINDING TO BE REUSED FROM. + * + * It used to be `const id`, and everything below this statement then had a plausible-looking + * server id to reach for — which is how four review rounds each moved one site onto the + * resolved row and left the next one standing. See {@link _EnableNamesTheRow}: after this + * statement there is exactly one id in scope, and it is the answering row's. + */ + await database + .insert(mcpServers) + .values({ + id: `composio-${input.slug}`, + title: input.title, + logo: input.logo ?? null, + // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule + // is checked against, and Composio is who this deployment is actually talking to. + vendor: "Composio", + url, + provenance: "composio", + // Nothing for the vault to hold. A brokered call runs as the person asking, on their own + // connection at the vendor, which is a `composio_connections` row rather than a secret. + credentialId: null, + /* + * What the config standing at Composio was created AS, which is the scheme every later + * connection against it has to keep using. + * + * THE RESOLVED ONE AND NOT THE CATALOGUE'S, WHICH THIS BRANCH USED TO WRITE UNGUARDED. + * The write-once rule below protects only the UPDATE, and this INSERT fires exactly when + * no row stands at the composed id — which is not the same as no row standing for the + * APP. In the two-rows-at-one-url state this method is built around, a restored or + * hand-added `gmail` at `composio://gmail` holding `API_KEY` and live key connections is + * untouched by the guard: the insert at `composio-gmail` fired, wrote `OAUTH2`, and + * `composio-gmail` sorts first, so `brokeredAppRowsAt` made the new row the answering + * one. `confirmBrokeredConnection` then classified the app `consent`, saw `verified: + * false` on a key row, and wrote `verified: true, probeAction: null` over it — erasing a + * recorded failed check on the next page load. See the derivation of `scheme` above. + */ + authScheme: scheme, + /* + * PERSONAL ON A NEW ROW, AND NULL WHERE THERE IS NO ACCOUNT TO HOLD. + * + * A freshly enabled app is reached through each asking person's own account until an + * administrator deliberately switches it to Shared — sharing one account across everyone + * a Bot reaches is a decision somebody makes, never a default this method makes for them. + * A `no-auth` app has no account at all, so it has no mode to choose between, and the + * column says so rather than claiming one; see {@link mcpServers.accountMode}. + * + * WRITTEN ON THE INSERT ONLY. The update below is the branch a second press of Add takes, + * and an app an administrator has already made Shared must not be quietly turned back + * into Personal by it. + */ + accountMode: input.connection.kind === "no-auth" ? null : "personal", + addedBy: input.by, + }) + .onConflictDoUpdate({ + target: mcpServers.id, + set: { + title: input.title, + logo: input.logo ?? null, + url, + /* + * WRITTEN BESIDE THE URL, BECAUSE THE TWO ARE ONE FACT AND A ROW HOLDING HALF OF IT IS + * NOT A SERVER AT ALL. + * + * CRITERION. Every row this method leaves behind says `composio`, whatever it said + * before. The url above and this column are what `accessFor` reads to answer that a call + * is brokered and which app it is against, and no path may write one without the other. + * + * REASON. This branch rewrote the url and left `provenance` standing, so enabling an app + * at an id a custom server already held produced a row reading `custom` at a + * `composio://` address. That is not a display inconsistency: `accessFor` answers + * `deployment-token` for it, so the transport dialled the row as an ordinary MCP + * endpoint on the deployment's own credential while the Composio screen went on + * attaching people's real accounts to the app its url named. The per-person gate was not + * weakened but SKIPPED — `connectionTokenFor` only ever asks for a connection down the + * brokered branch — which is the whole property this connector exists for. And + * `removeServer` finds the accounts to end at the vendor through that same answer, so it + * revoked nothing and reported the connector gone. + * + * A CONVERSION HERE, A REFUSAL IN THE OTHER DIRECTION, and the asymmetry is the point. + * What this method writes IS the app — the auth config stands at Composio before the row + * is touched, and the url names the app the connections will be keyed on — so a row it + * lands on comes out saying exactly what it now is, with nothing lost. Going the other + * way, the url being overwritten is the only record of which app a set of consents + * belongs to, and no column written alongside brings it back; see + * {@link requireNotBrokered}. + * + * `vendor` for the same reason one notch quieter: it is what the first-party rule is + * checked against, and a row reached over the broker whose vendor column still names the + * host somebody typed is answering that rule about a server this deployment no longer + * dials. + */ + provenance: "composio", + vendor: "Composio", + addedBy: input.by, + updatedAt: new Date(), + /* + * `credential_id` is neither written here nor cleared here. + * + * Every row this method creates has none and no path in this module attaches one, so + * there is nothing for an enable to set. Clearing it anyway would matter in the single + * case it could apply — a pointer that arrived by hand edit or restore — because + * `removeServer` retires a server's secret by reading it off this column, and a null + * written over it leaves that secret live with nothing left to name it. + */ + /* + * `auth_scheme` is not written here either, and for a neighbouring reason. + * + * This is the branch a second press of Add takes, and the scheme is the one thing on + * the row that live connections depend on rather than merely display. Rewriting it + * here would move them; the statement below rewrites it only where there are none. + */ + }, + }); + + /* + * WHICH ROW THIS DEPLOYMENT WILL ANSWER FOR THE APP WITH, RESOLVED ONCE AND FOR EVERYTHING + * BELOW. + * + * CRITERION. After the upsert, every act of this method — the scheme it records, the actions + * it lists, the row it files on the trail and the record it hands its caller back — is about + * the row {@link brokeredAppRow} names for this app. The composed `composio-` is what + * the statement above MINTS and is a stand-in for nothing. + * + * REASON. `mcp_servers.url` has no unique index, deliberately — see {@link brokeredAppRow} — + * so the row this method names and the row every brokered reader resolves are allowed to be + * different rows, and in the ordinary two-rows-at-one-url state they are. Every act keyed on + * the composed id was then an act performed somewhere nothing looks. FOUR ROUNDS OF REVIEW + * EACH MOVED ONE OF THEM AND LEFT THE NEXT STANDING: the probe was resolved by url, then + * `brokeredAppRow` was introduced and the probe and the confirm routed through it, then the + * scheme WRITE was moved here — and the actions went on being listed under the composed name + * the whole time. That last one is the deadlock the single-row rule exists to close: the + * chooser reads actions by server id, finds none on the answering row, so `checkable` is + * false on every page load, the browser draws no Re-check button, and the one press that + * could earn the app a verdict can never be made. + * + * THE ROW IS RE-READ RATHER THAN ASSUMED, because the upsert may have created it, found it, + * or landed beside an older row that sorts first — and which of those happened is exactly what + * decides the answer. The composed name is respelled as the fallback for the unreachable case + * of a row this method has just written not being found at its own url, which would mean the + * insert above and this read disagree about what was stored; it is respelled rather than held + * in a binding so that nothing below can take it by mistake. + */ + const answering = + (await brokeredAppRow(input.slug))?.id ?? `composio-${input.slug}`; + + /** + * WHICH ROW EACH ACT OF AN ENABLE NAMES, WRITTEN DOWN BECAUSE NOTHING ELSE CAN SAY IT. + * + * Type-only and erased; see {@link Decides}. Every other roster in this file is keyed on a + * vocabulary the compiler already knows — {@link SchemeKind}, {@link BrokeredProbe} — and + * this one is keyed on the acts of a single method, because the drift being pinned is not a + * branch that forgot a member but a SITE that reached for the wrong name. There is no type + * whose inhabitants are "the things an enable does", so the checklist is the union and the + * roster is what forces an answer out of it: a sixth act added to `BrokeredEnableAct` fails + * `tsc` here until somebody says which row it is about, and the two legal answers are a closed + * pair rather than free text so the seam cannot drift in the direction it has drifted four + * times. + * + * `minted` IS THE ANSWER FOR EXACTLY ONE ACT, and that is the whole shape of the rule. The + * upsert has to be keyed on the composed name — it is what converts a row an administrator + * typed at that id into the brokered app it now is, which is a property with its own test — + * and every act after it is about the row the readers read. + */ + type BrokeredEnableAct = + | "upsert" + | "scheme" + | "actions" + | "trail" + | "answer"; + type _EnableNamesTheRow = Decides< + BrokeredEnableAct, + { + upsert: "minted"; + scheme: "answering"; + actions: "answering"; + trail: "answering"; + answer: "answering"; + }, + "minted" | "answering" + >; + + /* + * WRITTEN EXACTLY WHERE A CONFIG WAS MADE TO WRITE IT ABOUT, AND NEVER OVER A STANDING ONE. + * + * 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. + * + * THE GUARD WAS "NOBODY HAS CONNECTED YET" ALONE, AND THAT HALF CANNOT SETTLE IT. It reasoned + * that with no connections there is nothing to strand, so the rewrite is safe and is how an + * operator picks up a vendor's change without removing and re-adding the app. The second + * clause was false, for one reason: {@link ComposioBroker.ensureAuthConfig} REUSES a config of + * ours whatever scheme it holds and creates nothing, so a press of Add does not pick the + * vendor's change up — it records a word beside a config that never moved. An app enabled as + * `API_KEY` and given managed OAuth by the vendor came out of that press recording `OAUTH2` + * against a key config, which makes `connectBrokeredWithFields` refuse every submission while + * `authorize` mints consent links that ask for nothing — and pressing Add again can never + * repair it, because the reuse is the cause. + * + * SO BOTH CLAUSES STAND, AND EACH CLOSES WHAT THE OTHER CANNOT. Nothing connected means no + * connection is moved by the write; a config this call actually CREATED means there is a + * config that was made as the word being written. `standing` writes nothing whatever the + * connection count, and remove-and-re-add is then the one act that genuinely changes an app's + * scheme, because it is the one that deletes the config. + * + * AND IT IS WRITTEN ON THE ROW THAT ANSWERS FOR THE APP, WHICH IS NOT ALWAYS THE ONE THE + * UPSERT NAMED. + * + * CRITERION. After this call, the scheme {@link brokeredAppScheme} answers with for the app is + * the scheme the authorization config standing at Composio was created as. + * + * REASON. The statement was keyed on the composed `composio-` while every reader of this + * column finds the app by its URL. Where those are two rows the write and the reads were about + * different rows: an app enabled with a key that every reader calls a consent app. What the + * person then meets is the connect form refusing them in a sentence about a sign-in screen + * that does not exist for this app, and a Re-check button that will not press. A writer keyed + * on a composed id has not recorded the fact; it has recorded it somewhere nothing looks. + */ + // Anyone at all, of either holder: a deployment's shared account was made against the + // standing config exactly as a person's was, and a rewrite would strand it the same way. + const connections = await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, input.slug), + ), + ) + .limit(1); + + if (configured !== "standing" && connections.length === 0) { + /* + * MODE PICKED FOR THE PERSON ONLY WHERE THIS REWRITE IS WHAT GIVES THE APP AN ACCOUNT AT + * ALL, AND NEVER WHERE ONE WAS ALREADY CHOSEN. A row enabled `no-auth` has `accountMode` + * null — there was nothing to hold a mode about, see the insert above — and if this same + * re-add is what turns it into a key or consent app, it needs the ordinary default an + * insert would have given it, or every later read that asks "personal or shared" finds + * null and has no answer. An administrator who already chose Shared keeps that choice. + */ + const [current] = await database + .select({ accountMode: mcpServers.accountMode }) + .from(mcpServers) + .where(eq(mcpServers.id, answering)) + .limit(1); + const bumpAccountMode = + schemeKind(recorded) === "none" && + schemeKind(scheme) !== "none" && + (current?.accountMode ?? null) === null; + + await database + .update(mcpServers) + .set({ + authScheme: scheme, + updatedAt: new Date(), + ...(bumpAccountMode ? { accountMode: "personal" as const } : {}), + }) + .where(eq(mcpServers.id, answering)); + } + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + /* + * THE ROW A READER WOULD GO AND LOOK AT, which is the one this deployment answers for the + * app with and not the one the upsert happened to key on. Where those differ the composed + * row exists too, so a trail naming it would send somebody to a row whose scheme, whose + * actions and whose Re-check button are all somewhere else — and `url` below names the app + * itself, so nothing about which app was enabled is lost by naming the answering row here. + */ + targetId: answering, + payload: { + actor: input.by, + change: "mcp_server_added", + server: answering, + 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. + * + * ONTO THE ANSWERING ROW, which is the site this whole block is about. `mcp_tools.server_id` + * is what {@link probeActionFor} reads actions by and what {@link listServers} joins them on, + * and every brokered caller hands those the id resolved above — so actions written under the + * composed name are actions nothing in the brokered path can see. + */ + await this.refreshTools(answering); + const added = (await this.listServers()).find( + (server) => server.id === answering, + ); + if (!added) throw new CatalogueEntryUnknownError(answering); + return added; + }, + + /** + * Remove a server, and stop every secret it was reached with being live. + * + * TWO KINDS OF SECRET, and both have to go. The server's own credential is whatever + * `mcp_servers.credential_id` names — a `mcp` bearer token an administrator added, or, for a + * `user-oauth` vendor, the deployment's OAuth client, keyed `oauth-client-`. Nothing + * else revokes it, so leaving it behind means re-adding the same server meets its own abandoned + * row on `credentials_active_key_idx`. + * + * The other kind is every PERSON'S grant for this server, keyed `mcp_user_token` on the server id. + * `mcp_user_credentials` cascades on the server row, so removing the connector used to delete + * every pointer and leave every refresh token live and unreferenced: reachable from no screen, + * revoked by no operation, and still a usable grant at the vendor. "We removed the connector" has + * to be true of the thing that matters, which is the token sitting at the vendor. + * + * Revoked rather than deleted, because the vault keeps revoked rows for audit. + * + * A THIRD KIND OF ACCESS THAT IS NOT A SECRET. A brokered app holds no per-person secret at all + * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing + * between a person and their mailbox is a `composio_connections` row, and that table references + * nothing that would cascade it. Removing the app therefore left every one of them behind, and + * adding the app back turned them live again without anybody being asked. That row goes too — + * and before it goes, the account it stands for is ended at Composio, because clearing the row + * alone shuts a gate and leaves the mailbox attached. The deployment's auth config for the app + * goes last, once nobody is connected to it any more. + * + * The revokes go first. These are writes on two tables and the store exposes no transaction that + * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves + * a server whose secrets no longer work and which removing again will finish off, while + * delete-then-revoke leaves live secrets no server references and no operation can reach. + */ + async removeServer(serverId: string, by: string): Promise { + const [existing] = await database + .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, + // The third of what `accessFor` resolves a row with. See there. + authScheme: mcpServers.authScheme, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + + /** + * Whether that token is still live, read rather than inferred from a + * thrown error, so a token a previous attempt already revoked, or one + * whose row is gone entirely, is skipped while a database fault still + * propagates and leaves the server row in place to be removed again. + * + * Two queries rather than a join because `mcp_servers.credential_id` is + * `text` and `credentials.id` is `uuid`, so the two columns do not + * compare without a cast. + */ + const [live] = existing?.credentialId + ? await database + .select({ id: credentialRows.id }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, existing.credentialId), + isNull(credentialRows.revokedAt), + ), + ) + : []; + + if (live) { + await credentials.revoke(live.id); + await recordAuditEvent(auditStore, { + eventType: "credential.revoked", + targetType: "credential", + targetId: live.id, + payload: { + actor: by, + reason: "mcp_server_removed", + server: serverId, + }, + }); + } + + /* + * Every person's grant for this server, read out of the VAULT rather than through the join + * table. + * + * `credentials.provider` holds the server id for an `mcp_user_token`, so the vault can be asked + * directly — which matters because the join row is the thing about to be cascaded away, and a + * grant whose pointer has already gone (a person removed earlier) would otherwise be invisible + * here too. The same argument `retireConnectionsFor` makes from the other direction. + */ + const held = await database + .select({ id: credentialRows.id, keyId: credentialRows.keyId }) + .from(credentialRows) + .where( + and( + eq(credentialRows.kind, "mcp_user_token"), + eq(credentialRows.provider, serverId), + isNull(credentialRows.revokedAt), + ), + ) + .orderBy(asc(credentialRows.keyId)); + + for (const grant of held) { + await credentials.revoke(grant.id); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: by, + server: serverId, + // Whose it was. `key_id` holds the user id for this kind, and it is the only place left + // to read it from once the join row has been cascaded away. + owner: grant.keyId, + /* + * Not "they disconnected" and not "they were removed": an administrator took the whole + * connector away, and the person did nothing. An auditor asking what happened to their + * access should see which of the three this was. + */ + reason: "mcp_server_removed", + vendorRevocationRequested: false, + }, + }); + } + + /* + * The third kind of access, which is not a secret at all: everybody's brokered connection. + * + * CRITERION. Removing an app must leave nobody holding brokered access to it, so that adding + * it back again grants nothing until each person has consented afresh. + * + * REASON. `composio_connections` is the whole gate on a brokered call and it references + * nothing — not `mcp_servers`, not `users` — so nothing cascaded it and removing the app left + * every row standing. Two ordinary administrative acts, remove and add back, then restored + * everybody's access at a url the second act chose, with nobody asked again and no screen + * saying it had happened. Consent that reattaches by itself is not consent. + * + * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a + * display key and nothing holds it equal to the slug in the url, so a delete by id would clear + * some other app's connections, or none, for the very row shape that gate already refuses to + * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * + * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An + * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row + * that has one — so passing the entry a colliding id looks up would hide the brokered state of + * the one row most in need of clearing, and would now refuse outright the very row this method + * exists to get rid of, leaving the collision unremovable. Nothing is dialled here, so there is + * no vendor for an entry to protect; the only question is which app's consent rows this row's + * own url stands for. + * + * Before the server row goes, for the reason the revokes above are: what a failure between + * two writes leaves has to be the recoverable half. A connection cleared with the app still + * present is fixed by removing it again; an app deleted with the connections standing is + * reachable by no operation at all, because the toolkit was only ever readable off its url. + * + * AND THE ACCOUNT IS ENDED AT THE VENDOR, not merely forgotten here. Deleting the row closes + * the gate this deployment owns and does nothing whatever to the account: the person's + * mailbox stays attached at Composio, the grant stays live, and an administrator who pressed + * "remove" was told the connector was gone. So every connected person is revoked through the + * broker first, exactly as {@link disconnectBrokered} revokes for one — the same + * shape at the scale of an app. + * + * REVOKE BEFORE DELETE, ALWAYS, and the argument is the one that method makes. The row is the + * only thing here that names which app this person connected, so a delete that ran first + * would leave a failed revoke with nothing to revoke under: a live grant on somebody's + * mailbox that no operation in this deployment can reach. The other order costs a repeat of + * an administrative act nobody minds repeating. Dead and reachable beats live and + * unreachable. + * + * WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes: a broker that will not + * answer ends this method with the rows still standing and the app still present, rather than + * letting it report an ending that did not happen. + * + * THE AUTH CONFIG GOES LAST, after every account is dead and every row is gone, for the same + * reasoning one step out. An orphaned auth config grants nobody anything — it is a shape this + * deployment holds at Composio, not an account — while a live account whose config has + * already been deleted is access that nothing left here can end. + */ + const toolkit = existing ? accessFor(existing, null).toolkit : null; + + if (toolkit) { + /* + * Read before anything is deleted, because the revokes below need the people and the rows + * are where the people are. Sorted, so two removals of the same app revoke in the same + * order and write their trail rows in the same order. + */ + const connected = await database + .select({ + holder: brokeredConnections.holder, + userId: brokeredConnections.userId, + vendorUserId: brokeredConnections.vendorUserId, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + ), + ) + .orderBy( + asc(brokeredConnections.holder), + asc(brokeredConnections.userId), + ); + + /* + * BOTH HOLDERS, EACH NAMED AS WHAT IT IS. Removing an app ends every account standing for + * it — every person's own, and the one the deployment holds if the app was ever Shared, + * which may still be standing after a switch back that has not retired it yet. Each is + * revoked as the account its row says it is, under the vendor id the row was filed under, + * so the deployment's account is asked for as the deployment's and never as whoever + * pressed "remove". A person row always carries its user id (the table's holder check), + * so the fallback to the deployment form below is the shape a corrupt row would need, not + * a reading anything here relies on. + */ + const accountOf = ( + connection: (typeof connected)[number], + ): AccountRef => + connection.holder === "person" && connection.userId !== null + ? { + holder: "person", + userId: connection.userId, + vendorUserId: connection.vendorUserId, + } + : { holder: "deployment", vendorUserId: connection.vendorUserId }; + + /* + * What the broker was actually asked for each of them, kept so the trail below records the + * answer rather than the call. False where there is no broker at all: a deployment whose + * key has since been unset can still remove the app, and it could not have been calling it + * either way — but nothing was asked of Composio and the row must not claim otherwise. + */ + const vendorRevocationRequested: boolean[] = []; + for (const connection of connected) { + vendorRevocationRequested.push( + broker + ? await broker.revoke({ account: accountOf(connection), toolkit }) + : false, + ); + } + + await database + .delete(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + ), + ); + + for (const [index, connection] of connected.entries()) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + /* + * THE APP, not this row's id, and the same key `retireConnectionsFor` files under. + * + * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces is + * keyed on the app at the broker, whichever act produced it, so one query answers + * what happened to one person's brokered access. + * + * REASON. The two acts that can end such a connection were keyed differently: this + * one on `mcp_servers.id`, offboarding on `composio_connections.toolkit` — which is + * all that row records and all that is left once the server row is gone. Nothing + * holds the two strings equal, so on any renamed row half the trail is filed under a + * name the other half never mentions, and the disagreement is invisible everywhere + * they happen to match. + * + * THE APP IS WHAT WAS CONSENTED TO. The gate is `(toolkit, user_id)`, the delete + * above is by toolkit, and the row outlives the server row entirely; the id is a + * display key that may not exist by the time somebody asks. Which server row was + * removed is not lost — the `configuration.changed` row written below names it. + */ + targetId: toolkit, + payload: { + actor: by, + server: toolkit, + // Whose account this was, by the rule {@link disconnectBrokered} names it by: a + // person's own under their id, the deployment's own as the deployment, never as + // the administrator who removed the app. + owner: + connection.holder === "person" && connection.userId !== null + ? connection.userId + : "deployment", + // The same three-way distinction the vault loop above draws, and the same answer: an + // administrator took the whole app away and the person did nothing. + reason: "mcp_server_removed", + /* + * What was asked of the vendor, not that a call was made — {@link + * ComposioBroker.revoke}'s own answer, passed through. True where an account was + * found and its withdrawal asked for, false where there was none to withdraw or + * where this deployment has no broker to have asked. The value of the field is + * exactly that a reader can tell an account this deployment acted on from one that + * outlives it somewhere else, so a constant here would be worse than none. It says + * "requested" because that is the strongest thing the vendor's answer supports: + * the upstream withdrawal runs as a background job nothing here can poll. + */ + vendorRevocationRequested: + vendorRevocationRequested[index] ?? 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); + } + + const released = await database.transaction(async (transaction) => { + const removed = await transaction + .delete(pluginGrants) + .where( + and( + eq(pluginGrants.kind, "mcp"), + eq(sql`split_part(${pluginGrants.ref}, '/', 1)`, serverId), + ), + ) + .returning({ ref: pluginGrants.ref, agentId: pluginGrants.agentId }); + await transaction.delete(mcpServers).where(eq(mcpServers.id, serverId)); + return removed.sort((left, right) => left.ref.localeCompare(right.ref)); + }); + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: by, + change: "mcp_server_removed", + server: serverId, + ...(released.length > 0 + ? { + releasedGrants: released.map((grant) => grant.ref), + bots: [...new Set(released.map((grant) => grant.agentId))], + } + : {}), + }, + }); + }, + + /** + * Ask a server what it offers and replace what we hold. + * + * Replaced wholesale, never merged. A tool a vendor withdrew has to stop being offered, and a + * merge would leave it in the list forever as a name the model will happily call. + * + * `actorId` is who is asking, and whether it is needed at all is the transport's answer rather + * than an assumption here. Where listing means asking a remote server — MCP — a `user-oauth` + * vendor has no deployment credential to ask with, so the listing runs on the grant of whoever + * pressed refresh, and an administrator who has not connected gets a refusal that lands in + * `lastError`. That is the honest state: until somebody has connected, this deployment genuinely + * does not know what that server offers. + * + * Where the tool list is this deployment's own code, nothing is asked and no credential is + * consulted. Requiring one anyway is what made setting Drive up a round trip through an + * administrator's personal settings page for a token that was then discarded. + * + * Absent for the refresh that happens right after a server is added, where nobody can have + * connected yet. It makes no difference to a `deployment-bearer` server, which never consults it. + */ + async refreshTools( + serverId: string, + actorId = "", + ): 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); + + /* + * 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. + * + * Where it is needed, it is taken from the same selection the call path uses rather than by + * decrypting `row.credentialId` — which is what this used to do, and which for a `user-oauth` + * server would have sent the deployment's OAuth client secret to the vendor as somebody's + * access token. One answer to "what token does this server get", and it cannot be a secret of + * the wrong kind. + * + * Where it is NOT needed, asking anyway is not a harmless extra check. For a `user-oauth` + * server that call refuses unless the person pressing the button has connected their own + * account — so an administrator setting Drive up was blocked at "refresh tools" and sent to + * their personal settings page to grant access, so that a token could be minted and handed to + * a function that discards it. The gate outlived the reason for it. + */ + const token = transport.listNeedsCredential + ? (await connectionTokenFor(row, entry, actorId, access)).token + : undefined; + + listed = await transport.listTools({ + url: effectiveUrl(row, entry), + token, + }); + } catch (error) { + /* + * Ours rather than a vendor's, asked as one question about the whole shelf. + * + * 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 (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; + } + + /* + * ASKED THROUGH THE ONE DOOR, like the `storableTools` catch forty lines below that writes + * the same column. + * + * `McpServerError` in the old test was doing nothing — it extends `Error`, so the second arm + * answered for it — and what the whole expression amounted to was + * `error instanceof Error ? error.message : String(error)`, which is precisely the reflex + * {@link reasonWithoutStatement} exists to replace: it reads `.message` directly and so walks + * straight past {@link withoutStatement}. + * + * NOTHING REACHABLE CHANGES BEHAVIOUR HERE TODAY, and that is the reason to write it rather + * than an argument against. The guard above answers every query failure before this line, so + * the shape whose message must never travel cannot arrive by any path anybody can name. What + * CAN arrive is a wrapper — `composio.listTools` rethrows `new Error(listingSentence(...))`, + * and `listingSentence` falls through to the thrown message verbatim — and a wrapper carries + * no `query` and no `params` of its own, so it is on nobody's shelf and answers to nobody's + * check. "No thrower reachable today" was also true of the credential envelope before the + * rotation path started writing one; the door costs nothing and does not need to be + * rediscovered at the next site. + */ + const message = reasonWithoutStatement(error); + // The failure is recorded rather than thrown away, because a server with no tools and no + // explanation reads as a server that offers nothing, and an operator would go looking in + // the wrong place. The tools already held are left alone: a vendor being briefly + // unreachable is not a reason to revoke what Bots are using. + await database + .update(mcpServers) + .set({ + /* + * Capped at the same 400 characters `callTool` caps its recorded failure at. + * + * Parts of this sentence come from a vendor, and it is drawn on the admin page — neither + * is a promise about length, and the two paths that show a vendor's words to an operator + * should not disagree about how much of them to keep. + */ + lastError: message.slice(0, 400), + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + return { tools: 0 }; + } + + /* + * An empty listing never destroys what a real listing recorded. + * + * CRITERION ONE. An empty answer must not be committed as a healthy refresh where doing so + * would delete actions this deployment holds, and must not clear `lastError`. + * + * CRITERION TWO. "This app advertises nothing" stays recordable: an app with nothing held has + * nothing to lose, so the empty answer falls through to the replace below and commits — a + * refresh stamp, no error, no actions. + * + * REASON. The replace below is a delete and an insert, so an empty answer committed here + * deletes every `mcp_tools` row for the server, taking the recorded `effect`, `destructive` + * and `version` with it. `version` is the one that cannot be reconstructed: `callTool` refuses + * an action without it, so a refresh that reported success broke every subsequent call, and + * the grants survived pointing at rows that no longer existed — absent from `listServers`, and + * revived only by a later refresh that worked. + * + * WHAT USED TO REACH THIS LINE, and no longer does. `composio.listTools` once answered `[]` + * for a url naming no app and for a deployment with no Composio client installed, neither of + * which is a vendor's answer, and the second of those is the state of every real deployment. + * Both throw now, so that particular arrival is closed at the seam rather than here. The guard + * stays because its argument never depended on who sent the empty answer. + * + * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor + * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the + * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and + * only a refresh from a deployment that can actually reach the vendor puts it back. That holds + * for any vendor that suddenly lists nothing, whatever made it do so, which is why removing + * this would reopen the same data loss for a different reason. + * + * THE SEAM REQUIREMENT this leans on, and it is satisfied: a transport that could not ask + * anybody must THROW rather than return `[]`. `composio.listTools` opens with two throws that + * say which of the two it is — no app in the url, no client installed — and no transport has + * an early `return []` left in it at all: `builtin-routines` answers a static list, and `mcp` + * and `google-drive-rest` hand back only what a request returned. So an empty listing reaching + * this line is a vendor's own answer, which is what the sentence below says. Nothing here asks + * which transport it is talking to, and nothing has to: the requirement is met at each seam + * rather than branched on here. + */ + if (listed.length === 0) { + const held = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + + if (held.length > 0) { + await database + .update(mcpServers) + .set({ + // Named as the state it is, because "listed nothing" and "would not answer" send an + // operator to different places, and reaching this line settles which one it was: a + // listing that could not be made throws and lands in the `catch` above instead. So + // this sentence must not send anybody to check their configuration — that is the + // other state's sentence, written by the transport that refused. No + // `toolsRefreshedAt`: that column says when this deployment last learned what the app + // offers, and it did not learn it here. + lastError: `This app was asked and answered with no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check whether it still publishes them, then refresh again.`, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + // What the app advertises, which is what it advertised before: the honest count, because + // nothing was replaced. + return { tools: held.length }; + } + } + + /* + * TURNING THE VENDOR'S ANSWER INTO ROWS, which is still the vendor's answer and so is still + * caught. + * + * CRITERION. Nothing a vendor can put in a listing leaves this method as an uncaught throw, + * and nothing a vendor can put in a listing half-applies the add that called it. + * + * REASON. Names are deduplicated and vendor text is made storable before a transaction is + * opened on any of it, because both of those failures used to abort the replace from inside + * one — see {@link storableTools}. Moving the work earlier moved the THROW earlier with it, + * to a line between the two `try` blocks and covered by neither. A schema that this could not + * make storable then left `refreshTools` raw: the refresh route has no mapping for it, so the + * admin page got a bodiless 500 and `lastError` kept whatever it held before; and `addServer` + * refreshes before it answers, so the add died AFTER its server row and its audit row had + * committed, leaving a configured app nobody had finished configuring. + * + * RECORDED RATHER THAN RAISED, which is the same answer the vendor `try` gives, because this + * is the same kind of event: the app was reached, it answered, and its answer is not something + * this deployment can write down. The actions it already holds are kept, no `toolsRefreshedAt` + * is stamped — nothing was learned — and the add completes with the failure on the row for an + * administrator to read. + * + * `withoutStatement` rather than `error.message`, so that the one shape whose message must + * never travel cannot reach the column even from a line that should never produce one. + */ + let storable: ReturnType; + try { + storable = storableTools(serverId, listed); + } catch (error) { + const reason = + error instanceof Error ? withoutStatement(error) : String(error); + await database + .update(mcpServers) + .set({ + // Capped where every other quoted failure in this file is capped, and for the same + // reason: part of this sentence comes from elsewhere and none of it is a promise about + // length. + lastError: + `This app answered with an action whose schema could not be stored as it arrived, so the actions already recorded for it were kept rather than replaced. ${reason}`.slice( + 0, + 400, + ), + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + return { tools: 0 }; + } + + /* + * COMMITTING WHAT THE VENDOR SAID. Nothing from here down is a vendor's doing, so nothing + * from here down is recorded as one — see the criterion on the vendor `try` further up, and + * the one on the `try` immediately above this, which is the last thing here that still is. + * + * 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. + */ + try { + await database.transaction(async (transaction) => { + await transaction + .delete(mcpTools) + .where(eq(mcpTools.serverId, serverId)); + if (storable.length > 0) { + await transaction.insert(mcpTools).values(storable); + } + }); + } catch (error) { + /* + * A database failure, with the statement and its parameters left behind. + * + * CRITERION. Nothing raised from here carries the SQL or the values bound to it. + * + * REASON. drizzle wraps every failure as a `DrizzleQueryError`, whose message is + * `Failed query:` followed by the whole statement and then every parameter — here, the + * vendor's entire tool list. That message is what an unhandled throw puts in the logs and + * what any caller that prints an error puts on a screen. A SQL dump on an error path is + * the same disclosure shape as a credential leak one layer out, and it is gratuitous: the + * driver's own complaint says what went wrong without any of it. + * + * RAISED, NOT RECORDED, which is what the paragraph above this transaction argues for and + * is now true rather than merely intended: with duplicate names and unstorable text + * removed before the statement is built, what is left is this database refusing something + * this deployment's own schema says it will take, and `lastError` is where a VENDOR's + * answer goes. + */ + throw new PluginInvariantError( + `${row.id}: the actions this app listed were not stored, so what it already had is unchanged. ${databaseComplaint(error)}`, + ); + } + + await database + .update(mcpServers) + .set({ + toolsRefreshedAt: new Date(), + lastError: null, + updatedAt: new Date(), + }) + .where(eq(mcpServers.id, serverId)); + + /* + * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at + * nothing. + * + * Reporting it on a screen answers "what is true now", which somebody has to go and look at. + * This answers "when did it stop being offered, and what was holding it" — the question asked + * after a transport is swapped back and a name starts resolving again. Without the row, the + * only record of the gap is its absence. + * + * Not a refusal and not an error, so `configuration.changed` rather than a new event type: + * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so + * what it names is what is actually left over — and only ever after a listing that was + * committed, because the guard above returns before this on an empty answer that would have + * named every grant the app holds. + */ + // The names as STORED, so a grant is compared against a row that exists: a duplicate the + // vendor listed twice is one row, and a name is spelled here the way the insert spelled it. + const advertised = new Set(storable.map((tool) => tool.name)); + const stranded = [...(await mcpGrantsForServers([serverId])).entries()] + .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) + .sort(([left], [right]) => left.localeCompare(right)); + + if (stranded.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: auditActor, + change: "grants_not_advertised", + server: serverId, + // The refs, because that is what a grant is keyed on and what an administrator revokes. + refs: stranded.map(([ref]) => ref), + bots: [...new Set(stranded.flatMap(([, agents]) => agents))], + note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", + }, + }); + } + + /* + * Tools the vendor advertises that this deployment's write list does not name. + * + * The mechanical half of the reconciliation Notion's catalogue entry says is required. See + * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an + * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list + * is silent, and for a vendor with no scope strings there is nothing else standing behind it. + * + * `configuration.changed` rather than a type of its own, the same as the stranded grants + * above and for the same reason: nothing was denied and the refresh succeeded. What changed + * is that the deployment now knows a name it had not classified. + */ + const unlisted = unlistedAdvertisedTools(entry, [...advertised]); + if (unlisted.length > 0) { + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "mcp_server", + targetId: serverId, + payload: { + actor: auditActor, + change: "unlisted_tools_advertised", + server: serverId, + tools: unlisted, + note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", + }, + }); + } + + // What was recorded, which is what "this app offers N actions" means on the page. Counting + // the listing instead reported a duplicate the vendor named twice as two actions the + // deployment holds, when `mcp_tools` holds one row for it. + return { tools: storable.length }; + }, + + async listServers(): Promise { + const rows = await database + .select() + .from(mcpServers) + .orderBy(asc(mcpServers.title)); + if (rows.length === 0) return []; + + const tools = await database + .select() + .from(mcpTools) + .where( + inArray( + mcpTools.serverId, + rows.map((row) => row.id), + ), + ) + .orderBy(asc(mcpTools.name)); + + /* + * Every grant on these servers, not only the ones matching a tool that is still advertised. + * Asking about the advertised refs answers "who holds what is offered", which cannot report the + * grants that are the point here — see `mcpGrantsForServers`. + */ + const grants = await mcpGrantsForServers(rows.map((row) => row.id)); + const advertised = new Set( + tools.map((tool) => `${tool.serverId}/${tool.name}`), + ); + + /* + * HOW EACH APP CONNECTS, WHICH IS A FACT ABOUT THE APP AND NOT ABOUT THE ROW BESIDE IT. + * + * CRITERION. Every row here whose url names a Composio app reports the scheme + * {@link brokeredAppScheme} answers for that app — so two rows at one url report one answer, + * and it is the answer {@link connectBrokeredWithFields} will act on. + * + * REASON. The browser forks on this field: `brokered-account-row.tsx` draws a consent button, + * a form or a "nothing to connect" sentence out of it, and the press then lands in a store + * method that resolves the app by its URL. Reported off each row's own column those were two + * readings of one fact — a form drawn from this row and a submission refused by the other + * row's scheme, telling somebody to connect the app the way it asks for over an app they were + * asked exactly that way. {@link serverAddress} answers the same field the same way, so the + * page that lists an app and the route that connects it cannot come apart either. + * + * ONE EXTRA READ FOR THE WHOLE LIST, and none where the deployment has enabled no apps. + */ + const brokeredApps = await brokeredAppRowsAt( + rows.filter((row) => toolkitOf(row.url) !== null).map((row) => row.url), + ); + + /* + * WHICH APPS THE DEPLOYMENT HOLDS AN ACCOUNT AT, IN ONE READ FOR THE WHOLE LIST. Keyed by + * app rather than by server, because the account belongs to the app's url and not to a row: + * two rows at one url are one Composio app with one deployment account behind it. + */ + const deploymentHeld = new Set( + ( + await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.holder, "deployment"), + ), + ) + ).map((held) => held.app), + ); + + return rows.map((row) => { + const entry = catalogueEntry(row.id); + const toolkit = toolkitOf(row.url); + return { + id: row.id, + title: row.title, + logo: row.logo, + vendor: row.vendor, + url: effectiveUrl(row, entry), + summary: entry?.summary ?? "", + docsUrl: entry?.docsUrl ?? "", + provenance: row.provenance, + hasCredential: row.credentialId !== null, + toolsRefreshedAt: iso(row.toolsRefreshedAt), + lastError: row.lastError, + addedBy: row.addedBy, + dynamicClient: + entry?.auth.kind === "user-oauth" && + entry.auth.clientRegistration === "dynamic", + // The app's, for a row whose url names one; this row's own column for everything else, + // which is a null on every server that is not brokered. See the read above. + authScheme: toolkit + ? (brokeredApps.get(row.url)?.authScheme ?? null) + : row.authScheme, + /* + * The app's mode for the same reason as its scheme: a duplicate row at a Shared app's url + * is the Shared app, and the memory ingestion decides whose account a source reads from + * exactly these two fields. + */ + accountMode: toolkit + ? (brokeredApps.get(row.url)?.accountMode ?? null) + : row.accountMode, + sharedVendorConnected: + toolkit !== null && + brokeredApps.get(row.url)?.accountMode === "shared" && + deploymentHeld.has(toolkit), + tools: tools + .filter((tool) => tool.serverId === row.id) + .map((tool) => { + const ref = `${tool.serverId}/${tool.name}`; + return { + serverId: tool.serverId, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as Record, + ref, + effect: classifyTool(entry, tool.name, true, tool.effect), + destructive: tool.destructive, + grantedTo: grants.get(ref) ?? [], + }; + }), + /* + * Sorted by ref so the list is stable between reads, which matters because this is the one + * place a discrepancy is reported and a reader comparing two visits should see the same + * order. + */ + withdrawn: [...grants.entries()] + .filter( + ([ref]) => ref.startsWith(`${row.id}/`) && !advertised.has(ref), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([ref, grantedTo]) => ({ + ref, + name: ref.slice(row.id.length + 1), + grantedTo, + })), + }; + }); + }, + + /** + * 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. + * + * AND THE SCHEME IS THE APP'S, NOT THIS ROW'S, WHICH IS THE ONE FIELD HERE THAT IS NOT ABOUT A + * ROW AT ALL. + * + * CRITERION. For a row whose url names a Composio app, `authScheme` is what + * {@link brokeredAppScheme} answers for that app. + * + * REASON. The connect route forks on this field — a form for a key app, a consent link for a + * consent one, a refusal for a no-auth one — and the store method that fork leads to, + * {@link connectBrokeredWithFields}, reads the scheme off the row that answers for the APP. Two + * rows may name one app, so those were two different reads of one fact: the page draws a form + * off this row and the submission is refused by the other row's scheme, in a sentence telling + * somebody to connect an app the way it asks for — over an app they were just asked exactly + * that way. The id and the title stay this row's own, because those name the row the page + * opened; the scheme is a fact about the app, and the app has one answer. + * + * `accountMode` IS THE APP'S FOR THE SAME REASON, off the same answering row and in the same + * read, never this row's own column; a row that names no app has no mode and answers null. + * + * ONE EXTRA READ, AND ONLY FOR A BROKERED URL. A row that names no app takes the read it always + * took. + */ + async serverAddress(serverId: string): Promise { + const [row] = await database + .select({ + id: mcpServers.id, + title: mcpServers.title, + url: mcpServers.url, + authScheme: mcpServers.authScheme, + }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!row) return undefined; + const toolkit = toolkitOf(row.url); + if (!toolkit) return { ...row, accountMode: null }; + const app = await brokeredAppRow(toolkit); + return { + ...row, + authScheme: app?.authScheme ?? null, + accountMode: app?.accountMode ?? null, + }; + }, + + /** + * 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); + }, + + /** Every account any holder has on a brokered app, as the broker would be asked to end it. */ + async accountsOn( + serverId: string, + ): Promise<{ toolkit: string; accounts: AccountRef[] } | null> { + const [row] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + const toolkit = row ? toolkitOf(row.url) : null; + if (!toolkit) return null; + const held = await database + .select({ + holder: brokeredConnections.holder, + userId: brokeredConnections.userId, + vendorUserId: brokeredConnections.vendorUserId, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.app, toolkit), + ), + ); + return { + toolkit, + accounts: held.map( + (row): AccountRef => + row.holder === "person" + ? { + holder: "person", + userId: row.userId ?? "", + vendorUserId: row.vendorUserId, + } + : { holder: "deployment", vendorUserId: row.vendorUserId }, + ), + }; + }, + + /** + * Write an app's mode and deployment identity — onto the row that ANSWERS for the app. + * + * Every reader takes these two columns off {@link brokeredAppRow}, so a write landing on a + * duplicate row at the same url would be a switch nobody reads: the page would say Shared and + * every call would go on running as it did. The id given is resolved to its url, the url to its + * app, and the app to its answering row; a row that names no app is written as itself, which is + * the only row there is to write. + */ + async setAccountModeColumns( + serverId: string, + mode: AccountMode, + sharedVendorUserId: string | null, + ) { + const [dialled] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + const toolkit = dialled ? toolkitOf(dialled.url) : null; + const app = toolkit ? await brokeredAppRow(toolkit) : null; + await database + .update(mcpServers) + .set({ accountMode: mode, sharedVendorUserId }) + .where(eq(mcpServers.id, app?.id ?? serverId)); + }, + }; +} diff --git a/server/src/plugins/plugin-store/skills.ts b/server/src/plugins/plugin-store/skills.ts new file mode 100644 index 000000000..39ca3dec5 --- /dev/null +++ b/server/src/plugins/plugin-store/skills.ts @@ -0,0 +1,279 @@ +import { and, asc, eq, isNull, or } from "drizzle-orm"; +import { recordAuditEvent } from "../../audit"; +import { + agentProfiles, + agents, + mcpServers, + pluginGrants, + skills, + skillTools, +} from "../../db/schema"; +import type { StoreContext } from "./context"; +import { PluginRefusedError, type SkillActor, type SkillRecord } from "./model"; + +/** Skills, and what the store answers about Bots and servers for the routes and the desk. */ +export function skillMethods(context: StoreContext) { + const { database, auditStore, grantsFor, toolsDeclaredBy, knownToolRefs } = + context; + return { + /** + * The skills this person may see: the deployment's, plus their own. + * + * An administrator sees every skill in the deployment, including other people's, because + * governing what Bots are told is the job of the surface they are looking at. + */ + async listSkills(actor?: SkillActor): Promise { + const visible = + !actor || actor.isAdmin + ? undefined + : or(isNull(skills.ownerUserId), eq(skills.ownerUserId, actor.id)); + const rows = await database + .select() + .from(skills) + .where(visible) + .orderBy(asc(skills.title)); + const grants = await grantsFor( + "skill", + rows.map((row) => row.slug), + ); + const declared = await toolsDeclaredBy(rows.map((row) => row.id)); + return rows.map((row) => ({ + id: row.id, + slug: row.slug, + ownerUserId: row.ownerUserId, + title: row.title, + summary: row.summary, + instructions: row.instructions, + origin: row.origin, + installedBy: row.installedBy, + grantedTo: grants.get(row.slug) ?? [], + tools: declared.get(row.id) ?? [], + })); + }, + + /** Whose a skill is, or `undefined` if there is no such skill. Null owner means the deployment's. */ + async skillOwner(slug: string): Promise { + const [row] = await database + .select({ ownerUserId: skills.ownerUserId }) + .from(skills) + .where(eq(skills.slug, slug)) + .limit(1); + return row ? row.ownerUserId : undefined; + }, + + /** + * Whose a Bot is, or `undefined` if there is no such Bot. + * + * Read here rather than through the coworker store because the only question this file asks is + * "may this person put their skill on that Bot", and a whole profile is more than that needs. + */ + /** + * Whether this Bot can be granted handing work on, or `undefined` if there is no such Bot. + * + * Any Bot that exists can: a built-in Bot runs `message_bot` in this process, and a Bot at its + * own endpoint calls back for it through the signed tool callback, which reaches the same desk. + */ + async agentCanHandOn(agentId: string): Promise { + const [row] = await database + .select({ id: agents.id }) + .from(agents) + .innerJoin(agentProfiles, eq(agentProfiles.agentId, agents.id)) + // A deleted Bot is not one anybody may be given, and answering about it at all would say it + // had existed. + .where(and(eq(agents.id, agentId), isNull(agentProfiles.deletedAt))) + .limit(1); + return row ? true : undefined; + }, + + /** + * Whether this Bot is one somebody could be handed work by, at all. + * + * The TARGET of a bot grant, unlike the grantee, may perfectly well run at its own endpoint — + * being handed work is not the same as being able to hand it on. What it may not be is absent: + * `ref` is bare text with no foreign key, so a typo stored happily, `message_bot` was offered, + * and every hop refused as not-granted. That is the same row-that-cannot-work this check exists + * to stop, arriving from the other side. + */ + async agentIsRegistered(agentId: string): Promise { + const [row] = await database + .select({ id: agents.id }) + .from(agents) + .innerJoin(agentProfiles, eq(agentProfiles.agentId, agents.id)) + .where(and(eq(agents.id, agentId), isNull(agentProfiles.deletedAt))) + .limit(1); + return row !== undefined; + }, + + /** + * Whether this deployment has an app by this id. + * + * The narrowest question a caller can ask about a server, and deliberately not `listServers`, + * which materialises every tool and every grant in the deployment to answer. One row, one + * column, one limit — this runs on the grant path, which is a person waiting on a switch. + * + * Existence only. Whether the app currently ADVERTISES a given tool is a different question and + * is not asked here: a grant naming a tool a server has stopped offering is a supported state + * ({@link GrantOnWithdrawnTool}), held and not offered, because what a vendor advertises today + * is not what somebody decided yesterday. A grant naming no app at all is not that state. + */ + async serverExists(serverId: string): Promise { + if (!serverId) return false; + const [row] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + return row !== undefined; + }, + + async agentOwner(agentId: string): Promise { + const [row] = await database + .select({ ownerUserId: agentProfiles.ownerUserId }) + .from(agentProfiles) + .where(eq(agentProfiles.agentId, agentId)) + .limit(1); + return row ? row.ownerUserId : undefined; + }, + + async installSkill(input: { + slug: string; + title: string; + summary: string; + instructions: string; + origin?: string; + /** Whose it is. Null writes a skill for the whole deployment, which is an admin's to make. */ + ownerUserId: string | null; + /** + * The tools this skill needs, as `/` refs. Absent leaves whatever was + * declared before; an empty array clears it, which is how a skill stops asking for anything. + */ + tools?: string[]; + by: string; + }): Promise { + /* + * Checked before anything is written, so a save is all-or-nothing from the caller's side: a + * skill is never left saved with half its declarations because the fourth ref was a typo. + */ + const declared = + input.tools === undefined + ? undefined + : [...new Set(input.tools.map((ref) => ref.trim()).filter(Boolean))]; + if (declared !== undefined && declared.length > 0) { + const known = await knownToolRefs(declared); + const unknown = declared.filter((ref) => !known.has(ref)); + if (unknown.length > 0) { + throw new PluginRefusedError( + `No tool by that name has been seen here: ${unknown.join(", ")}. A skill names tools as serverId/toolName, and the server has to have been refreshed at least once.`, + // No policy rule refused this; the name simply matches nothing. `rule` is what an audit + // reader is shown as the reason, and inventing one here would put a rule in the trail + // that nobody wrote. + null, + ); + } + } + + await database + .insert(skills) + .values({ + id: input.slug, + slug: input.slug, + ownerUserId: input.ownerUserId, + title: input.title, + summary: input.summary, + instructions: input.instructions, + origin: input.origin ?? "yours", + installedBy: input.by, + }) + // Editing keeps the owner it already had. Whose a skill is, is not something a re-save + // should quietly change, and the route has already checked this person may edit it. + .onConflictDoUpdate({ + target: skills.slug, + set: { + title: input.title, + summary: input.summary, + instructions: input.instructions, + updatedAt: new Date(), + }, + }); + + /* + * Replaced wholesale rather than merged. What a skill needs is a set the author is editing, so + * a save says what it is now; merging would make removing one a thing with no gesture for it. + */ + if (declared !== undefined) { + await database + .delete(skillTools) + .where(eq(skillTools.skillId, input.slug)); + if (declared.length > 0) { + await database.insert(skillTools).values( + declared.map((ref) => ({ + skillId: input.slug, + ref, + declaredBy: input.by, + })), + ); + } + } + + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "skill", + targetId: input.slug, + payload: { + actor: input.by, + change: "skill_installed", + skill: input.slug, + // Recorded because it is what the skill will pull into a model's context once selection is + // built. It changes nothing about what may be called; the grant still decides that. + ...(declared === undefined ? {} : { declares: declared }), + }, + }); + }, + + async uninstallSkill(slug: string, by: string): Promise { + await database.transaction(async (transaction) => { + await transaction + .delete(pluginGrants) + .where( + and(eq(pluginGrants.kind, "skill"), eq(pluginGrants.ref, slug)), + ); + await transaction.delete(skills).where(eq(skills.slug, slug)); + }); + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "skill", + targetId: slug, + payload: { actor: by, change: "skill_uninstalled", skill: slug }, + }); + }, + + /** + * The Bots one Bot has been granted, read fresh. + * + * NEVER CACHED. Whether one Bot may address another is a decision an administrator can change, + * and a grant revoked a minute ago has to apply to the next hop rather than after a restart. It + * is a single indexed read, which is the right price for that. + */ + /** + * The Bots this one may hand work to. + * + * Whatever the grantee's type. A Bot at its own endpoint is offered `message_bot` as a tool it + * calls back for, and the call reaches the same handoff desk, grant check and caps as a built-in + * Bot's (see `createCoordinationTools`). This used to keep only built-in grantees, from before + * remote Bots could call back for coordination, which left every remote Bot's grant reading as + * configured while no hop ever happened. + * + * The join keeps a grant held by a Bot that no longer exists from counting. + */ + async botsReachableFrom(agentId: string): Promise { + const rows = await database + .select({ ref: pluginGrants.ref }) + .from(pluginGrants) + .innerJoin(agents, eq(agents.id, pluginGrants.agentId)) + .where( + and(eq(pluginGrants.kind, "bot"), eq(pluginGrants.agentId, agentId)), + ); + return rows.map((row) => row.ref); + }, + }; +} diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts new file mode 100644 index 000000000..5c23a242b --- /dev/null +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -0,0 +1,341 @@ +import { requireAdmin } from "../../auth/guards"; +import { reasonWithoutStatement } from "../../db/query-failure"; +import { + type BrokerApp, + BrokerUnconfiguredError, + brokerSentence, +} from "../broker"; +import { toolkitOf, vendorSentence } from "../composio"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, + PluginRefusedError, +} from "../store"; +import { + brokerRefusal, + DIRECTORY_UNAVAILABLE, + type PluginRouteContext, +} from "./context"; + +/** Browsing Composio's directory and enabling an app out of it. */ +export function mountComposioAppRoutes({ + routes, + store, + requireUser, + composio, + actorEmail, +}: PluginRouteContext) { + /** + * The broker's catalogue, as an administrator chooses an app out of it. + * + * THE SEARCH IS OURS, AND HAS TO BE. `@composio/core`'s toolkit listing forwards category, + * managed_by, sort_by, cursor and limit, and takes no search term at all — a term handed to it is + * dropped without a word, and what comes back is an unfiltered first page that looks exactly like + * a result. So the whole directory is read and filtered in this process, over the three fields an + * administrator would actually be typing at: the slug, the name and the description. A few + * hundred rows is a list, not a query. + * + * NO BROKER IS A 503 NAMING THE SETTING, not an empty list. An empty directory and an absent one + * are different facts: the first says Composio has nothing to offer, the second says nobody was + * asked. Answered as `{ apps: [] }`, a deployment with no key draws "no apps available" over a + * remedy that is one environment variable long, which is why + * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written + * here. + * + * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the + * transport reads which app a call is against, so it is the only reading that decides anything; + * the id names the row — `composio-linear` — and reading one as the other would quietly work + * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that + * property made structural: there is no id here to read by mistake. + */ + routes.get("/composio/apps", requireUser, async (context) => { + /* + * INSIDE THE HANDLER, and the response returned. `requireAdmin` is a function that answers a + * response, not Hono middleware: put in the middleware position it typechecks against Hono's + * variadic signature, runs, and gates nothing at all, because nobody reads what it returned. + */ + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + let directory: BrokerApp[]; + try { + directory = await composio.broker.listApps(); + } catch (error) { + // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis + // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. + const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + return context.json({ error: refusal.error }, refusal.status); + } + /* + * AN APP THAT CANNOT BE CONNECTED IS NOT AN APP TO OFFER. Every kind but this one ends at a + * person with a working account; `unsupported` ends at an administrator pressing Add and + * meeting Composio's refusal, because the OAuth client it wants is one this deployment has + * nowhere to hold. Hidden here rather than at the vendor: the filter is a fact about what this + * deployment can drive, not about what Composio publishes. + */ + const connectable = directory.filter( + (candidate) => candidate.connection.kind !== "unsupported", + ); + const term = (context.req.query("q") ?? "").trim().toLowerCase(); + const matched = term + ? connectable.filter((app) => + [app.slug, app.name, app.description].some((field) => + field.toLowerCase().includes(term), + ), + ) + : connectable; + + /* + * THE ONE CALL ON THIS ROUTE THAT IS NOT THE VENDOR'S, AND IT WAS THE ONE NOTHING ANSWERED FOR. + * + * CRITERION. The same one the add routes state: 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. The `try` above wraps `listApps` because that is where a wrong key shows itself, and + * this read sat outside it with no mapping at all — so a query this database refused while + * marking which apps are already enabled gave an administrator the framework's bodyless 500, + * on the one screen where a Composio key has just been set. That is the reading most likely to + * send somebody back to a key that is fine, over a fault Composio had no part in. The same 409 + * and the same sentence the sibling admin routes give the shelf; a failure that is not on it + * still throws, which is what keeps the stack in the log. + */ + let urls: string[]; + try { + urls = await store.serverUrls(); + } catch (error) { + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } + throw error; + } + + const enabled = new Set( + urls + .map((url) => toolkitOf(url)) + .filter((toolkit): toolkit is string => toolkit !== null), + ); + return context.json({ + apps: matched.map((app) => ({ ...app, enabled: enabled.has(app.slug) })), + }); + }); + + /** + * Enable one app of that catalogue, which is a third way for a server to arrive. + * + * THE SLUG VALIDATION IS THE WHOLE ROUTE. `addBrokeredApp` composes `composio://`, and that + * url is what every future call for the app is resolved against — so a slug the directory never + * answered with is a row pointing at an app that does not exist: added, grantable, enabled on a + * Bot, and dead at the first call, with nothing on the page saying so. The live directory is what + * it is checked against rather than a pattern, because the question is not whether the text is + * well formed but whether Composio has such an app right now. + * + * The title comes off the directory entry too. The caller chose an app; they did not choose a + * name for it. + */ + routes.post("/composio/apps", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + const body = (await context.req.json().catch(() => null)) as { + slug?: unknown; + } | null; + /* + * ASKED WITH `typeof` RATHER THAN OFF THE ANNOTATION, which is the same hazard the skill routes + * below spell out twice (`:2064`, and again at the grant route): the body is JSON, so the type + * above is a wish. `{"slug":123}` made `(123).trim()` a `TypeError` thrown out of the handler — + * and this statement sits outside every `try` here, so Hono's default handler answered a + * bodyless 500 and the admin page fell back to "That app could not be added". Admin-gated, so + * what it cost was an operator's diagnosis rather than anything they should not have had. + * + * A non-string is simply no slug, and the not-found refusal below already says the true thing + * about that, so the narrowing answers with `undefined` rather than a second sentence. + */ + const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined; + let directory: BrokerApp[] = []; + if (slug) { + try { + directory = await composio.broker.listApps(); + } catch (error) { + /* + * The same failure the directory route answers, in the same words, because it is the same + * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a + * deployment whose key is wrong was told nothing at all, on the one screen where the key + * had just been set. + */ + const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + return context.json({ error: refusal.error }, refusal.status); + } + } + const app = directory.find((candidate) => candidate.slug === slug); + if (!app) { + return context.json( + { + error: slug + ? `${slug} is not an app Composio lists for this deployment.` + : "An app is required.", + }, + 400, + ); + } + + /* + * 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, + logo: app.logo, + by: actorEmail(context), + // Off the directory entry the administrator chose, never derived a second time here: a + // second derivation is a second answer, which is the one thing `BrokerConnection` exists to + // prevent. It decides what config the store creates at the vendor, and what the row records. + connection: app.connection, + }); + return context.json({ server }, 201); + } catch (error) { + // The same mapping the add routes above make, for the same reason: a refusal an + // administrator can correct comes back as itself rather than as a 500. + if ( + error instanceof CustomServerRefusedError || + error instanceof PluginRefusedError + ) { + return context.json({ error: error.message }, 400); + } + // And, as on those routes, enabling refreshes before it answers, so every fault + // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, + // which is the honest answer whichever side of the insert the fault arrived on. + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } + /* + * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its + * three sibling add routes map and it did not. + * + * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not + * reported as an app that was not enabled, and is not reported as Composio's doing. + * + * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files + * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of + * `listServers` — raising this when it is not there, as `requireServer` does one call + * deeper. Every one of those steps has already committed by then. + * + * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail + * below and an administrator was told "Slack could not be enabled, and Composio said nothing + * about why. Try again, and check this deployment's Composio key if it persists." That is + * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, + * and the one thing the reader now holds — a row on their Plugins page — went unmentioned + * while they were sent to check a key that is fine. + * + * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact + * wearing the same class. On the add routes above, an unknown key is a caller naming a + * server this deployment will not connect to, which is a malformed request. Here the row was + * written and this deployment cannot see it: two of its own reads disagree, and the request + * cannot be answered until one of them changes — the reading the refresh route's own 409 + * spells out. The message is written here rather than passed through, because + * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. + */ + if (error instanceof CatalogueEntryUnknownError) { + return context.json( + { + error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, + }, + 409, + ); + } + /* + * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here + * is what left an administrator with nothing. Composio's own sentence — "Default auth config + * not found for toolkit linear_mcp. Composio does not have managed credentials for this + * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the + * browser fell back to its own "That app could not be added". Everything that explains the + * failure existed; nothing carried it the last step. + */ + /* + * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a + * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed + * where an operator could find it. Catching everything fixed the answer and silenced the log, + * and the failure that needs the log most is the one left here — not a refusal this + * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one + * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both + * null is a fault nobody has explained, most often a programmer error on this side, and it + * would otherwise leave only the generic "Composio said nothing about why" in a browser + * nobody is reading a console from. + * + * The same shape and the same restraint as the connection-not-recorded log below: the slug, + * because it is the app an operator is about to be asked about; what the person was told, so + * the console line and the support request can be matched up; and the error read through + * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, + * no vendor response, no request body, and no statement — the two sentence tests below do not + * exclude a query failure, they are the branch one arrives in. + * + * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above + * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, + * and the read-back that could not find it — so what is left arrives from the steps before + * the insert, where nothing was written and the app really was not enabled. The generic + * sentence below names Composio for the same reason: the one call in front of the insert is + * `ensureAuthConfig`, which is a call to the vendor. + */ + if (brokerSentence(error) === null && vendorSentence(error) === null) { + console.error( + JSON.stringify({ + type: "composio-app-not-enabled", + slug: app.slug, + note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + error: reasonWithoutStatement(error), + }), + ); + } + + const refusal = brokerRefusal( + error, + `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + }); +} diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts new file mode 100644 index 000000000..7ef106608 --- /dev/null +++ b/server/src/plugins/route-groups/connections.ts @@ -0,0 +1,1156 @@ +import type { AppVariables } from "../../auth/guards"; +import { + type BrokerField, + BrokerUnconfiguredError, + brokerReturnUrl, + type Decides, + isFieldScheme, + type SchemeKind, + schemeKind, +} from "../broker"; +import { catalogueEntry } from "../catalogue"; +import { toolkitOf } from "../composio"; +import { + authorizationUrlFor, + type ConnectOrigin, + challengeFor, + connectedAccountsUrlFor, + createVerifier, + redirectUriFor, + sealConnectState, +} from "../oauth"; +import type { AccountRef } from "../shared-accounts"; +import { + CatalogueEntryUnknownError, + type OAuthClient, + PluginRefusedError, +} from "../store"; +import { brokerRefusal, type PluginRouteContext } from "./context"; + +/** A person's connections: listing them, connecting an app (OAuth or brokered) and disconnecting. */ +export function mountConnectionRoutes({ + routes, + store, + requireUser, + connect, + composio, + actorEmail, +}: PluginRouteContext) { + /** + * Where a person's own connections are, and how to start a new one. + * + * Not admin-only, and that is the point: an administrator registers the connector once, and then + * everybody connects their own account. Somebody can only ever see or start their own. + */ + routes.get("/connections", requireUser, async (context) => { + /* + * BOTH TABLES, ONE LIST, because the person asking has one question. + * + * "Am I connected to this?" is the same question whether the grant is a refresh token in this + * deployment's vault or an account Composio holds on our behalf. Which table a connection lives + * in is a fact about how the vendor is reached — the transport, the credential, who keeps the + * secret — and none of that is something a settings page should have to know in order to draw a + * word beside a row. Answering out of `connectionsFor` alone left the brokered half invisible, + * so the page could only either say "Not connected" over a live account or say nothing at all, + * and it chose to say nothing. + * + * CONCATENATED WITHOUT REWRITING, because the fields a settings page draws from — the server + * id, the scope, the date — line up across the two reads, and one row template can draw either + * kind. What does not line up is what `brokeredConnectionsFor` adds — `verified`, `verifiedAt`, + * `probe` and `checkable` — so the list that leaves here is not uniform. Those travel because a + * brokered row is the only one with anything to re-check: this deployment holds no secret for + * it, only a note that Composio said yes, and that note can drift when somebody ends the + * connection in Composio's own dashboard. `probe` rides with the first two because the flag + * cannot be read alone — three situations share one `verified: false`, and which action the + * check actually SPENT is what separates them on a page that has done nothing but load. + * + * `probe` AND `checkable` ARE TWO ANSWERS AND NOT ONE SENT TWICE, and a caller that treats them + * as interchangeable breaks the page in one of two opposite ways. `probe` is a record of the + * check that was made; `checkable` is whether the app has anything to check with NOW. They + * agreed while the first was derived, and an administrator's press of Refresh moved what the + * app publishes without touching what the check spent — so the page accused a key nobody had + * tried. Recording the first fixed that and deadlocked the other half: a key nothing was spent + * on reads null for good, and the button that is the only way to ever spend one was gated on + * that null. So the sentence is drawn off `probe` and the button off `checkable`; see + * `brokeredConnectionsFor`, which sets both failures out in full. A held connection has no + * equivalent question, so its rows carry none of these fields, and their absence is what tells + * the two READS apart. It is not how a reader learns how an app connects: that is the app's + * recorded `authScheme`, and a page asking this list instead would be deriving a second answer + * to a question the row already carries. + * + * SORTED, so two requests answer in the same order. Each read is ordered by server id within + * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain + * strings rather than by `localeCompare`, because the order only has to be the SAME one every + * time, and a collation that varies with the deployment's locale is not that. + */ + const [held, brokered, shared] = await Promise.all([ + store.connectionsFor(context.var.actor.id), + store.brokeredConnectionsFor(context.var.actor.id), + store.sharedConnections(), + ]); + const connections = [ + ...held.map((row) => ({ ...row, holder: "person" as const })), + ...brokered.map((row) => ({ ...row, holder: "person" as const })), + ...shared, + ].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 + // this deployment will send. Null means the deployment has no public URL and cannot connect. + redirectUri: connect?.publicUrl + ? redirectUriFor(connect.publicUrl) + : null, + }); + }); + + /** + * Begin connecting an account: a person's own, or, for a Shared app, the deployment's team + * account. + * + * Answers with a URL rather than redirecting, so the browser decides when to leave the page. The + * state is minted here, from the session and the row's mode, and never from anything the request + * carries. + */ + routes.post("/servers/:id/connect", requireUser, async (context) => { + const serverId = context.req.param("id"); + + /* + * A BROKERED APP IS ANSWERED HERE AND GOES NO FURTHER DOWN THIS HANDLER. + * + * Everything below this branch belongs to the consent flow THIS deployment runs: a public URL + * to build a redirect URI out of, a catalogue entry naming the vendor's authorization + * endpoint, an OAuth client an administrator registered, a sealed state the callback reads + * back. A brokered app has none of it. Composio holds the consent, so no authorization code + * ever comes back to us, no refresh token is stored here, and no redirect URI of ours is + * registered with anybody — there is nothing for those checks to be about. + * + * WHICH IS WHY THE ORDER IS THE WHOLE POINT AND NOT A TIDINESS. Falling through, a brokered + * row met `catalogueEntry`, which has never heard of `composio-linear`, and the person + * pressing Connect was told the app "is not connected as an individual person" — the exact + * opposite of true about the one kind of row that is ONLY ever connected as an individual + * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, + * refusing for want of a setting that has no bearing on a flow it does not enter. + * + * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the + * directory route says: the url is where the transport reads which app a call is against, and + * the id is a row name that happens to look similar. + * + * ONE ROW, BY ID. Every request to this route pays for this read, including the ones that fall + * through to the OAuth flow below, because the branch cannot be taken until the row is in hand + * — so what it costs has to be a lookup of three columns rather than the whole plugin surface. + * `serverAddress` answering `undefined` is an id naming no row, which falls through exactly as + * a missing row did when this was a `.find`. + */ + const row = await store.serverAddress(serverId); + const toolkit = row ? toolkitOf(row.url) : null; + if (row && toolkit) { + /* + * AN APP THAT NEEDS NO CREDENTIAL IS ANSWERED HERE, BEFORE `brokeredAccountFor` IS EVER + * ASKED WHOSE ACCOUNT THIS IS. + * + * `accountRefFor` — which that resolver calls — refuses a `none` scheme too, but in the one + * sentence it can write for all three routes that share it: "needs no account", with nothing + * about what that means or what the app can still do. That function speaks for confirm, + * re-check and end as well as connect, so it cannot know which of them is asking or reach for + * this route's fuller wording. Asked first and answered here instead, the sentence this route + * has always had for a no-auth app — naming what the app is and that a Bot can use it as it + * is — stays the one a person pressing Connect reads, rather than going dead code behind the + * resolver's terser refusal. + */ + if (schemeKind(row.authScheme) === "none") { + return context.json( + { + error: `${row.title} needs no account, so there is nothing to connect. A Bot granted its tools can use it as it is.`, + }, + 400, + ); + } + + /* + * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, THE SAME RESOLVER THE OTHER THREE + * BROKERED ROUTES CALL, AND FIRST IN THIS BRANCH SO NOTHING BELOW CAN ACT BEFORE IT HAS + * ANSWERED. A Personal app's account is the actor's own; a Shared app's is the deployment's, + * and only an administrator may reach that one — both decided there, once, rather than + * re-derived here. Its refusal already covers no broker being configured and a row that + * `accountRefFor` cannot resolve, in the same words this branch used to write itself. The + * `none` scheme is the one member of that refusal's vocabulary this branch now answers + * itself, for the reason just given — everything `accountRefFor` can still be asked about a + * row that reaches this call is never `none`. + */ + const resolved = await brokeredAccountFor(context, serverId); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const { account } = resolved; + + /* + * `composio` IS CHECKED AGAIN HERE FOR `tsc`'S BENEFIT AND NOT FOR THE REFUSAL. The function + * above closes over the same binding and has already returned for everybody it is unset for + * — this can only be reached once it answered — but that is a fact about the function's + * body and not one the type checker can see across the call above. Without this, every + * `composio.broker` read below the field and consent branches would be a `possibly + * undefined` it cannot actually be. + */ + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + + /* + * THE ACCOUNT IS `brokeredAccountFor`'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 one is passed in, 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. For a Shared app the account is the deployment's rather than whoever is + * signed in, decided above and never by anything the request carries. + */ + const existing = await store.brokeredConnection({ toolkit, account }); + if (existing) { + /* + * Named with the step to take rather than only refused: a second link would attach a + * second account behind a row that already says connected, and the way to a new one is + * through the connection they have. + * + * THE APP'S TITLE, NOT THE ROW'S ID. `composio-linear` is this deployment's name for a + * table row; "Linear" is the name of the thing the person connected and the only one of + * the two they have ever seen on a screen. An internal key in a sentence addressed to a + * person is both unhelpful and a small leak of how the rows are keyed. + * + * WHOSE ALREADY, SAID PLAINLY FOR A SHARED APP. "You already have an account" is true for + * a Personal app and false for a Shared one — nobody signed in holds that account, the + * deployment does — so a deployment account's refusal names the deployment's connection + * rather than putting the actor's own claim on it. + */ + return context.json( + { + error: + account.holder === "deployment" + ? `${row.title} already has a shared account connected. Disconnect it first if you want to connect a different one.` + : `You already have an account connected to ${row.title}. Disconnect it first if you want to connect a different one.`, + }, + 409, + ); + } + + /* + * AN APP WHOSE SECRET THE PERSON HOLDS IS ANSWERED HERE AND NEVER SENT AT A CONSENT SCREEN. + * + * Most of Composio's catalogue connects this way rather than through a consent screen: the + * person already holds an API key, so there is nothing to consent to, no url to mint and no + * return leg to build — the press that opens a vendor page for a consent app has to answer + * with a form instead, and the press after it carries what was typed into it. One route + * serves all three arms because the browser asks the same question every time: connect me to + * this app. The third is below: an app that needs no credential at all, which is answered + * with the fact rather than with either flow. + * + * 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 every kind 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; + + /** + * WHAT THIS FORK ANSWERS FOR EACH KIND OF APP STILL REACHABLE HERE, AND EVERY ARM IS REACHED + * BY DECIDING. + * + * Type-only and erased; see {@link Decides} in `./broker`. A member of this three-name + * vocabulary left unanswered fails `tsc` here by name, which is what this roster is for: the + * fork below reads it with a chain of `if`s, and an `if` chain has no opinion about the + * answers it was not written for. + * + * `none` IS NOT A MEMBER OF IT, AND IS EXCLUDED RATHER THAN ANSWERED `unreachable`. The + * branch above — before `brokeredAccountFor` is ever called — already refused a `none` + * scheme in the route's own words, so `authScheme` read here can never be one: this fork is + * about the three kinds of app that branch does not already dispose of. + * + * CRITERION. Every arm below is entered because {@link schemeKind} said so. No arm is the + * fall-through of the others. + * + * REASON. This file used to import {@link isFieldScheme} and not `schemeKind`, and compare one + * raw literal beside it — so the fork was two tests and a fall-through, and the fall-through + * was the consent arm. A scheme this deployment cannot read — a null, or a literal nothing + * here writes, which the row that ANSWERS for an app is free to carry because + * `mcp_servers.url` has no unique index — therefore landed in the arm that mints an + * authorization link at Composio for an app with no consent config behind it. That is a real + * act performed at the vendor on behalf of an answer nobody decided, and its only possible end + * is a link the person cannot complete. The three readers of this column in `./store` already + * fail closed on that value; this one was the last that did not. + */ + type _ConnectForkDecides = Decides< + Exclude, + { + key: "answers with the form the app publishes, and connects what is typed into it"; + consent: "mints a link at Composio for the person to finish at the vendor's own screen"; + unreadable: "refuses, and asks Composio nothing — it is not a consent app and there is no link to mint"; + } + >; + const kind = schemeKind(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 }); + + /* + * AN APP WITH NO BOXES CANNOT BE CONNECTED BY SOMEBODY TYPING, WHICH IS THE ONLY WAY THIS + * BRANCH CONNECTS ANYBODY. + * + * {@link isFieldScheme} admits exactly the schemes whose secret a PERSON holds, so an + * empty published list is not a short form — it is a form with nowhere to put the one + * thing the scheme is for. Every check below passes such a submission for want of anything + * to check, and what it makes is an account Composio marks `ACTIVE` because Composio does + * not grade what it is handed. An app with no probeable action then has that row written + * and drawn as connected on every screen here. + * + * ON THE SUBMISSION AND NOT ON THE PRESS ABOVE IT. The empty list is a true answer to + * "what does this app ask for" — {@link ComposioBroker.connectionFields} says so, and + * distinguishes it there from the scheme the app no longer publishes at all, which refuses. + * What is false is the account the next press would make, so that is the press that is + * refused. + * + * THE REMEDY IS AN ADMINISTRATOR'S, because nothing the person pressing Connect can do + * changes what the app publishes or which scheme this deployment recorded for it. + */ + if (published.length === 0) { + return context.json( + { + error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + }, + 400, + ); + } + + /* + * AND AN APP THAT PUBLISHES BOXES BUT REQUIRES NONE OF THEM IS THE SAME ACCOUNT WITH A FORM + * IN FRONT OF IT. + * + * THE GUARD BELOW READS `required` ON THE NAMES THAT ARRIVED; THIS ONE READS THE LIST. A + * published list with no required field in it leaves that guard nothing to be about, so + * `{"values":{}}` passes it vacuously — and so does a box holding spaces, and so does the + * default the form seeded itself from. What each of the three makes is an account carrying + * no credential, which Composio answers `ACTIVE` for because it does not grade what it is + * handed, and which an app with no probeable action leaves recorded and drawn as connected: + * the very end state the required guard exists to prevent, reached down the one path that + * guard cannot see. + * + * THE CLAIM THIS RESTS ON, STATED RATHER THAN ASSUMED: EVERY FIELD SCHEME IN COMPOSIO'S + * CATALOGUE PUBLISHES AT LEAST ONE REQUIRED FIELD, BECAUSE A FIELD SCHEME IS COMPOSIO'S OWN + * STATEMENT THAT THE PERSON HOLDS A CREDENTIAL. What makes it a claim about the catalogue + * rather than a guess is that "this app needs nothing" already has its own name there: + * `NO_AUTH` sits beside `API_KEY`, `BASIC`, `BEARER_TOKEN` and `BASIC_WITH_JWT` in the + * vendor's own scheme enum (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:9-24`), + * and this route answers it in its own branch a few lines above — so an app that reached + * HERE has told Composio it needs SOMETHING, and a field list requiring nothing is not that + * app saying it needs nothing after all. + * + * AND THE SDK SAYS WHAT THE SOMETHING IS, PER SCHEME. `connectedAccountAuthStates.types.ts` + * declares the shape of an ACTIVE account one scheme at a time: `BASIC` and `BASIC_WITH_JWT` + * require `username` and `password`, `BEARER_TOKEN` requires `token`, and `NO_AUTH` requires + * nothing at all — an ACTIVE no-auth account is the base row and no credential beside it. + * `API_KEY` is the one that marks `api_key` and `generic_api_key` optional, and it does so + * because the NAME of the box varies across the catalogue and both spellings occur in it, + * not because an API_KEY account may hold no key: the same declaration keeps a `catchall` + * for the third spelling. Three of the four field schemes therefore say in the vendor's own + * types that an account of theirs carries a credential, and the fourth says which credential + * it cannot name. + * + * WHAT WAS NOT DONE, AND IT IS THE HONEST LIMIT OF THE CLAIM: the live catalogue was not + * enumerated. There is no network and no Composio key on this path, so "no toolkit publishes + * a field scheme with an empty required list" is ARGUED from the vendor's own package rather + * than counted across its apps. The guard is written to fail in the safe direction for that + * reason. If such an app does exist, it becomes an app this deployment will not connect and + * SAYS SO, with a remedy addressed to the one person who could change it — which is the + * direction the empty-list guard directly above already chose, and the opposite of the + * silent credential-less account that is the alternative. + * + * THE STATE UNDER THIS GUARD IS ONE STATE AND NOT TWO, WHICH IS WHAT MAKES IT DECIDABLE AT + * ALL. "An app whose required flags we could not read" used to be indistinguishable from + * "an app that requires nothing", and it is no longer: {@link ComposioBroker.connectionFields} + * reads each flag through `flagOf`, which answers the caller's default for an ABSENT flag + * and refuses one that is PRESENT and not a boolean. So a list arriving here with every + * `required` false is the vendor's own no on every row — never a shape this deployment + * failed to read, because that shape never gets past the adapter. + * + * ON THE APP AND NOT ON THE SUBMISSION, AND THE SEEDED DEFAULT IS WHY. A guard that demanded + * one non-blank value would be satisfied by the form's own work: the form draws each box + * holding the default the app published and posts every published name back whether or not + * anybody touched it, so a person who types nothing at an app publishing an optional + * `base_url` submits that default — non-blank, unchosen, and a credential in no sense. The + * thing that is false here is the app's field list, so the app is what is refused. + * + * ON THE SUBMISSION AND NOT ON THE PRESS ABOVE IT, for the empty-list guard's reason: the + * list is a true answer to "what does this app ask for", and what is false is the account + * the next press would make. + * + * THE SENTENCE NAMES THE BOXES THE APP DOES PUBLISH AND NO VALUE OF ANY KIND. These names + * are the VENDOR's, read off the list just fetched rather than off the request, and they are + * the whole of what an administrator needs to see what the app is publishing now. Nothing + * submitted appears — not even a value that merely matches a published default, because on + * this request that value is the caller's text rather than the vendor's. + */ + if (published.every((field) => !field.required)) { + const optional = published.map((field) => field.name).join(", "); + return context.json( + { + error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one Composio publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + }, + 400, + ); + } + + /* + * 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. + * + * AND THE BAG HOLDS ONLY WHAT WAS PUT IN IT, WHICH `{}` DOES NOT. Every name here is the + * VENDOR's — chosen by whoever publishes the app at Composio and never by this deployment — + * and a plain object literal answers for names nobody submitted: `values.constructor` and + * `values.toString` come back as functions off `Object.prototype`, so the required guard + * below calls `.trim()` on a function and answers a person who typed nothing wrong with a + * 500. A prototype value that happened to be TEXT would be worse still, because that guard + * would count a credential nobody supplied and connect the account anyway. The other + * direction fails silently: `values["__proto__"] = "acme"` on a literal reaches the + * prototype setter, which ignores a string, so a value somebody typed is dropped between + * the check that admitted it and the store that was meant to receive it — an account made + * at Composio without one of the values the app publishes, marked `ACTIVE` because Composio + * does not grade what it is handed, and drawn as connected on every screen here. + * + * A NULL-PROTOTYPE BAG IS THE WHOLE FIX AND IT IS THE SAME ONE THE FORM USES. Nothing is + * inherited, so a read answers `undefined` exactly when nobody submitted that name and a + * write stores what it was given under whatever name it was given. `Object.keys` — which is + * what the audit row is built from one layer down — reads the same either way, and the + * names are all this object's contents may ever appear as. + */ + const names = new Set(published.map((field) => field.name)); + const values: Record = Object.create(null); + 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; + } + + /* + * AND WHAT THE APP SAID IT CANNOT DO WITHOUT HAS TO BE THERE, WHICH THE LOOP ABOVE NEVER + * ASKED. That loop checks every name that ARRIVED; `required` is a fact about a name that + * did not. A body of `{"values":{}}` passes it vacuously — no name is unpublished, no value + * is not text — and what that connects is an account carrying no credential at all, which + * Composio accepts because it does not grade what it is handed and which an app with + * nothing safe to probe leaves recorded and drawn as connected. + * + * `required` HAD ONE READER AND IT WAS THE BROWSER. It rode the field out to the form and + * became an HTML attribute, which is a courtesy to somebody filling boxes in and no kind of + * guard: this route is reachable without that form, and the form itself accepts a space. + * + * BLANK IS ABSENT, AND WHITESPACE IS BLANK. The person's claim is that they filled the box + * in, and a box holding spaces is one they did not — no vendor reads a key made of + * whitespace, so admitting it buys exactly the account this guard exists to refuse, with a + * value in it that makes the state harder to read rather than easier. The JUDGEMENT is made + * on the trimmed text; what travels is what was typed, because trimming somebody's + * credential on its way out is editing it, and a key that really does carry padding is the + * vendor's to reject in its own words. + * + * OPTIONAL BOXES ARE UNTOUCHED BY THIS, and the distinction is the whole of why it reads + * `required` rather than counting values. The form seeds each box from the default the app + * published and posts every published name back whether or not anybody touched it, so a + * blank optional value is what an ordinary submission carries. + * + * THE SENTENCE NAMES THE FIELDS AND NOTHING ELSE. These names are the VENDOR's, read off + * the list this deployment just fetched rather than off the request — which is what sets + * them apart from the refusal above, where the offending name is the caller's own text — + * and no value of any kind appears here, for the reason the whole of this branch is built + * around: a submitted value is somebody's own credential and belongs in no message, no log + * and no row. + */ + const missing = published + .filter( + (field) => field.required && !(values[field.name] ?? "").trim(), + ) + .map((field) => field.name); + if (missing.length > 0) { + return context.json( + { + error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to Composio. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, + }, + 400, + ); + } + + 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, + account, + by: 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); + } + } + + /* + * A SCHEME THIS DEPLOYMENT CANNOT READ IS REFUSED, AND IS NOT WALKED INTO THE CONSENT FLOW. + * + * `unreadable` is a null column or a literal nothing here writes — which is an ordinary row + * rather than a corrupt one: `mcp_servers.url` carries no unique index, so the row that + * answers for an app is not always the row an enable wrote a scheme onto, and a row may have + * been inserted by hand or restored from a deployment that knew other names. Reached by + * elimination, it arrived at the arm below and minted an authorization link at Composio for an + * app this deployment holds no consent config for — a call spent at the vendor, and a link + * whose only possible end is the person failing to complete it at a screen that has nothing + * to ask them. + * + * THE REMEDY IS AN ADMINISTRATOR'S, because nothing the person pressing Connect can do + * changes which scheme this deployment recorded for the app. Re-adding it is what writes the + * column again, which is the same remedy the field branch above names for its own two + * unreadable-app states. 400 rather than 503: it is a fact about this one app's row and not + * about the deployment's settings. + */ + if (kind === "unreadable") { + return context.json( + { + error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to Composio and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way Composio connects it now.`, + }, + 400, + ); + } + + /* + * NO APP URL IS A REFUSAL, NOT A LINK WITH NO WAY BACK. + * + * The address below is where Composio sends this person once they have consented, and it has + * to be absolute: the consent screen is on another company's origin, so a relative path + * resolves against theirs. A deployment that cannot say where its own pages are cannot + * produce one — and minting the link anyway would leave somebody stranded on Composio's + * hosted page having just granted access, with no route back to the deployment that asked + * for it and nothing here knowing it happened. + * + * The OAuth flow below refuses for its missing `OPENBOT_PUBLIC_URL` in these same terms and + * for this same reason. `OPENBOT_APP_URL` is the setting here because the two addresses are + * genuinely different: the API is one origin and the browser app is another, and it is a + * page this person is coming back to rather than an endpoint. + * + * BELOW BOTH BRANCHES ABOVE AND NOT IN FRONT OF THEM, FOR THE REASON THIS BRANCH'S OWN HEADER + * GIVES ONE GUARD EARLIER. Neither an app whose secret the person types nor one that needs no + * credential at all mints a link or has a return leg, so this setting has no bearing on either + * flow — and standing before the fork, this guard meant no key app and no no-auth app could be + * connected on a deployment without `OPENBOT_APP_URL`, refused in the name of a remedy that + * would not have helped. It stays after the one-account guard for + * the reason that guard's own comment gives: somebody who already has an account attached is + * told the step to take, rather than handed an operator's configuration complaint about a + * link that was never going to be minted for them. + */ + if (!connect?.appUrl) { + return context.json( + { + error: + "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", + }, + 503, + ); + } + + /* + * WHERE THE CONSENT COMES BACK TO, BUILT HERE AND NEVER READ OFF THE REQUEST. + * + * Composio sends the person to this address when they are done, so whoever chooses it + * chooses where somebody lands holding a just-completed consent. A url taken from the body, + * the query or a header would therefore be an open redirect with a consent screen in front + * of it — the exact thing {@link ConnectOrigin} exists to stop on the OAuth flow below, and + * it is narrowed here in the same way: the caller may name one of two PAGES, and the origin + * underneath them is this deployment's configured app URL in both cases. + * + * Both pages confirm on load, which is what makes either of them a correct destination: the + * return trip carries nothing signed, so arriving proves nothing, and the page asks Composio + * whether the account is really attached before anything here says it is. + * + * FORCED TO "admin" FOR A DEPLOYMENT ACCOUNT, AND NOT LEFT TO THE QUERY. A Shared app's + * settings page shows nobody's own connection — the account the consent is about belongs to + * the deployment — so "settings" would land an administrator who just granted it back on a + * page with nothing of theirs to confirm. The admin screen is the one that reads a Shared + * app's row, and it is where this consent began. + */ + const returnTo: ConnectOrigin = + account.holder === "deployment" + ? "admin" + : context.req.query("returnTo") === "admin" + ? "admin" + : "settings"; + + /* + * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to the connection + * resolved above — this person's own for a Personal app, or the deployment's for a Shared + * one — 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, + * or the deployment's shared one, for as long as it stays valid — which is why the failure + * below answers with the vendor's sentence and never with what was being minted when it + * failed. + */ + let redirectUrl: string; + try { + ({ redirectUrl } = await composio.broker.authorize({ + account, + toolkit, + /* + * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. + * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` + * entry — and nothing between there and Composio has ever looked at it, so + * `openbot.example.com` with the scheme left off builds a callback that is not a + * callback. That failure lands after somebody has consented, on the vendor's page, where + * this deployment cannot tell them anything; the guard moves it to before the link is + * minted, where the sentence reaches an operator who can set the variable. + */ + returnUrl: brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + ), + })); + } catch (error) { + const refusal = brokerRefusal( + error, + `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + return context.json({ authorizationUrl: redirectUrl }); + } + + if (!connect?.publicUrl) { + return context.json( + { + error: + "This deployment has no public URL configured, so it cannot complete a consent flow. Set OPENBOT_PUBLIC_URL.", + }, + 503, + ); + } + + const entry = catalogueEntry(serverId); + if (entry?.auth.kind !== "user-oauth") { + return context.json( + { error: `${serverId} is not connected as an individual person.` }, + 400, + ); + } + + /* + * A dynamic entry introduces the deployment itself on first use; a manual one still waits + * for an administrator. Registration lives here, on the one handler that already refuses + * without OPENBOT_PUBLIC_URL — the redirect URI it registers is guaranteed to exist. + */ + /* + * A vendor in the catalogue that nobody has added to this deployment reaches here, gets past + * every check above — the entry is real — and then asks the store for a client it cannot have, + * because there is no server row to hold one. `ensureOAuthClient` says so by throwing, and + * unhandled that was a 500 on the one path where a person is trying to connect their account. + * + * The same 409 as a vendor whose client an administrator has not pasted in yet, because it is + * the same situation: the person pressing Connect has no step to take, and an administrator has + * one. The sentence names the step rather than the exception. + */ + let client: OAuthClient | null; + try { + client = + (await store.oauthClientFor(serverId)) ?? + (entry.auth.clientRegistration === "dynamic" + ? await store.ensureOAuthClient(serverId, actorEmail(context)) + : null); + } catch (error) { + if (error instanceof CatalogueEntryUnknownError) { + return context.json( + { + error: `${entry.title} has not been added to this deployment yet. An administrator has to add it first.`, + }, + 409, + ); + } + throw error; + } + if (!client) { + if (entry.auth.clientRegistration === "dynamic") { + return context.json( + { + error: `${entry.title} would not register this deployment, or could not be reached. Try again, and check the vendor's status if it persists.`, + }, + 502, + ); + } + return context.json( + { + error: `${entry.title} has no OAuth client registered yet. An administrator has to add one first.`, + }, + 409, + ); + } + + /* + * Where to come back to, as one of two names rather than a URL the caller chose. + * + * Read from the query and narrowed immediately, so an unrecognised value is the default rather + * than something carried into a sealed state. See {@link ConnectOrigin}: a destination that could + * name another origin is an open redirect with a consent screen in front of it. + */ + const returnTo = + context.req.query("returnTo") === "admin" ? "admin" : "settings"; + + const verifier = createVerifier(); + return context.json({ + authorizationUrl: authorizationUrlFor({ + auth: entry.auth, + clientId: client.clientId, + redirectUri: redirectUriFor(connect.publicUrl), + state: await sealConnectState( + { userId: context.var.actor.id, serverId, verifier, returnTo }, + connect.encryptionKey, + ), + codeChallenge: challengeFor(verifier), + }), + }); + }); + + /** + * Which app, and whose account in it, one of the four routes below is about, or the refusal that + * ends it — decided by the row's mode and the signed-in person, never by anything in the request. + * + * Each of them acts on a brokered connection and on nothing else, so each asks the same 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, or at a Shared app + * they have no business touching, should not be able to drift into four sentences. + * + * THE APP COMES OFF THE ROW'S URL AND NEVER OFF ITS ID, for the reason the directory route and + * the connect branch above both give: the url is where the transport reads which app a call is + * against, and the id is a row name that happens to look similar. + * + * ONE ROW, BY ID, AND CONFIRM IS WHY. Both brokered account screens call that route from an + * effect when they mount, so this read runs on every page load — and it used to be + * `listServers`, which materialises every tool and every grant in the deployment to answer + * whether one row is brokered. + * + * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this + * deployment really has — what is wrong is that its connection does not live at Composio, and + * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an + * id naming no row at all, which is the same answer from the caller's side. + * + * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is + * {@link BrokerUnconfiguredError}'s own message rather than a sentence written here. + * + * THE ACCOUNT ITSELF COMES FROM `store.accountRefFor`, WHICH READS THE ROW AND THE ACTOR AND + * NEVER THE REQUEST. A Personal app answers with the actor's own identity; a Shared app answers + * with the deployment's — one account, decided here, rather than whatever a caller could name in + * a body. A Shared app's account belongs to the deployment and not to whoever is signed in, so an + * actor who is not an administrator is refused here, once, instead of in four routes below. + * + * THE CONNECT ROUTE'S BROKERED BRANCH IS THE FOURTH, CALLED ONLY ONCE IT ALREADY KNOWS THE + * BRANCH IS TAKEN. That route still asks `toolkitOf` itself before this function is ever reached + * — a row that is not brokered has an OAuth flow below it to fall through to, and refusing it + * here, where there is no such flow to fall through to, would be wrong. Once that question is + * settled and the answer is yes, whose account the connection is for is exactly the question + * this function exists to answer once, rather than a fourth place a Shared app's account could + * be decided differently from the other three. + */ + const brokeredAccountFor = async ( + context: { var: AppVariables }, + serverId: string, + ): Promise< + | { + toolkit: string; + account: AccountRef; + title: string; + refusal?: undefined; + } + | { + toolkit?: undefined; + account?: undefined; + title?: undefined; + refusal: { error: string; status: 400 | 403 | 503 }; + } + > => { + const row = await store.serverAddress(serverId); + const toolkit = row ? toolkitOf(row.url) : null; + if (!toolkit) { + return { + refusal: { + error: "That app is not reached through a broker.", + status: 400, + }, + }; + } + if (!composio) { + return { + refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, + }; + } + const resolved = await store.accountRefFor(serverId, context.var.actor.id); + if ("refusal" in resolved) { + return { refusal: { error: resolved.refusal, status: 400 } }; + } + if ( + resolved.ref.holder === "deployment" && + context.var.actor.role !== "admin" + ) { + return { + refusal: { + error: "An administrator connects shared apps.", + status: 403, + }, + }; + } + return { toolkit, account: resolved.ref, title: resolved.title }; + }; + + /** + * 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 AT THE ROUTE — `brokeredAccountFor` is where that + * happens instead. A Personal app's confirm is not an administrative act, so the route itself + * stays open to anybody signed in; a Shared app's is, and it is the resolver that tells the two + * apart by the account it resolves rather than this route carrying `requireAdmin` unconditionally. + */ + routes.post( + "/servers/:id/connection/confirm", + requireUser, + async (context) => { + const resolved = await brokeredAccountFor( + context, + context.req.param("id"), + ); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, AND THERE IS NO SECOND SOURCE FOR IT. + * 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. `by` is the session's actor, for the + * trail, and is the same id for a Personal app's own account and an administrator confirming a + * Shared app's. + * + * The store's answer is passed straight back rather than restated here. `connected` is what + * Composio said, and a shape invented at this layer would be a second opinion about a fact + * only the vendor holds. + */ + try { + return context.json( + await store.confirmBrokeredConnection({ + toolkit: resolved.toolkit, + account: resolved.account, + by: context.var.actor.id, + }), + ); + } catch (error) { + /* + * A BROKER THAT WOULD NOT ANSWER IS NOT A CONNECTION THAT IS ABSENT. + * + * This route is called on every page load, so the tempting answer to a failure is + * `{ connected: false }` — and that would be this deployment inventing a fact only Composio + * holds, drawing "Not connected" over a live account and, one step on, deleting the row + * that says otherwise. The store deletes on a NO from the vendor, and a failure is not a + * no. So the page is told the ask failed, and what it goes on showing is the last answer + * Composio gave rather than a guess about this one. + */ + const refusal = brokerRefusal( + error, + "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + + /** + * Try this person's key against the app, because they pressed the button that asks. + * + * A BUTTON, AND NEVER A PAGE-LOAD EFFECT, which is the one thing a caller of this route has to + * know. Composio never re-checks a key — it accepts one when it is typed and says nothing about it + * again — so this is the only thing in the product that can correct a row whose key was rotated, + * revoked or left to expire. That is also the argument somebody will make for calling it from an + * effect on mount, and it is wrong: the call goes out to the app on the person's OWN account and + * against their own rate limit at the vendor, so verifying on every render would spend somebody's + * quota at Linear to redraw one word on a settings page. The confirm route above is the one that + * runs on mount; it asks Composio about its own records and costs the person nothing. + * + * AND IT IS NOT A CONNECT. The store's argument is made there in full: the account already exists, + * so nothing here creates one, nothing withdraws one when the key turns out to be bad — their + * account stays, it is their key that is wrong — and nothing changes but the verification and its + * date. + * + * A PROBE THAT RAN AND FAILED COMES BACK AS A FAILURE, never as a 200 saying `verified: false`. + * That flag is also what an app publishing nothing safe to call produces, and the row drawing this + * answer cannot tell the two apart — so an answer would quietly drop the Re-check button in + * exactly the state somebody needs it, having just fixed their key, while telling them nothing had + * ever been checked. The store raises with Composio's own sentence in it, and this passes that + * through as a refusal the browser surfaces. The only `verified: false` that arrives as an answer + * is the one carrying `probe: null`, which says there was nothing to check with. + * + * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, as on the two routes around it and for + * the sharper reason this one adds: an account 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 AT THE ROUTE, for confirm's reason: `brokeredAccountFor` + * is where a Shared app's account is told apart from a Personal one's, and only the former needs + * an administrator. + */ + routes.post( + "/servers/:id/connection/recheck", + requireUser, + async (context) => { + const resolved = await brokeredAccountFor( + context, + 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, + account: resolved.account, + by: 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 account — this person's own, or the deployment's for a Shared app — 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 account this app held, so a delete that ran before the revoke could leave a live + * grant on somebody's mailbox that nothing here can reach. What comes back is what was asked for + * — `vendorRevocationRequested` false is a grant that was already gone — and it is passed through + * rather than rewritten, because telling those two apart is the whole value of the field. + * + * `reason` IS "self" OR "admin" BECAUSE OF WHOSE ACCOUNT `brokeredAccountFor` RESOLVED, not + * because of who is asking in general: a Personal app's account is always the actor's own, so it + * ends as "self"; a Shared app's account is the deployment's, and only an administrator reaches + * this route for one, so it ends as "admin". The other word the store takes is `person_removed`, + * which belongs to an administrator offboarding somebody from the People screen, and + * `mode_switched`, filed when an app moves between Personal and Shared. The trail tells these + * apart by this word and by whether `by` and the account's own id differ. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED AT THE ROUTE, for confirm's reason: + * `brokeredAccountFor` is where a Shared app's account is told apart from a Personal one's and + * refused to anybody but an administrator. + */ + routes.delete("/servers/:id/connection", requireUser, async (context) => { + const resolved = await brokeredAccountFor(context, context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + + /* + * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, here as on confirm and for a sharper + * reason: an account a caller could name would be a DELETE that revokes somebody else's grant + * at the vendor. `by` is read once, from `context.var.actor`, and is the acting administrator + * for a Shared app's account rather than the account's own id — nothing in the body or the + * query is looked at at all. + */ + try { + return context.json( + await store.disconnectBrokered({ + toolkit: resolved.toolkit, + account: resolved.account, + by: context.var.actor.id, + reason: resolved.account.holder === "deployment" ? "admin" : "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/src/plugins/route-groups/context.ts b/server/src/plugins/route-groups/context.ts new file mode 100644 index 000000000..87635f5f8 --- /dev/null +++ b/server/src/plugins/route-groups/context.ts @@ -0,0 +1,137 @@ +import type { Hono, MiddlewareHandler } from "hono"; +import type { BotAccessCheck } from "../../agents/profile-policy"; +import type { AppVariables } from "../../auth/guards"; +import type { AccountModeSwitch } from "../account-mode"; +import { brokerSentence, type ComposioBroker } from "../broker"; +import { vendorSentence } from "../composio"; +import type { SharedUseStore } from "../shared-use-store"; +import type { PluginStore } from "../store"; + +/** + * What the plugin route groups share. See `../routes.ts`, which builds one of these and mounts + * every group on it in order. + */ + +/** + * Whether the person a consent was started for still has access to this deployment. + * + * A seam rather than an import, because these routes have no business knowing what a person is or + * where the deny list lives — and because the answer has to come from the deployment as it is when + * the callback lands, not from what was true when the flow started. + * + * False for somebody who was removed while they were away at the vendor's consent screen, and false + * for a user id that names nobody at all. Both are the same refusal: there is no live person for + * this grant to belong to. + */ +export type ConnectingPersonCheck = (userId: string) => Promise; + +/** + * What somebody is told when the broker itself failed, and what is deliberately kept out of it. + * + * EVERY BROKERED ROUTE NEEDS THIS AND NONE OF THEM USED TO HAVE IT. A listing, a connect, a confirm + * and a disconnect all end in a call to another company's API, and an unhandled rejection out of any + * of them is a bare 500 with the vendor's thrown object on this deployment's console. That object is + * the whole HTTP response — headers, trace ids, rate-limit counters — and the person reading the 500 + * gets none of it and no sentence either. A wrong `COMPOSIO_API_KEY` is the first failure a new + * operator meets, and it is the one this used to answer worst. + * + * `vendorSentence` IS THE SAME ONE THE TRANSPORT USES, for the same reason it exists there: the + * useful sentence — "Invalid API key", "No connected account found for user …" — is nested two + * levels inside `cause` beside everything that must not be shown, so this reaches in for that one + * string and takes nothing else. Null from it is a failure this deployment cannot explain, and the + * caller's own generic sentence is what a reader gets instead of the vendor's placeholder. + * + * NOTHING FROM THE REQUEST OR THE ANSWER TRAVELS WITH IT. Not the API key, which never leaves the + * adapter; not a connect link, which is a bearer capability handed to one browser; and not the + * thrown object, whether by spreading it, stringifying it or logging it. + * + * 502 RATHER THAN 500, because nothing here broke: this deployment asked a third party and the third + * party did not answer usefully, which is the same reading the dynamic-registration failure below + * already gives. + * + * `./broker`'s `BrokerRefusalError` IS THE EXCEPTION, AND IT IS A WIDER ONE THAN IT WAS. It used to + * be the one no-key state; it is now every refusal the broker layer AUTHORED — no key, an app whose + * authorization config this deployment never created or cannot use, a consent the vendor answered + * with nowhere to send anybody, a catalogue too large to be sure of. All of them share the property + * that made the first one special: Composio answered, this deployment decided, and the sentence + * already names the step that fixes it. So the message is passed through and the status stays 503, + * which reads correctly for every one of them — the brokered surface is unavailable for this app + * until somebody changes something here, rather than unavailable because a third party is down. The + * generic sentence below is for failures this deployment genuinely cannot explain, and answering one + * of these with it would tell an administrator to check a key that is fine. + */ +export function brokerRefusal( + error: unknown, + generic: string, +): { error: string; status: 502 | 503 } { + const authored = brokerSentence(error); + if (authored) return { error: authored, status: 503 }; + return { error: vendorSentence(error) ?? generic, status: 502 }; +} + +/** + * What a reader is told when Composio would not answer with its directory. + * + * One constant because two routes make that call — browsing the catalogue and enabling an app out of + * it — and a reader meeting the same failure through two doors should not meet two sentences. It + * names the setting rather than quoting it: a key that was pasted with a space in it, or one for + * another project, is the likeliest reason Composio will not talk to this deployment at all. + */ +export 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."; + +/** What the connect flow needs that the store does not hold. See `createPluginRoutes`. */ +export type PluginConnectOptions = { + encryptionKey: string; + /** + * Whether the person a state names may still connect an account here. + * + * Required rather than optional, unlike everything else that arrived on this object as "one more + * parameter". The callback is sessionless on purpose, so this is the ONLY thing asking whether + * the identity in the state is still one this deployment recognises — and a deployment that + * forgot to pass it would complete a consent for somebody who was removed ten minutes ago and + * write a live refresh token nothing will ever revoke. + */ + personHasAccess: ConnectingPersonCheck; + /** + * Whether this deployment holds a shared secret a Bot may present when calling a tool back. + * + * A boolean about configuration, never the secret. Without it, a Bot can only call back if it + * holds a credential issued to it alone, and a Bot with neither is refused before the call ever + * reaches the grant, the boundary or the trail. The Bots screen needs this to stop promising a + * grant the deployment cannot honour. + */ + botsMayCallBack?: boolean; + publicUrl: string | undefined; + /** + * Where the app is, which is not where this API is. + * + * The callback lands here and has to send the person back to a page. A relative redirect would + * put them on this server's origin, which locally is a Vite-less port that serves no pages at + * all — so the flow would complete correctly and end on a 404. + */ + appUrl: string | undefined; +}; + +/** + * Everything a group of plugin routes is mounted with: the router they are added to, what + * `createPluginRoutes` was given, and the per-request helpers more than one group asks. + */ +export type PluginRouteContext = { + routes: Hono<{ Variables: AppVariables }>; + store: PluginStore; + requireUser: MiddlewareHandler<{ Variables: AppVariables }>; + canUseBot: BotAccessCheck; + connect: PluginConnectOptions | undefined; + composio: { broker: ComposioBroker } | undefined; + shared: { modes: AccountModeSwitch; use: SharedUseStore } | undefined; + actorEmail: (context: { var: AppVariables }) => string; + skillActor: (context: { var: AppVariables }) => { + id: string; + isAdmin: boolean; + }; + skillRefusal: ( + context: { var: AppVariables }, + slug: string, + ) => Promise; +}; diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts new file mode 100644 index 000000000..dd1b0cb3a --- /dev/null +++ b/server/src/plugins/route-groups/grants.ts @@ -0,0 +1,437 @@ +import { PERSON_INITIATOR } from "../../audit"; +import type { AppVariables } from "../../auth/guards"; +import { + asSharedUseApproval, + exposureOf, + type SharedUseApproval, +} from "../shared-use"; +import { + CatalogueEntryUnknownError, + isDeploymentFault, + type PluginKind, + PluginRefusedError, +} from "../store"; +import type { PluginRouteContext } from "./context"; + +/** Granting and revoking a server, skill or Bot, and a Bot's tool calls back. */ +export function mountGrantRoutes({ + routes, + store, + requireUser, + canUseBot, + shared, + actorEmail, + skillActor, +}: PluginRouteContext) { + /** + * Grant and revoke, for both kinds, through one pair of endpoints. + * + * The store keeps one grant table because the question is the same either way; the API says the + * same thing, so a reader is never left wondering whether skills are governed differently. + */ + + /** + * The kinds of grant this API will act on. + * + * CHECKED AT RUNTIME, not only in the types. `kind` arrives in a JSON body, so a type annotation + * on it is a comment: before this, anything at all could be written into the grant table through + * the ordinary endpoint, and one kind that was never meant to be settable this way already could. + */ + const GRANT_KINDS = new Set(["mcp", "skill", "bot"]); + const asGrantKind = (value: unknown): PluginKind | null => + typeof value === "string" && GRANT_KINDS.has(value as PluginKind) + ? (value as PluginKind) + : null; + + /** + * May this person put this on that Bot? + * + * MCP is an administrator's, always: it reaches another company's system with a stored credential. + * A skill is an instruction, so somebody may put their own skill on a Bot they own, and neither + * half alone is enough. Both are checked here rather than in the store, because this is the only + * place that knows who is asking. + */ + async function enablementRefusal( + context: { var: AppVariables }, + kind: PluginKind, + ref: string, + agentId: string, + /** + * Which way this is going, because they are not symmetric. + * + * TAKING SOMETHING AWAY IS ALWAYS ALLOWED. The checks below decide whether a grant should exist, + * and applying them to a revoke turns every one of them into a trap: a `bot` grant made before + * the grantee moved to its own endpoint — or before this check existed — could never be removed, + * because the reason it is wrong is the same reason the revoke was refused. An administrator + * looking at a dead row in the UI would have had no way to delete it. + */ + intent: "grant" | "revoke", + ): Promise { + const actor = skillActor(context); + + if (kind === "mcp") { + if (!actor.isAdmin) { + return "An administrator decides which Bots may reach a tool."; + } + // Taking something away is always allowed: see the note on `intent`. It matters more here + // than anywhere else, because the rows this check exists to prevent are the same shape as the + // rows #572's migration had to delete, and an administrator has to be able to remove one by + // hand rather than wait for a migration. + if (intent === "revoke") return null; + + /* + * A grant that could never do anything is refused rather than stored, which is the rule the + * `bot` branch below already states and this one did not follow. + * + * Nothing checked that the app existed. `store.grant` is a bare upsert, so a ref naming an + * app this deployment has not added was stored and then invisible: the surface that reports a + * grant nothing advertises is built per server row, and there was no row. #572 fixed the way + * these rows were MADE — a removal used to leave its grants behind — and added the migration + * that deleted the ones already there. This is the other door into the same room. Add the app + * afterwards and the id is the same, the action names are the same, and every one of those + * grants resolves, with nobody having granted anything and no row in the trail saying so. + * + * THE SERVER HALF ONLY. A grant naming a tool the server has stopped advertising is a + * supported state, held and not offered, because what a vendor lists today is not what + * somebody decided yesterday — checking the tool here would refuse a re-grant of exactly the + * tool an administrator is trying to restore. A grant naming no app at all is not that state. + */ + const [serverId] = ref.split("/"); + if (!(await store.serverExists(serverId ?? ""))) { + return `${serverId} is not an app this deployment has added, so there is nothing for a Bot to reach. Add it first, and its tools can be granted then.`; + } + /* + * The Bot has to exist as well, which the `bot` branch below already says in the same words. + * `plugin_grants.agent_id` is a foreign key, so a grant naming a Bot nobody has reached the + * insert and failed there, and a person's mistyped Bot id answered 500 with no body where + * every other refusal on this route is a 403 with a sentence. + */ + if (!(await store.agentIsRegistered(agentId))) + return "There is no such Bot."; + return null; + } + + if (kind === "bot") { + /* + * THE ROLE IS CHECKED BEFORE ANYTHING IS LOOKED UP, and that ordering is the point. + * + * One Bot reaching another lets it spend that Bot's model calls, wake its computer and reach + * whatever it may reach, so it is an administrator's decision rather than something somebody + * attaches to a coworker they own. But this route only requires a signed-in user, so every + * refusal below is readable by anybody: checking whether the Bot exists, and whether it runs + * here, before this line handed out three distinguishable answers and turned a 403 into an + * oracle for other people's private Bots. `handoff.ts` in this same feature collapses exactly + * this, deliberately, and this had it backwards. + */ + if (!actor.isAdmin) { + return "An administrator decides which Bots may hand work to another Bot."; + } + // Taking something away is always allowed: see the note on `intent`. + if (intent === "revoke") return null; + + /* + * A grant that could never do anything is refused rather than stored, from both ends. + * + * The GRANTEE must exist. Remote Bots execute through the same signed callback and handoff + * desk as built-in Bots, so where their model loop runs does not change the grant. + * + * The TARGET only has to exist. Being handed work is not the same as being able to hand it on, + * so a target at its own endpoint is perfectly ordinary — but `ref` is bare text with no + * foreign key, so a typo stored happily and every hop then refused as not-granted. + */ + /* + * A Bot cannot be granted itself. The desk refuses a self-hop outright — "a Bot cannot hand + * work to itself" — so the row is dead the moment it is written, and reads as configured. + */ + if (ref === agentId) { + return "A Bot cannot be granted itself to hand work to."; + } + if (!(await store.agentIsRegistered(agentId))) + return "There is no such Bot."; + if (!(await store.agentIsRegistered(ref))) { + return `There is no Bot called ${ref} to hand work to.`; + } + return null; + } + + if (actor.isAdmin) { + // Taking something away is always allowed: see the note on `intent`. + if (intent === "revoke") return null; + /* + * A grant naming no skill is refused for an administrator too, as the `mcp` branch refuses a + * grant naming no app. `store.grant` is a bare upsert and a Bot's skills are read by slug + * alone, so the row sat there granting nothing until somebody wrote a skill under that name, + * and then that skill was on this Bot with nobody having put it there. On a Bot the + * deployment shares, that is one person's instructions answering everybody: + * `skill-uninstall-grants.integration.test.ts` guards the same room from the uninstall side. + */ + if ((await store.skillOwner(ref)) === undefined) { + return `There is no skill called ${ref}.`; + } + // And the Bot, for the reason the `mcp` branch gives: an administrator's grant naming one + // nobody has failed on the foreign key. Everybody else is asked below, through `agentOwner`. + if (!(await store.agentIsRegistered(agentId))) + return "There is no such Bot."; + return null; + } + + const owner = await store.skillOwner(ref); + if (owner === undefined) return `There is no skill called ${ref}.`; + if (owner !== actor.id) { + return owner === null + ? `${ref} belongs to this deployment. An administrator decides which Bots use it.` + : `${ref} is somebody else's skill.`; + } + + const botOwner = await store.agentOwner(agentId); + if (botOwner === undefined) return "There is no such Bot."; + if (botOwner !== actor.id) { + // Including the shared Bots this deployment publishes, which have no owner at all: a skill + // one person wrote would otherwise change how a Bot answers everybody. + return "You can only put your own skills on Bots you own."; + } + return null; + } + + routes.post("/grants", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + kind?: unknown; + ref?: string; + agentId?: string; + approval?: unknown; + } | null; + const kind = asGrantKind(body?.kind); + /* + * The body is JSON, so the annotation is a wish: `{"ref":123,"agentId":[]}` passes a + * truthiness check and then reaches the store, where Drizzle compares a text column against + * a number and the request answers 500. A ref and a Bot id are non-empty strings here. + */ + if ( + !kind || + typeof body?.ref !== "string" || + !body.ref.trim() || + typeof body.agentId !== "string" || + !body.agentId.trim() + ) { + return context.json( + { error: "A kind, a ref and a Bot are required." }, + 400, + ); + } + /* + * Trimmed before it is checked and before it is stored, the way the DELETE twin below already + * does. Without this a padded ref refuses with the padding quoted back (`" app" is not an app`), + * and a grant that did pass would be stored under a ref no revoke could match. + */ + const grantRef = body.ref.trim(); + const grantAgentId = body.agentId.trim(); + const refusal = await enablementRefusal( + context, + kind, + grantRef, + grantAgentId, + "grant", + ); + if (refusal) return context.json({ error: refusal }, 403); + + /* + * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. Without an approval + * the gate refuses every call the grant just allowed, so a grant with none sent approves the + * Bot's exposure as it stands now — unless an approval is already recorded, which is an + * administrator's earlier decision and not this request's to widen. "Is Shared" is the app's + * answer (its answering row), never the granted row's own column. + */ + let approval: SharedUseApproval | null = null; + let appId = ""; + if (kind === "mcp" && shared) { + const [serverId] = grantRef.split("/"); + appId = await shared.use.appIdOf(serverId ?? ""); + if ((await store.serverAddress(appId))?.accountMode === "shared") { + if (body?.approval !== undefined) { + approval = asSharedUseApproval(body.approval); + if (!approval) { + return context.json( + { error: "That approval is not one this deployment can read." }, + 400, + ); + } + } else if (!(await shared.use.approvalFor(grantAgentId, appId))) { + approval = exposureOf(await shared.use.botFacts(grantAgentId)); + } + } + } + await store.grant(kind, grantRef, grantAgentId, actorEmail(context)); + if (approval && shared) { + await shared.use.setApproval({ + botId: grantAgentId, + serverId: appId, + approval, + by: context.var.actor.id, + }); + } + return context.json({ ok: true }); + }); + + routes.delete("/grants", requireUser, async (context) => { + const kind = asGrantKind(context.req.query("kind")); + const ref = context.req.query("ref"); + const agentId = context.req.query("agentId"); + /* + * Query params are always strings, so truthiness is not enough: `" "` is truthy and used + * to pass this check, delete zero rows by exact match, still write a `plugin_revoked` audit + * row naming whitespace, and answer `ok:true`. The POST twin already requires non-empty + * strings; this requires the same and acts on the trimmed values. + */ + if ( + !kind || + typeof ref !== "string" || + !ref.trim() || + typeof agentId !== "string" || + !agentId.trim() + ) { + return context.json( + { error: "A kind, a ref and a Bot are required." }, + 400, + ); + } + const trimmedRef = ref.trim(); + const trimmedAgentId = agentId.trim(); + const refusal = await enablementRefusal( + context, + kind, + trimmedRef, + trimmedAgentId, + "revoke", + ); + if (refusal) return context.json({ error: refusal }, 403); + + await store.revoke(kind, trimmedRef, trimmedAgentId, actorEmail(context)); + return context.json({ ok: true }); + }); + + /** What one Bot holds. The runtime reads this to decide what to offer a model. */ + routes.get("/for/:agentId", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + // A grant list is a fact about the Bot it belongs to. Left open it says which tools somebody + // else's private coworker has been given. + if (!(await canUseBot(context.var.actor, agentId))) { + return context.json({ error: "There is no such Bot." }, 404); + } + return context.json(await store.listForAgent(agentId)); + }); + + /** + * Call a tool, as a Bot. + * + * The grant, the policy and the audit row all happen inside the store, so this endpoint cannot + * accidentally satisfy one of them and skip another. A refusal comes back as 403 with the reason + * the model and the person are both shown, which is the same sentence written to the trail. + * + * NOTHING IN THIS REPOSITORY CALLS IT. It is what the browser used to post to when a Bot's tool + * loop ran client-side; that loop moved to the server, and the client helper for this went with it. + * Kept rather than removed, because #37 hardened it with `canUseBot` after that move — so removing + * it belongs in a change that says so, not in a merge resolution. + */ + routes.post("/call", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + ref?: string; + args?: Record; + agentId?: string; + } | null; + // Same shape lie as `/grants` above: JSON numbers, objects and arrays are truthy, so they + // must be refused here rather than inside `canUseBot` or the tool call. + if ( + typeof body?.ref !== "string" || + !body.ref.trim() || + typeof body.agentId !== "string" || + !body.agentId.trim() + ) { + return context.json({ error: "A tool and a Bot are required." }, 400); + } + // `args` reaches `Object.entries` inside the store, where a string fans out into indexed + // entries, a number becomes no entries, and an array passes as an object — all escaping as + // a vendor 502 instead of a 400 for a malformed call. + if ( + body.args !== undefined && + (typeof body.args !== "object" || + body.args === null || + Array.isArray(body.args) || + Object.getPrototypeOf(body.args) !== Object.prototype) + ) { + return context.json({ error: "Tool arguments must be an object." }, 400); + } + + // Asked before the grant is looked up, and before anything reaches a vendor. The grant says this + // Bot may use the tool; it says nothing about whether this person may act as this Bot, and the + // call goes out on the deployment's own credential either way. + if (!(await canUseBot(context.var.actor, body.agentId))) { + return context.json({ error: "There is no such Bot." }, 404); + } + + try { + const result = await store.callTool({ + ref: body.ref, + args: body.args ?? {}, + botId: body.agentId, + /* + * The user id, not the address. + * + * `callTool` keys a per-person connection on `users.id`, so an address here finds nothing and + * every call through this route would be answered "you have not connected your account" — + * about a connector the person has connected. It never surfaced because a Bot's own tool loop + * runs on the server and does not come through here. + * + * The other uses of `actorEmail` in this file are `by:` on configuration changes, where an + * address is the useful thing to record. This one is an identity being resolved, not a name + * being written down, and the two are not interchangeable. + */ + actorId: context.var.actor.id, + // A signed-in person calling the tool themselves: say so, because a shared account refuses a + // call that does not say what started it. + initiator: PERSON_INITIATOR, + }); + return context.json(result); + } catch (error) { + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message, rule: error.rule }, 403); + } + 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( + { + error: + error instanceof Error + ? error.message + : "The server did not answer.", + failed: true, + }, + 502, + ); + } + }); +} diff --git a/server/src/plugins/route-groups/oauth-callback.ts b/server/src/plugins/route-groups/oauth-callback.ts new file mode 100644 index 000000000..71e1c2a35 --- /dev/null +++ b/server/src/plugins/route-groups/oauth-callback.ts @@ -0,0 +1,175 @@ +import { reasonWithoutStatement } from "../../db/query-failure"; +import { catalogueEntry } from "../catalogue"; +import { + connectedAccountsUrlFor, + readConnectState, + redeemAuthorizationCode, + redirectUriFor, +} from "../oauth"; +import type { PluginRouteContext } from "./context"; + +/** Where a vendor sends a person back after consent. */ +export function mountOAuthCallbackRoute({ + routes, + store, + connect, +}: PluginRouteContext) { + /** + * Where the vendor sends somebody back. + * + * Deliberately not behind `requireUser`. The person arrives on a redirect from another company's + * server, and whose connection this is comes from the sealed state rather than from whatever + * session the browser happens to be carrying — which is what stops a callback delivered to the + * wrong browser from attaching one person's Google account to another person's row. + * + * Having no session is what makes the access check below necessary. Every other route asks the + * question by being behind a guard; this one has to ask it out loud. + * + * Every failure ends the same way: back at Settings with a word about what happened, and nothing + * written. There is no useful distinction here for the person between a forged state and an expired + * one, and spelling out which is which tells anybody probing this endpoint how far they got. + */ + routes.get("/oauth/callback", async (context) => { + const failed = connectedAccountsUrlFor(connect?.appUrl, { + failed: true, + }); + if (!connect?.publicUrl) return context.redirect(failed); + + /* + * EVERY WAY OUT OF THIS HANDLER IS A REDIRECT, AND THE `try` IS WHAT MAKES THAT TRUE. + * + * CRITERION. No request to this endpoint ends without a `Location`. + * + * REASON. The header above says every failure ends the same way, and that was true of + * every failure this handler ASKED for and of none of the failures its questions could + * raise. `personHasAccess` reaches the people store and `oauthClientFor` reaches the + * vault, and either of them throwing put somebody who had just consented at another + * company on a bodyless 500 with no `Location` at all — no page, no notice, nothing to + * press, and a browser left on this API's origin, which locally serves no pages at all. + * It is the exact answer this route exists to make impossible: `redeemAuthorizationCode` + * guards its own `fetch` against it in as many words, and the vault write below was given + * a `catch` for it — one call at a time, which is the shape that keeps leaving one out. + * + * SO IT IS THE WHOLE BODY RATHER THAN EACH CALL. A blanket `catch` is a promise about the + * route; a per-call one is a promise about the calls somebody remembered. Nothing is + * written before the vault, so a failure anywhere above it leaves the same nothing behind + * as an unreadable state does, and the person is told the same sentence either way. + * + * THE PERSON LEARNS NOTHING NEW FROM IT, deliberately, for the reason the header gives: + * there is no useful distinction here between a forged state and a store that would not + * answer, and spelling out which is which tells anybody probing this endpoint how far they + * got. The console is where the difference survives, and it carries the error stringified + * and nothing else — not the state, which names a person, and not the code, which is one + * redemption away from a refresh token. + */ + try { + const code = context.req.query("code"); + const state = await readConnectState( + context.req.query("state") ?? "", + connect.encryptionKey, + ); + if (!code || !state) return context.redirect(failed); + + /* + * Is the person in the state still somebody here? + * + * Asked here, before the code is redeemed and before anything is written, because a state is + * good for ten minutes and access can end inside them. Removing somebody deny-lists their + * address, deletes their sessions and retires the credentials they had already granted — and + * none of that reaches a consent already in flight at the vendor. Without this, that consent + * comes back and writes a fresh, live refresh token belonging to somebody who no longer has + * access, which nothing downstream will ever revoke because nothing knows it was created. + * + * The same anonymous failure as an unreadable state. Whether an address is deny-listed is not a + * fact this endpoint owes an unauthenticated caller. + */ + if (!(await connect.personHasAccess(state.userId))) { + return context.redirect(failed); + } + + const entry = catalogueEntry(state.serverId); + if (entry?.auth.kind !== "user-oauth") return context.redirect(failed); + + const client = await store.oauthClientFor(state.serverId); + if (!client) return context.redirect(failed); + + const grant = await redeemAuthorizationCode({ + tokenUrl: entry.auth.tokenUrl, + clientId: client.clientId, + clientSecret: client.clientSecret, + code, + redirectUri: redirectUriFor(connect.publicUrl), + verifier: state.verifier, + }); + if (!grant) return context.redirect(failed); + + /* + * The last thing that can fail, answered the same way as everything before it. + * + * A vault that will not take the grant is this deployment's problem, not the person's, and they + * have already done their part at the vendor. Unhandled, this threw past the handler and gave + * them the bare 500 that every other failure on this route was written to avoid, on the one + * path where they had most reason to think it had worked. + * + * Told, because unlike the refusals above this one is nobody's fault but ours, and the person's + * sentence deliberately says nothing about which failure it was. The refresh token is not + * logged: it is the one thing here worth stealing, and the row it belonged to was never written. + */ + try { + await store.recordConnection({ + serverId: state.serverId, + userId: state.userId, + refreshToken: grant.refreshToken, + scope: grant.scope, + }); + } catch (error) { + console.error( + JSON.stringify({ + type: "oauth-connection-not-recorded", + serverId: state.serverId, + note: "A person consented and the grant could not be stored. They were sent back to Settings with a failure and will have to connect again.", + /* + * READ THROUGH {@link reasonWithoutStatement}, BECAUSE THE FAILURE THIS LINE EXISTS FOR + * IS THE ONE WHOSE MESSAGE CARRIES THE REFRESH TOKEN. + * + * `recordConnection` runs `swapUserCredential` → `credentials.create/rotate`, whose + * bound parameters include `encryptedValue: await encryptSecret(key, refreshToken)`. + * drizzle wraps a driver failure as a `DrizzleQueryError` whose own message is + * `Failed query: insert into "credentials" … params: …, , …`, so + * `String(error)` here put the envelope on this deployment's stdout and into whatever + * sink collects it — for a person who was correctly told nothing was saved. Two consent + * callbacks for one `(serverId, userId)` landing together, or a statement timeout on + * the insert, is all it takes. + * + * The helper answers the driver's own complaint instead — `duplicate key value violates + * unique constraint`, `canceling statement due to statement timeout` — which is the + * useful half and names nothing anybody sent. + */ + error: reasonWithoutStatement(error), + }), + ); + return context.redirect(failed); + } + + return context.redirect( + connectedAccountsUrlFor( + connect.appUrl, + { serverId: state.serverId }, + // From the sealed state, so the destination is one this deployment chose, not the browser. + state.returnTo, + ), + ); + } catch (error) { + console.error( + JSON.stringify({ + type: "oauth-callback-failed", + note: "A person came back from a consent screen and a check on this route raised instead of answering. They were sent back to Settings with a failure, nothing was written, and they will have to connect again.", + // The outer catch of the branch above, so it stands over the same write and needs the + // same door. See the note on `oauth-connection-not-recorded`. + error: reasonWithoutStatement(error), + }), + ); + return context.redirect(failed); + } + }); +} diff --git a/server/src/plugins/route-groups/servers.ts b/server/src/plugins/route-groups/servers.ts new file mode 100644 index 000000000..9c51fdab2 --- /dev/null +++ b/server/src/plugins/route-groups/servers.ts @@ -0,0 +1,504 @@ +import { requireAdmin } from "../../auth/guards"; +import { reasonWithoutStatement } from "../../db/query-failure"; +import { brokerSentence, schemeKind } from "../broker"; +import { CATALOGUE } from "../catalogue"; +import { toolkitOf, vendorSentence } from "../composio"; +import { redirectUriFor } from "../oauth"; +import { asAccountMode } from "../shared-accounts"; +import { asSharedUseApproval, type SharedUseApproval } from "../shared-use"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, + deploymentFaultSentence, + isDeploymentFault, +} from "../store"; +import { brokerRefusal, type PluginRouteContext } from "./context"; + +/** Adding, removing and refreshing MCP servers, and an app's account mode. */ +export function mountServerRoutes({ + routes, + store, + requireUser, + connect, + composio, + shared, + actorEmail, + skillActor, +}: PluginRouteContext) { + /** Everything the Plugins page draws: the catalogue, what is added, and the skills. */ + routes.get("/", requireUser, async (context) => + context.json({ + catalogue: CATALOGUE.map((entry) => ({ + key: entry.key, + title: entry.title, + vendor: entry.vendor, + summary: entry.summary, + docsUrl: entry.docsUrl, + /* + * The kind, not the whole thing. The page needs to know what to ask an administrator for; + * it has no use for the vendor's OAuth addresses, and a URL this deployment sends an + * authorization code to is not improved by also existing in every browser that opens the + * Plugins page. + */ + auth: entry.auth.kind, + perInstance: entry.host === null, + })), + /* + * Whether a Bot with no credential of its own can still call a tool back. + * + * The grant switch decides whether a tool is offered to a model; this decides whether any + * call it makes can be authenticated at all. They are different questions and the screen used + * to answer only the first, so a grant could read "May call this tool" on a deployment where + * 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)), + /* + * What an administrator has to register with the vendor, character for character. + * + * Served rather than assembled in the browser, so what is displayed is exactly what the + * callback will present. A mismatch here fails at the vendor with a message that does not name + * us, which is a bad afternoon for whoever is setting it up. + * + * Null means this deployment has no public URL, so it cannot complete a consent flow at all. + */ + redirectUri: connect?.publicUrl + ? redirectUriFor(connect.publicUrl) + : null, + }), + ); + + /** Add a curated server. The URL comes from the catalogue, never from the request. */ + routes.post("/servers", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + const body = (await context.req.json().catch(() => null)) as { + key?: unknown; + instanceHost?: unknown; + credentialId?: unknown; + } | null; + const key = typeof body?.key === "string" ? body.key.trim() : ""; + if (!key) { + return context.json({ error: "A catalogue key is required." }, 400); + } + // Optional fields travel to `input.credentialId?.trim()` in the store, where a number or + // object throws a TypeError that escapes as a 500. A string that is only whitespace would + // silently become `undefined` there, so it is refused here instead of being coerced. + if ( + body?.instanceHost !== undefined && + (typeof body.instanceHost !== "string" || !body.instanceHost.trim()) + ) { + return context.json( + { error: "An instance host must be a non-empty string." }, + 400, + ); + } + if ( + body?.credentialId !== undefined && + (typeof body.credentialId !== "string" || !body.credentialId.trim()) + ) { + return context.json( + { error: "A credential id must be a non-empty string." }, + 400, + ); + } + + try { + const server = await store.addServer({ + key, + instanceHost: + typeof body?.instanceHost === "string" + ? body.instanceHost.trim() + : undefined, + credentialId: + typeof body?.credentialId === "string" + ? body.credentialId.trim() + : undefined, + by: actorEmail(context), + }); + return context.json({ server }); + } catch (error) { + // A refused credential is the administrator's mistake to correct, so it comes back as a + // refusal with its reason rather than as a 500 the way an unmapped throw would. + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) { + 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; + } + }); + + /** + * Add a server by URL. + * + * Its own endpoint rather than a flag on the one above, so that "an administrator pointed this + * deployment at an address of their own" is a distinct act in the code, in the audit trail and in + * anything that reads either. + */ + routes.post("/servers/custom", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + const body = (await context.req.json().catch(() => null)) as { + id?: unknown; + title?: unknown; + url?: unknown; + credentialId?: unknown; + } | null; + if ( + typeof body?.id !== "string" || + !body.id.trim() || + typeof body?.title !== "string" || + !body.title.trim() || + typeof body?.url !== "string" || + !body.url.trim() + ) { + return context.json( + { error: "A name, a title and a URL are required." }, + 400, + ); + } + // `addCustomServer` dereferences `input.credentialId?.trim()`, so a number or object here + // throws a TypeError that escapes as a 500 instead of a 400. + if ( + body.credentialId !== undefined && + (typeof body.credentialId !== "string" || !body.credentialId.trim()) + ) { + return context.json( + { error: "A credential id must be a non-empty string." }, + 400, + ); + } + + try { + const server = await store.addCustomServer({ + id: body.id.trim(), + title: body.title.trim(), + url: body.url.trim(), + credentialId: + typeof body.credentialId === "string" + ? body.credentialId.trim() + : undefined, + by: actorEmail(context), + }); + return context.json({ server }); + } catch (error) { + if ( + error instanceof CustomServerRefusedError || + error instanceof CatalogueEntryUnknownError + ) { + 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; + } + }); + + /** + * Register this deployment's OAuth client for a server reached as the person asking. + * + * Its own endpoint rather than a field on `POST /servers`, because it is a separate act with a + * separate lifetime: a client is rotated without the server being re-added, and re-adding a server + * should not require re-typing a client. An administrator's, like everything else that decides what + * a Bot can reach. + */ + routes.post("/servers/:id/oauth-client", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + const body = (await context.req.json().catch(() => null)) as { + clientId?: unknown; + clientSecret?: unknown; + } | null; + /* + * `typeof` before `.trim()`, as `POST /servers` and `/servers/custom` do: the body is JSON, so + * `{"clientId": 12345}` or a secret of `{}` used to reach `.trim()` here, outside the try, and + * answer 500 for what is a person's malformed request. + */ + if ( + typeof body?.clientId !== "string" || + typeof body.clientSecret !== "string" || + !body.clientId.trim() || + !body.clientSecret.trim() + ) { + return context.json( + { error: "A client id and a client secret are both required." }, + 400, + ); + } + + try { + await store.registerOAuthClient({ + serverId: context.req.param("id"), + client: { + clientId: body.clientId.trim(), + clientSecret: body.clientSecret.trim(), + }, + by: actorEmail(context), + }); + return context.json({ ok: true }); + } catch (error) { + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) { + 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; + } + }); + + /** + * Take a server away, and answer for a removal that did not finish. + * + * CRITERION. The same one the add routes above state, on the route that needed it most: every + * refusal `removeServer` can raise comes back as a body naming what was left standing, rather + * than reaching the framework's default handler. + * + * REASON. THIS MAPPED NOTHING AT ALL, and what it calls is the loudest method in the store. + * `removeServer` withdraws every person's brokered account at Composio and drops this + * deployment's auth configs BEFORE it deletes the row, deliberately — the argument is made + * there, and it ends "WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes". The + * refusals it raises are written for exactly this reader: how many of this deployment's configs + * went and how many are still standing, or a config row Composio described with no id and no + * name. All of it reached Hono's default handler, which answers a bodyless 500, so an + * administrator who had just half-withdrawn an app from everybody was told nothing whatever — + * on the one act in this file whose half-done state somebody has to go and finish by hand. + * `PluginInvariantError` out of `accessFor` arrived the same way, and it is the one that names + * which of this deployment's own rows is wrong. + * + * THE SHELF FIRST, THEN THE BROKER, which is the order the enable route below pins and for its + * reason: a contradiction between two of this deployment's columns is not Composio being down, + * and answering it through the broker mapping would send an operator to check a key that is + * fine. This route is `requireAdmin`, which is what makes showing that sentence here safe. + * + * AND THE GENERIC SENTENCE BLAMES NEITHER SIDE. Half of what this call path does is local — the + * vault revokes, the trail writes, the deletes — and half of it is the broker, so a failure that + * neither `brokerSentence` nor `vendorSentence` put words to is one where which half failed is + * precisely what nobody said. What a reader needs is the button, and the store's own order is + * what makes pressing it safe: nothing here is deleted until the withdrawals have been asked + * for, so a second press asks only for what is left. + */ + routes.delete("/servers/:id", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + const serverId = context.req.param("id"); + /* + * READ BEFORE THE ROW GOES. The ask-before-write rule a Shared app was given lives in the + * approval policy, not on the row, so nothing cascades it away; asked afterwards, the mode is + * gone with the row and the rule would outlive the account it guarded. + */ + const wasShared = + shared !== undefined && + (await store.serverAddress(serverId))?.accountMode === "shared"; + try { + await store.removeServer(serverId, actorEmail(context)); + } catch (error) { + if (isDeploymentFault(error)) { + return context.json({ error: deploymentFaultSentence(error) }, 409); + } + /* + * WHAT THE MAPPING COST, PUT BACK, exactly as the enable route below does it. An unhandled + * throw was a bad answer and a good log: the administrator got nothing, but the stack was + * printed where an operator could find it. Catching everything fixes the answer and would + * silence that, and the failure that needs the log most is the one left here — neither a + * refusal this deployment authored, which `brokerSentence` names, nor a sentence Composio + * wrote, which `vendorSentence` reaches for. The row, because it is the app an operator is + * about to be asked about, and the error read through {@link reasonWithoutStatement}, never + * spread, logged as an object or reached into. + * + * AND THAT DOOR RATHER THAN `String(error)`, BECAUSE THE TWO SENTENCE TESTS ARE NOT A GUARD + * AGAINST THIS. A `DrizzleQueryError` has neither a broker sentence nor a vendor one, so it + * lands in exactly this branch — which is what removing a server raises when a delete fails + * mid-way — and its own message is the statement plus every value bound to it. + */ + if (brokerSentence(error) === null && vendorSentence(error) === null) { + console.error( + JSON.stringify({ + type: "mcp-server-not-removed", + server: serverId, + note: "Removing a server failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.", + error: reasonWithoutStatement(error), + }), + ); + } + + const refusal = brokerRefusal( + error, + "Removing this app did not finish, and neither this deployment nor Composio said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at Composio before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's Composio key if it persists.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + if (wasShared) + await shared?.modes.forgetRule(serverId, context.var.actor.id); + return context.json({ ok: true }); + }); + + /** + * Personal ⇄ Shared. Without `confirm`, a preview of what the switch would end and approve; with + * it, the switch itself, which changes nothing unless every old account was ended at the vendor. + */ + routes.put("/servers/:id/account-mode", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + if (!shared) { + return context.json( + { error: "Shared accounts are not available on this deployment." }, + 503, + ); + } + const body = (await context.req.json().catch(() => null)) as { + mode?: unknown; + confirm?: unknown; + approvals?: unknown; + } | null; + const mode = asAccountMode(body?.mode); + if (!mode) + return context.json({ error: "Choose personal or shared." }, 400); + const serverId = context.req.param("id"); + const row = await store.serverAddress(serverId); + if (!row || !toolkitOf(row.url)) { + return context.json( + { error: "That app is not reached through a broker." }, + 400, + ); + } + if (schemeKind(row.authScheme) === "none") { + return context.json( + { + error: `${row.title} needs no account, so there is nothing to share.`, + }, + 400, + ); + } + const approvals: Record = {}; + if (body?.approvals && typeof body.approvals === "object") { + for (const [botId, value] of Object.entries( + body.approvals as Record, + )) { + const approval = asSharedUseApproval(value); + if (!approval) { + return context.json( + { + error: `The approval for ${botId} is not one this deployment can read.`, + }, + 400, + ); + } + approvals[botId] = approval; + } + } + try { + const result = await shared.modes.switchMode({ + serverId, + mode, + by: context.var.actor.id, + confirm: body?.confirm === true, + approvals, + }); + if ("failures" in result) { + return context.json( + { + ...result, + error: result.failures.map((failure) => failure.error).join(" "), + }, + 502, + ); + } + return context.json(result); + } catch (error) { + const refusal = brokerRefusal( + error, + "The switch did not finish, and nothing was changed.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }); + + /** Ask a server what it offers now. Reported rather than thrown, so the page can say what broke. */ + routes.post("/servers/:id/refresh", requireUser, async (context) => { + const forbidden = requireAdmin(context); + if (forbidden) return forbidden; + + try { + const result = await store.refreshTools( + context.req.param("id"), + context.var.actor.id, + ); + const servers = await store.listServers(); + return context.json({ + tools: result.tools, + server: servers.find((server) => server.id === context.req.param("id")), + }); + } catch (error) { + if (error instanceof CatalogueEntryUnknownError) { + return context.json({ error: error.message }, 404); + } + /* + * The one audience the sentence was written for, and the only route that may show it. + * + * CRITERION. A contradiction between this deployment's own columns comes back to an + * administrator as itself: a body, naming the row and what to do about it. + * + * REASON. Unmapped, it reached the framework's default handler — a 500 with no JSON at all, + * which the admin page reads as "That did not work", the fallback it uses when a response + * carries no message. So the one refusal that names exactly which row is wrong and how to + * correct it was the one an operator could not see, while the same sentence WAS reaching a + * model on the tool-call path. This route is `requireAdmin`, which is what makes showing it + * here safe and showing it anywhere else not. + * + * 409 rather than 500: nothing broke, and nothing about the request was malformed. Two rows + * of ours disagree, and the request cannot be answered until one of them changes — which is + * what the sentence tells the reader to go and do. + */ + if (isDeploymentFault(error)) { + // `deploymentFaultSentence` rather than `error.message`: the shelf now includes a query + // this database refused, and that one's message is the statement and every value bound to + // it. An administrator is entitled to the reason, not to the dump. + return context.json({ error: deploymentFaultSentence(error) }, 409); + } + throw error; + } + }); +} diff --git a/server/src/plugins/route-groups/skills.ts b/server/src/plugins/route-groups/skills.ts new file mode 100644 index 000000000..18ef2f984 --- /dev/null +++ b/server/src/plugins/route-groups/skills.ts @@ -0,0 +1,135 @@ +import { PluginRefusedError } from "../store"; +import type { PluginRouteContext } from "./context"; + +/** Writing and deleting skills. */ +export function mountSkillRoutes({ + routes, + store, + requireUser, + actorEmail, + skillActor, + skillRefusal, +}: PluginRouteContext) { + /** + * Write a skill. + * + * Not admin-only. A skill is an instruction, not a capability: it can only ask a + * Bot to use tools that Bot was already granted, and every one of those calls is still decided, + * policy-checked and audited. Adding an MCP server is the opposite, and stays an administrator's. + * + * A person's skill is their own. `global` writes one for the whole deployment, which an + * administrator may do and nobody else. + */ + routes.post("/skills", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + slug?: unknown; + title?: unknown; + summary?: unknown; + instructions?: unknown; + global?: boolean; + tools?: unknown; + } | null; + /* + * The body is JSON, so the annotations are wishes: `{"slug":123}` passes a truthiness + * check and `RegExp.test` then coerces it to `"123"`, and `{"summary":{}}` reaches the + * store where the insert throws a 500. A slug, a title and instructions are non-empty + * strings here, and a summary is absent or a string. Anything else is a 400 before + * any refusal check, store write, or audit row. + */ + if ( + typeof body?.slug !== "string" || + typeof body?.title !== "string" || + !body.title.trim() || + typeof body?.instructions !== "string" || + !body.instructions.trim() + ) { + return context.json( + { error: "A slug, a title and instructions are required." }, + 400, + ); + } + if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(body.slug)) { + return context.json( + { error: "A slug is lower-case letters, numbers and hyphens." }, + 400, + ); + } + if (body.summary !== undefined && typeof body.summary !== "string") { + return context.json( + { error: "A summary is text when it is present." }, + 400, + ); + } + + const actor = skillActor(context); + if (body.global && !actor.isAdmin) { + return context.json( + { error: "Only an administrator writes a skill for the deployment." }, + 403, + ); + } + + // Editing an existing slug, which is what a repeated save is, needs the right to edit that + // skill. Without this, saving over somebody else's name would silently take it. + const refusal = await skillRefusal(context, body.slug); + if (refusal) return context.json({ error: refusal }, 403); + + /* + * Absent leaves the declarations alone, so a caller that predates this field does not silently + * clear one. An array, including an empty one, says what the skill needs now. + * + * Every entry must be a non-empty string: silently dropping mistyped entries would turn a + * client bug into a skill that declares nothing and answers success. `global` must be a + * boolean when present, so the string `"yes"` cannot create a deployment-wide skill. + */ + if (body.global !== undefined && typeof body.global !== "boolean") { + return context.json( + { error: "Global must be true or false when it is present." }, + 400, + ); + } + if (body.tools !== undefined) { + if ( + !Array.isArray(body.tools) || + body.tools.some((ref) => typeof ref !== "string" || !ref.trim()) + ) { + return context.json( + { error: "Tools are a list of serverId/toolName references." }, + 400, + ); + } + } + const tools = Array.isArray(body.tools) + ? (body.tools as string[]).map((ref) => ref.trim()) + : undefined; + + try { + await store.installSkill({ + slug: body.slug, + title: body.title.trim(), + summary: body.summary ?? "", + instructions: body.instructions.trim(), + ownerUserId: body.global ? null : actor.id, + ...(tools === undefined ? {} : { tools }), + by: actorEmail(context), + }); + } catch (error) { + // A ref naming no tool this deployment has seen. Answered rather than thrown, because it is + // something the person writing the skill can fix and the message says what to fix. + if (error instanceof PluginRefusedError) { + return context.json({ error: error.message }, 400); + } + throw error; + } + return context.json({ skills: await store.listSkills(actor) }); + }); + + routes.delete("/skills/:slug", requireUser, async (context) => { + const slug = context.req.param("slug"); + const refusal = await skillRefusal(context, slug); + if (refusal) return context.json({ error: refusal }, 403); + + await store.uninstallSkill(slug, actorEmail(context)); + return context.json({ ok: true }); + }); +} diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d3aca75af..b3cebe4ba 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -1,121 +1,22 @@ -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; +import { Hono, type MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; -import { PERSON_INITIATOR } from "../audit"; import type { AppVariables } from "../auth/guards"; -import { requireAdmin } from "../auth/guards"; -import { reasonWithoutStatement } from "../db/query-failure"; import type { AccountModeSwitch } from "./account-mode"; -import { - type BrokerApp, - type BrokerField, - BrokerUnconfiguredError, - brokerReturnUrl, - brokerSentence, - type ComposioBroker, - type Decides, - isFieldScheme, - type SchemeKind, - schemeKind, -} from "./broker"; -import { CATALOGUE, catalogueEntry } from "./catalogue"; -import { toolkitOf, vendorSentence } from "./composio"; -import { - authorizationUrlFor, - type ConnectOrigin, - challengeFor, - connectedAccountsUrlFor, - createVerifier, - readConnectState, - redeemAuthorizationCode, - redirectUriFor, - sealConnectState, -} from "./oauth"; -import { type AccountRef, asAccountMode } from "./shared-accounts"; -import { - asSharedUseApproval, - exposureOf, - type SharedUseApproval, -} from "./shared-use"; +import type { ComposioBroker } from "./broker"; +import { mountComposioAppRoutes } from "./route-groups/composio-apps"; +import { mountConnectionRoutes } from "./route-groups/connections"; +import type { + PluginConnectOptions, + PluginRouteContext, +} from "./route-groups/context"; +import { mountGrantRoutes } from "./route-groups/grants"; +import { mountOAuthCallbackRoute } from "./route-groups/oauth-callback"; +import { mountServerRoutes } from "./route-groups/servers"; +import { mountSkillRoutes } from "./route-groups/skills"; import type { SharedUseStore } from "./shared-use-store"; -import { - CatalogueEntryUnknownError, - CustomServerRefusedError, - deploymentFaultSentence, - isDeploymentFault, - type OAuthClient, - type PluginKind, - PluginRefusedError, - type PluginStore, -} from "./store"; +import type { PluginStore } from "./store"; -/** - * Whether the person a consent was started for still has access to this deployment. - * - * A seam rather than an import, because these routes have no business knowing what a person is or - * where the deny list lives — and because the answer has to come from the deployment as it is when - * the callback lands, not from what was true when the flow started. - * - * False for somebody who was removed while they were away at the vendor's consent screen, and false - * for a user id that names nobody at all. Both are the same refusal: there is no live person for - * this grant to belong to. - */ -export type ConnectingPersonCheck = (userId: string) => Promise; - -/** - * What somebody is told when the broker itself failed, and what is deliberately kept out of it. - * - * EVERY BROKERED ROUTE NEEDS THIS AND NONE OF THEM USED TO HAVE IT. A listing, a connect, a confirm - * and a disconnect all end in a call to another company's API, and an unhandled rejection out of any - * of them is a bare 500 with the vendor's thrown object on this deployment's console. That object is - * the whole HTTP response — headers, trace ids, rate-limit counters — and the person reading the 500 - * gets none of it and no sentence either. A wrong `COMPOSIO_API_KEY` is the first failure a new - * operator meets, and it is the one this used to answer worst. - * - * `vendorSentence` IS THE SAME ONE THE TRANSPORT USES, for the same reason it exists there: the - * useful sentence — "Invalid API key", "No connected account found for user …" — is nested two - * levels inside `cause` beside everything that must not be shown, so this reaches in for that one - * string and takes nothing else. Null from it is a failure this deployment cannot explain, and the - * caller's own generic sentence is what a reader gets instead of the vendor's placeholder. - * - * NOTHING FROM THE REQUEST OR THE ANSWER TRAVELS WITH IT. Not the API key, which never leaves the - * adapter; not a connect link, which is a bearer capability handed to one browser; and not the - * thrown object, whether by spreading it, stringifying it or logging it. - * - * 502 RATHER THAN 500, because nothing here broke: this deployment asked a third party and the third - * party did not answer usefully, which is the same reading the dynamic-registration failure below - * already gives. - * - * `./broker`'s `BrokerRefusalError` IS THE EXCEPTION, AND IT IS A WIDER ONE THAN IT WAS. It used to - * be the one no-key state; it is now every refusal the broker layer AUTHORED — no key, an app whose - * authorization config this deployment never created or cannot use, a consent the vendor answered - * with nowhere to send anybody, a catalogue too large to be sure of. All of them share the property - * that made the first one special: Composio answered, this deployment decided, and the sentence - * already names the step that fixes it. So the message is passed through and the status stays 503, - * which reads correctly for every one of them — the brokered surface is unavailable for this app - * until somebody changes something here, rather than unavailable because a third party is down. The - * generic sentence below is for failures this deployment genuinely cannot explain, and answering one - * of these with it would tell an administrator to check a key that is fine. - */ -function brokerRefusal( - error: unknown, - generic: string, -): { error: string; status: 502 | 503 } { - const authored = brokerSentence(error); - if (authored) return { error: authored, status: 503 }; - return { error: vendorSentence(error) ?? generic, status: 502 }; -} - -/** - * What a reader is told when Composio would not answer with its directory. - * - * One constant because two routes make that call — browsing the catalogue and enabling an app out of - * it — and a reader meeting the same failure through two doors should not meet two sentences. It - * names the setting rather than quoting it: a key that was pasted with a space in it, or one for - * another project, is the likeliest reason Composio will not talk to this deployment at all. - */ -const DIRECTORY_UNAVAILABLE = - "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; +export type { ConnectingPersonCheck } from "./route-groups/context"; /** * The Plugins surface: what this deployment has added, and which Bots may use it. @@ -154,37 +55,7 @@ export function createPluginRoutes( * positional list becomes a trap: every argument from here on is optional, so a misplaced one * typechecks and simply does nothing. */ - connect?: { - encryptionKey: string; - /** - * Whether the person a state names may still connect an account here. - * - * Required rather than optional, unlike everything else that arrived on this object as "one more - * parameter". The callback is sessionless on purpose, so this is the ONLY thing asking whether - * the identity in the state is still one this deployment recognises — and a deployment that - * forgot to pass it would complete a consent for somebody who was removed ten minutes ago and - * write a live refresh token nothing will ever revoke. - */ - personHasAccess: ConnectingPersonCheck; - /** - * Whether this deployment holds a shared secret a Bot may present when calling a tool back. - * - * A boolean about configuration, never the secret. Without it, a Bot can only call back if it - * holds a credential issued to it alone, and a Bot with neither is refused before the call ever - * reaches the grant, the boundary or the trail. The Bots screen needs this to stop promising a - * grant the deployment cannot honour. - */ - botsMayCallBack?: boolean; - publicUrl: string | undefined; - /** - * Where the app is, which is not where this API is. - * - * The callback lands here and has to send the person back to a page. A relative redirect would - * put them on this server's origin, which locally is a Vite-less port that serves no pages at - * all — so the flow would complete correctly and end on a 404. - */ - appUrl: string | undefined; - }, + connect?: PluginConnectOptions, /** * The broker this deployment talks to when an app is connected for somebody rather than * registered by an administrator. @@ -241,2606 +112,25 @@ export function createPluginRoutes( return owner === actor.id ? null : `${slug} is somebody else's skill.`; } - /** Everything the Plugins page draws: the catalogue, what is added, and the skills. */ - routes.get("/", requireUser, async (context) => - context.json({ - catalogue: CATALOGUE.map((entry) => ({ - key: entry.key, - title: entry.title, - vendor: entry.vendor, - summary: entry.summary, - docsUrl: entry.docsUrl, - /* - * The kind, not the whole thing. The page needs to know what to ask an administrator for; - * it has no use for the vendor's OAuth addresses, and a URL this deployment sends an - * authorization code to is not improved by also existing in every browser that opens the - * Plugins page. - */ - auth: entry.auth.kind, - perInstance: entry.host === null, - })), - /* - * Whether a Bot with no credential of its own can still call a tool back. - * - * The grant switch decides whether a tool is offered to a model; this decides whether any - * call it makes can be authenticated at all. They are different questions and the screen used - * to answer only the first, so a grant could read "May call this tool" on a deployment where - * 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)), - /* - * What an administrator has to register with the vendor, character for character. - * - * Served rather than assembled in the browser, so what is displayed is exactly what the - * callback will present. A mismatch here fails at the vendor with a message that does not name - * us, which is a bad afternoon for whoever is setting it up. - * - * Null means this deployment has no public URL, so it cannot complete a consent flow at all. - */ - redirectUri: connect?.publicUrl - ? redirectUriFor(connect.publicUrl) - : null, - }), - ); - - /** Add a curated server. The URL comes from the catalogue, never from the request. */ - routes.post("/servers", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - const body = (await context.req.json().catch(() => null)) as { - key?: unknown; - instanceHost?: unknown; - credentialId?: unknown; - } | null; - const key = typeof body?.key === "string" ? body.key.trim() : ""; - if (!key) { - return context.json({ error: "A catalogue key is required." }, 400); - } - // Optional fields travel to `input.credentialId?.trim()` in the store, where a number or - // object throws a TypeError that escapes as a 500. A string that is only whitespace would - // silently become `undefined` there, so it is refused here instead of being coerced. - if ( - body?.instanceHost !== undefined && - (typeof body.instanceHost !== "string" || !body.instanceHost.trim()) - ) { - return context.json( - { error: "An instance host must be a non-empty string." }, - 400, - ); - } - if ( - body?.credentialId !== undefined && - (typeof body.credentialId !== "string" || !body.credentialId.trim()) - ) { - return context.json( - { error: "A credential id must be a non-empty string." }, - 400, - ); - } - - try { - const server = await store.addServer({ - key, - instanceHost: - typeof body?.instanceHost === "string" - ? body.instanceHost.trim() - : undefined, - credentialId: - typeof body?.credentialId === "string" - ? body.credentialId.trim() - : undefined, - by: actorEmail(context), - }); - return context.json({ server }); - } catch (error) { - // A refused credential is the administrator's mistake to correct, so it comes back as a - // refusal with its reason rather than as a 500 the way an unmapped throw would. - if ( - error instanceof CatalogueEntryUnknownError || - error instanceof CustomServerRefusedError - ) { - 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; - } - }); - - /** - * Add a server by URL. - * - * Its own endpoint rather than a flag on the one above, so that "an administrator pointed this - * deployment at an address of their own" is a distinct act in the code, in the audit trail and in - * anything that reads either. - */ - routes.post("/servers/custom", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - const body = (await context.req.json().catch(() => null)) as { - id?: unknown; - title?: unknown; - url?: unknown; - credentialId?: unknown; - } | null; - if ( - typeof body?.id !== "string" || - !body.id.trim() || - typeof body?.title !== "string" || - !body.title.trim() || - typeof body?.url !== "string" || - !body.url.trim() - ) { - return context.json( - { error: "A name, a title and a URL are required." }, - 400, - ); - } - // `addCustomServer` dereferences `input.credentialId?.trim()`, so a number or object here - // throws a TypeError that escapes as a 500 instead of a 400. - if ( - body.credentialId !== undefined && - (typeof body.credentialId !== "string" || !body.credentialId.trim()) - ) { - return context.json( - { error: "A credential id must be a non-empty string." }, - 400, - ); - } - - try { - const server = await store.addCustomServer({ - id: body.id.trim(), - title: body.title.trim(), - url: body.url.trim(), - credentialId: - typeof body.credentialId === "string" - ? body.credentialId.trim() - : undefined, - by: actorEmail(context), - }); - return context.json({ server }); - } catch (error) { - if ( - error instanceof CustomServerRefusedError || - error instanceof CatalogueEntryUnknownError - ) { - 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; - } - }); - - /** - * Register this deployment's OAuth client for a server reached as the person asking. - * - * Its own endpoint rather than a field on `POST /servers`, because it is a separate act with a - * separate lifetime: a client is rotated without the server being re-added, and re-adding a server - * should not require re-typing a client. An administrator's, like everything else that decides what - * a Bot can reach. - */ - routes.post("/servers/:id/oauth-client", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - const body = (await context.req.json().catch(() => null)) as { - clientId?: unknown; - clientSecret?: unknown; - } | null; - /* - * `typeof` before `.trim()`, as `POST /servers` and `/servers/custom` do: the body is JSON, so - * `{"clientId": 12345}` or a secret of `{}` used to reach `.trim()` here, outside the try, and - * answer 500 for what is a person's malformed request. - */ - if ( - typeof body?.clientId !== "string" || - typeof body.clientSecret !== "string" || - !body.clientId.trim() || - !body.clientSecret.trim() - ) { - return context.json( - { error: "A client id and a client secret are both required." }, - 400, - ); - } - - try { - await store.registerOAuthClient({ - serverId: context.req.param("id"), - client: { - clientId: body.clientId.trim(), - clientSecret: body.clientSecret.trim(), - }, - by: actorEmail(context), - }); - return context.json({ ok: true }); - } catch (error) { - if ( - error instanceof CatalogueEntryUnknownError || - error instanceof CustomServerRefusedError - ) { - 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; - } - }); - - /** - * Take a server away, and answer for a removal that did not finish. - * - * CRITERION. The same one the add routes above state, on the route that needed it most: every - * refusal `removeServer` can raise comes back as a body naming what was left standing, rather - * than reaching the framework's default handler. - * - * REASON. THIS MAPPED NOTHING AT ALL, and what it calls is the loudest method in the store. - * `removeServer` withdraws every person's brokered account at Composio and drops this - * deployment's auth configs BEFORE it deletes the row, deliberately — the argument is made - * there, and it ends "WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes". The - * refusals it raises are written for exactly this reader: how many of this deployment's configs - * went and how many are still standing, or a config row Composio described with no id and no - * name. All of it reached Hono's default handler, which answers a bodyless 500, so an - * administrator who had just half-withdrawn an app from everybody was told nothing whatever — - * on the one act in this file whose half-done state somebody has to go and finish by hand. - * `PluginInvariantError` out of `accessFor` arrived the same way, and it is the one that names - * which of this deployment's own rows is wrong. - * - * THE SHELF FIRST, THEN THE BROKER, which is the order the enable route below pins and for its - * reason: a contradiction between two of this deployment's columns is not Composio being down, - * and answering it through the broker mapping would send an operator to check a key that is - * fine. This route is `requireAdmin`, which is what makes showing that sentence here safe. - * - * AND THE GENERIC SENTENCE BLAMES NEITHER SIDE. Half of what this call path does is local — the - * vault revokes, the trail writes, the deletes — and half of it is the broker, so a failure that - * neither `brokerSentence` nor `vendorSentence` put words to is one where which half failed is - * precisely what nobody said. What a reader needs is the button, and the store's own order is - * what makes pressing it safe: nothing here is deleted until the withdrawals have been asked - * for, so a second press asks only for what is left. - */ - routes.delete("/servers/:id", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - const serverId = context.req.param("id"); - /* - * READ BEFORE THE ROW GOES. The ask-before-write rule a Shared app was given lives in the - * approval policy, not on the row, so nothing cascades it away; asked afterwards, the mode is - * gone with the row and the rule would outlive the account it guarded. - */ - const wasShared = - shared !== undefined && - (await store.serverAddress(serverId))?.accountMode === "shared"; - try { - await store.removeServer(serverId, actorEmail(context)); - } catch (error) { - if (isDeploymentFault(error)) { - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - /* - * WHAT THE MAPPING COST, PUT BACK, exactly as the enable route below does it. An unhandled - * throw was a bad answer and a good log: the administrator got nothing, but the stack was - * printed where an operator could find it. Catching everything fixes the answer and would - * silence that, and the failure that needs the log most is the one left here — neither a - * refusal this deployment authored, which `brokerSentence` names, nor a sentence Composio - * wrote, which `vendorSentence` reaches for. The row, because it is the app an operator is - * about to be asked about, and the error read through {@link reasonWithoutStatement}, never - * spread, logged as an object or reached into. - * - * AND THAT DOOR RATHER THAN `String(error)`, BECAUSE THE TWO SENTENCE TESTS ARE NOT A GUARD - * AGAINST THIS. A `DrizzleQueryError` has neither a broker sentence nor a vendor one, so it - * lands in exactly this branch — which is what removing a server raises when a delete fails - * mid-way — and its own message is the statement plus every value bound to it. - */ - if (brokerSentence(error) === null && vendorSentence(error) === null) { - console.error( - JSON.stringify({ - type: "mcp-server-not-removed", - server: serverId, - note: "Removing a server failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.", - error: reasonWithoutStatement(error), - }), - ); - } - - const refusal = brokerRefusal( - error, - "Removing this app did not finish, and neither this deployment nor Composio said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at Composio before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's Composio key if it persists.", - ); - return context.json({ error: refusal.error }, refusal.status); - } - if (wasShared) - await shared?.modes.forgetRule(serverId, context.var.actor.id); - return context.json({ ok: true }); - }); - - /** - * Personal ⇄ Shared. Without `confirm`, a preview of what the switch would end and approve; with - * it, the switch itself, which changes nothing unless every old account was ended at the vendor. - */ - routes.put("/servers/:id/account-mode", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - if (!shared) { - return context.json( - { error: "Shared accounts are not available on this deployment." }, - 503, - ); - } - const body = (await context.req.json().catch(() => null)) as { - mode?: unknown; - confirm?: unknown; - approvals?: unknown; - } | null; - const mode = asAccountMode(body?.mode); - if (!mode) - return context.json({ error: "Choose personal or shared." }, 400); - const serverId = context.req.param("id"); - const row = await store.serverAddress(serverId); - if (!row || !toolkitOf(row.url)) { - return context.json( - { error: "That app is not reached through a broker." }, - 400, - ); - } - if (schemeKind(row.authScheme) === "none") { - return context.json( - { - error: `${row.title} needs no account, so there is nothing to share.`, - }, - 400, - ); - } - const approvals: Record = {}; - if (body?.approvals && typeof body.approvals === "object") { - for (const [botId, value] of Object.entries( - body.approvals as Record, - )) { - const approval = asSharedUseApproval(value); - if (!approval) { - return context.json( - { - error: `The approval for ${botId} is not one this deployment can read.`, - }, - 400, - ); - } - approvals[botId] = approval; - } - } - try { - const result = await shared.modes.switchMode({ - serverId, - mode, - by: context.var.actor.id, - confirm: body?.confirm === true, - approvals, - }); - if ("failures" in result) { - return context.json( - { - ...result, - error: result.failures.map((failure) => failure.error).join(" "), - }, - 502, - ); - } - return context.json(result); - } catch (error) { - const refusal = brokerRefusal( - error, - "The switch did not finish, and nothing was changed.", - ); - return context.json({ error: refusal.error }, refusal.status); - } - }); - - /** Ask a server what it offers now. Reported rather than thrown, so the page can say what broke. */ - routes.post("/servers/:id/refresh", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - try { - const result = await store.refreshTools( - context.req.param("id"), - context.var.actor.id, - ); - const servers = await store.listServers(); - return context.json({ - tools: result.tools, - server: servers.find((server) => server.id === context.req.param("id")), - }); - } catch (error) { - if (error instanceof CatalogueEntryUnknownError) { - return context.json({ error: error.message }, 404); - } - /* - * The one audience the sentence was written for, and the only route that may show it. - * - * CRITERION. A contradiction between this deployment's own columns comes back to an - * administrator as itself: a body, naming the row and what to do about it. - * - * REASON. Unmapped, it reached the framework's default handler — a 500 with no JSON at all, - * which the admin page reads as "That did not work", the fallback it uses when a response - * carries no message. So the one refusal that names exactly which row is wrong and how to - * correct it was the one an operator could not see, while the same sentence WAS reaching a - * model on the tool-call path. This route is `requireAdmin`, which is what makes showing it - * here safe and showing it anywhere else not. - * - * 409 rather than 500: nothing broke, and nothing about the request was malformed. Two rows - * of ours disagree, and the request cannot be answered until one of them changes — which is - * what the sentence tells the reader to go and do. - */ - if (isDeploymentFault(error)) { - // `deploymentFaultSentence` rather than `error.message`: the shelf now includes a query - // this database refused, and that one's message is the statement and every value bound to - // it. An administrator is entitled to the reason, not to the dump. - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - throw error; - } - }); - - /** - * The broker's catalogue, as an administrator chooses an app out of it. - * - * THE SEARCH IS OURS, AND HAS TO BE. `@composio/core`'s toolkit listing forwards category, - * managed_by, sort_by, cursor and limit, and takes no search term at all — a term handed to it is - * dropped without a word, and what comes back is an unfiltered first page that looks exactly like - * a result. So the whole directory is read and filtered in this process, over the three fields an - * administrator would actually be typing at: the slug, the name and the description. A few - * hundred rows is a list, not a query. - * - * NO BROKER IS A 503 NAMING THE SETTING, not an empty list. An empty directory and an absent one - * are different facts: the first says Composio has nothing to offer, the second says nobody was - * asked. Answered as `{ apps: [] }`, a deployment with no key draws "no apps available" over a - * remedy that is one environment variable long, which is why - * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written - * here. - * - * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the - * transport reads which app a call is against, so it is the only reading that decides anything; - * the id names the row — `composio-linear` — and reading one as the other would quietly work - * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that - * property made structural: there is no id here to read by mistake. - */ - routes.get("/composio/apps", requireUser, async (context) => { - /* - * INSIDE THE HANDLER, and the response returned. `requireAdmin` is a function that answers a - * response, not Hono middleware: put in the middleware position it typechecks against Hono's - * variadic signature, runs, and gates nothing at all, because nobody reads what it returned. - */ - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - if (!composio) { - return context.json( - { error: new BrokerUnconfiguredError().message }, - 503, - ); - } - - let directory: BrokerApp[]; - try { - directory = await composio.broker.listApps(); - } catch (error) { - // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis - // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); - return context.json({ error: refusal.error }, refusal.status); - } - /* - * AN APP THAT CANNOT BE CONNECTED IS NOT AN APP TO OFFER. Every kind but this one ends at a - * person with a working account; `unsupported` ends at an administrator pressing Add and - * meeting Composio's refusal, because the OAuth client it wants is one this deployment has - * nowhere to hold. Hidden here rather than at the vendor: the filter is a fact about what this - * deployment can drive, not about what Composio publishes. - */ - const connectable = directory.filter( - (candidate) => candidate.connection.kind !== "unsupported", - ); - const term = (context.req.query("q") ?? "").trim().toLowerCase(); - const matched = term - ? connectable.filter((app) => - [app.slug, app.name, app.description].some((field) => - field.toLowerCase().includes(term), - ), - ) - : connectable; - - /* - * THE ONE CALL ON THIS ROUTE THAT IS NOT THE VENDOR'S, AND IT WAS THE ONE NOTHING ANSWERED FOR. - * - * CRITERION. The same one the add routes state: 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. The `try` above wraps `listApps` because that is where a wrong key shows itself, and - * this read sat outside it with no mapping at all — so a query this database refused while - * marking which apps are already enabled gave an administrator the framework's bodyless 500, - * on the one screen where a Composio key has just been set. That is the reading most likely to - * send somebody back to a key that is fine, over a fault Composio had no part in. The same 409 - * and the same sentence the sibling admin routes give the shelf; a failure that is not on it - * still throws, which is what keeps the stack in the log. - */ - let urls: string[]; - try { - urls = await store.serverUrls(); - } catch (error) { - if (isDeploymentFault(error)) { - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - throw error; - } - - const enabled = new Set( - urls - .map((url) => toolkitOf(url)) - .filter((toolkit): toolkit is string => toolkit !== null), - ); - return context.json({ - apps: matched.map((app) => ({ ...app, enabled: enabled.has(app.slug) })), - }); - }); - - /** - * Enable one app of that catalogue, which is a third way for a server to arrive. - * - * THE SLUG VALIDATION IS THE WHOLE ROUTE. `addBrokeredApp` composes `composio://`, and that - * url is what every future call for the app is resolved against — so a slug the directory never - * answered with is a row pointing at an app that does not exist: added, grantable, enabled on a - * Bot, and dead at the first call, with nothing on the page saying so. The live directory is what - * it is checked against rather than a pattern, because the question is not whether the text is - * well formed but whether Composio has such an app right now. - * - * The title comes off the directory entry too. The caller chose an app; they did not choose a - * name for it. - */ - routes.post("/composio/apps", requireUser, async (context) => { - const forbidden = requireAdmin(context); - if (forbidden) return forbidden; - - if (!composio) { - return context.json( - { error: new BrokerUnconfiguredError().message }, - 503, - ); - } - - const body = (await context.req.json().catch(() => null)) as { - slug?: unknown; - } | null; - /* - * ASKED WITH `typeof` RATHER THAN OFF THE ANNOTATION, which is the same hazard the skill routes - * below spell out twice (`:2064`, and again at the grant route): the body is JSON, so the type - * above is a wish. `{"slug":123}` made `(123).trim()` a `TypeError` thrown out of the handler — - * and this statement sits outside every `try` here, so Hono's default handler answered a - * bodyless 500 and the admin page fell back to "That app could not be added". Admin-gated, so - * what it cost was an operator's diagnosis rather than anything they should not have had. - * - * A non-string is simply no slug, and the not-found refusal below already says the true thing - * about that, so the narrowing answers with `undefined` rather than a second sentence. - */ - const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined; - let directory: BrokerApp[] = []; - if (slug) { - try { - directory = await composio.broker.listApps(); - } catch (error) { - /* - * The same failure the directory route answers, in the same words, because it is the same - * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a - * deployment whose key is wrong was told nothing at all, on the one screen where the key - * had just been set. - */ - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); - return context.json({ error: refusal.error }, refusal.status); - } - } - const app = directory.find((candidate) => candidate.slug === slug); - if (!app) { - return context.json( - { - error: slug - ? `${slug} is not an app Composio lists for this deployment.` - : "An app is required.", - }, - 400, - ); - } - - /* - * 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, - logo: app.logo, - by: actorEmail(context), - // Off the directory entry the administrator chose, never derived a second time here: a - // second derivation is a second answer, which is the one thing `BrokerConnection` exists to - // prevent. It decides what config the store creates at the vendor, and what the row records. - connection: app.connection, - }); - return context.json({ server }, 201); - } catch (error) { - // The same mapping the add routes above make, for the same reason: a refusal an - // administrator can correct comes back as itself rather than as a 500. - if ( - error instanceof CustomServerRefusedError || - error instanceof PluginRefusedError - ) { - return context.json({ error: error.message }, 400); - } - // And, as on those routes, enabling refreshes before it answers, so every fault - // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row, - // which is the honest answer whichever side of the insert the fault arrived on. - if (isDeploymentFault(error)) { - return context.json({ error: deploymentFaultSentence(error) }, 409); - } - /* - * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its - * three sibling add routes map and it did not. - * - * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not - * reported as an app that was not enabled, and is not reported as Composio's doing. - * - * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files - * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of - * `listServers` — raising this when it is not there, as `requireServer` does one call - * deeper. Every one of those steps has already committed by then. - * - * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail - * below and an administrator was told "Slack could not be enabled, and Composio said nothing - * about why. Try again, and check this deployment's Composio key if it persists." That is - * wrong three times over: the app WAS enabled, Composio had no part in the step that failed, - * and the one thing the reader now holds — a row on their Plugins page — went unmentioned - * while they were sent to check a key that is fine. - * - * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact - * wearing the same class. On the add routes above, an unknown key is a caller naming a - * server this deployment will not connect to, which is a malformed request. Here the row was - * written and this deployment cannot see it: two of its own reads disagree, and the request - * cannot be answered until one of them changes — the reading the refresh route's own 409 - * spells out. The message is written here rather than passed through, because - * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case. - */ - if (error instanceof CatalogueEntryUnknownError) { - return context.json( - { - error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`, - }, - 409, - ); - } - /* - * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here - * is what left an administrator with nothing. Composio's own sentence — "Default auth config - * not found for toolkit linear_mcp. Composio does not have managed credentials for this - * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the - * browser fell back to its own "That app could not be added". Everything that explains the - * failure existed; nothing carried it the last step. - */ - /* - * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a - * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed - * where an operator could find it. Catching everything fixed the answer and silenced the log, - * and the failure that needs the log most is the one left here — not a refusal this - * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one - * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both - * null is a fault nobody has explained, most often a programmer error on this side, and it - * would otherwise leave only the generic "Composio said nothing about why" in a browser - * nobody is reading a console from. - * - * The same shape and the same restraint as the connection-not-recorded log below: the slug, - * because it is the app an operator is about to be asked about; what the person was told, so - * the console line and the support request can be matched up; and the error read through - * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key, - * no vendor response, no request body, and no statement — the two sentence tests below do not - * exclude a query failure, they are the branch one arrives in. - * - * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above - * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf, - * and the read-back that could not find it — so what is left arrives from the steps before - * the insert, where nothing was written and the app really was not enabled. The generic - * sentence below names Composio for the same reason: the one call in front of the insert is - * `ensureAuthConfig`, which is a call to the vendor. - */ - if (brokerSentence(error) === null && vendorSentence(error) === null) { - console.error( - JSON.stringify({ - type: "composio-app-not-enabled", - slug: app.slug, - note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", - error: reasonWithoutStatement(error), - }), - ); - } - - const refusal = brokerRefusal( - error, - `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, - ); - return context.json({ error: refusal.error }, refusal.status); - } - }); - - /** - * Where a person's own connections are, and how to start a new one. - * - * Not admin-only, and that is the point: an administrator registers the connector once, and then - * everybody connects their own account. Somebody can only ever see or start their own. - */ - routes.get("/connections", requireUser, async (context) => { - /* - * BOTH TABLES, ONE LIST, because the person asking has one question. - * - * "Am I connected to this?" is the same question whether the grant is a refresh token in this - * deployment's vault or an account Composio holds on our behalf. Which table a connection lives - * in is a fact about how the vendor is reached — the transport, the credential, who keeps the - * secret — and none of that is something a settings page should have to know in order to draw a - * word beside a row. Answering out of `connectionsFor` alone left the brokered half invisible, - * so the page could only either say "Not connected" over a live account or say nothing at all, - * and it chose to say nothing. - * - * CONCATENATED WITHOUT REWRITING, because the fields a settings page draws from — the server - * id, the scope, the date — line up across the two reads, and one row template can draw either - * kind. What does not line up is what `brokeredConnectionsFor` adds — `verified`, `verifiedAt`, - * `probe` and `checkable` — so the list that leaves here is not uniform. Those travel because a - * brokered row is the only one with anything to re-check: this deployment holds no secret for - * it, only a note that Composio said yes, and that note can drift when somebody ends the - * connection in Composio's own dashboard. `probe` rides with the first two because the flag - * cannot be read alone — three situations share one `verified: false`, and which action the - * check actually SPENT is what separates them on a page that has done nothing but load. - * - * `probe` AND `checkable` ARE TWO ANSWERS AND NOT ONE SENT TWICE, and a caller that treats them - * as interchangeable breaks the page in one of two opposite ways. `probe` is a record of the - * check that was made; `checkable` is whether the app has anything to check with NOW. They - * agreed while the first was derived, and an administrator's press of Refresh moved what the - * app publishes without touching what the check spent — so the page accused a key nobody had - * tried. Recording the first fixed that and deadlocked the other half: a key nothing was spent - * on reads null for good, and the button that is the only way to ever spend one was gated on - * that null. So the sentence is drawn off `probe` and the button off `checkable`; see - * `brokeredConnectionsFor`, which sets both failures out in full. A held connection has no - * equivalent question, so its rows carry none of these fields, and their absence is what tells - * the two READS apart. It is not how a reader learns how an app connects: that is the app's - * recorded `authScheme`, and a page asking this list instead would be deriving a second answer - * to a question the row already carries. - * - * SORTED, so two requests answer in the same order. Each read is ordered by server id within - * its own table, and concatenating two sorted lists is not a sorted list. Compared as plain - * strings rather than by `localeCompare`, because the order only has to be the SAME one every - * time, and a collation that varies with the deployment's locale is not that. - */ - const [held, brokered, shared] = await Promise.all([ - store.connectionsFor(context.var.actor.id), - store.brokeredConnectionsFor(context.var.actor.id), - store.sharedConnections(), - ]); - const connections = [ - ...held.map((row) => ({ ...row, holder: "person" as const })), - ...brokered.map((row) => ({ ...row, holder: "person" as const })), - ...shared, - ].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 - // this deployment will send. Null means the deployment has no public URL and cannot connect. - redirectUri: connect?.publicUrl - ? redirectUriFor(connect.publicUrl) - : null, - }); - }); - - /** - * Begin connecting an account: a person's own, or, for a Shared app, the deployment's team - * account. - * - * Answers with a URL rather than redirecting, so the browser decides when to leave the page. The - * state is minted here, from the session and the row's mode, and never from anything the request - * carries. - */ - routes.post("/servers/:id/connect", requireUser, async (context) => { - const serverId = context.req.param("id"); - - /* - * A BROKERED APP IS ANSWERED HERE AND GOES NO FURTHER DOWN THIS HANDLER. - * - * Everything below this branch belongs to the consent flow THIS deployment runs: a public URL - * to build a redirect URI out of, a catalogue entry naming the vendor's authorization - * endpoint, an OAuth client an administrator registered, a sealed state the callback reads - * back. A brokered app has none of it. Composio holds the consent, so no authorization code - * ever comes back to us, no refresh token is stored here, and no redirect URI of ours is - * registered with anybody — there is nothing for those checks to be about. - * - * WHICH IS WHY THE ORDER IS THE WHOLE POINT AND NOT A TIDINESS. Falling through, a brokered - * row met `catalogueEntry`, which has never heard of `composio-linear`, and the person - * pressing Connect was told the app "is not connected as an individual person" — the exact - * opposite of true about the one kind of row that is ONLY ever connected as an individual - * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, - * refusing for want of a setting that has no bearing on a flow it does not enter. - * - * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the - * directory route says: the url is where the transport reads which app a call is against, and - * the id is a row name that happens to look similar. - * - * ONE ROW, BY ID. Every request to this route pays for this read, including the ones that fall - * through to the OAuth flow below, because the branch cannot be taken until the row is in hand - * — so what it costs has to be a lookup of three columns rather than the whole plugin surface. - * `serverAddress` answering `undefined` is an id naming no row, which falls through exactly as - * a missing row did when this was a `.find`. - */ - const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; - if (row && toolkit) { - /* - * AN APP THAT NEEDS NO CREDENTIAL IS ANSWERED HERE, BEFORE `brokeredAccountFor` IS EVER - * ASKED WHOSE ACCOUNT THIS IS. - * - * `accountRefFor` — which that resolver calls — refuses a `none` scheme too, but in the one - * sentence it can write for all three routes that share it: "needs no account", with nothing - * about what that means or what the app can still do. That function speaks for confirm, - * re-check and end as well as connect, so it cannot know which of them is asking or reach for - * this route's fuller wording. Asked first and answered here instead, the sentence this route - * has always had for a no-auth app — naming what the app is and that a Bot can use it as it - * is — stays the one a person pressing Connect reads, rather than going dead code behind the - * resolver's terser refusal. - */ - if (schemeKind(row.authScheme) === "none") { - return context.json( - { - error: `${row.title} needs no account, so there is nothing to connect. A Bot granted its tools can use it as it is.`, - }, - 400, - ); - } - - /* - * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, THE SAME RESOLVER THE OTHER THREE - * BROKERED ROUTES CALL, AND FIRST IN THIS BRANCH SO NOTHING BELOW CAN ACT BEFORE IT HAS - * ANSWERED. A Personal app's account is the actor's own; a Shared app's is the deployment's, - * and only an administrator may reach that one — both decided there, once, rather than - * re-derived here. Its refusal already covers no broker being configured and a row that - * `accountRefFor` cannot resolve, in the same words this branch used to write itself. The - * `none` scheme is the one member of that refusal's vocabulary this branch now answers - * itself, for the reason just given — everything `accountRefFor` can still be asked about a - * row that reaches this call is never `none`. - */ - const resolved = await brokeredAccountFor(context, serverId); - if (resolved.refusal) { - return context.json( - { error: resolved.refusal.error }, - resolved.refusal.status, - ); - } - const { account } = resolved; - - /* - * `composio` IS CHECKED AGAIN HERE FOR `tsc`'S BENEFIT AND NOT FOR THE REFUSAL. The function - * above closes over the same binding and has already returned for everybody it is unset for - * — this can only be reached once it answered — but that is a fact about the function's - * body and not one the type checker can see across the call above. Without this, every - * `composio.broker` read below the field and consent branches would be a `possibly - * undefined` it cannot actually be. - */ - if (!composio) { - return context.json( - { error: new BrokerUnconfiguredError().message }, - 503, - ); - } - - /* - * THE ACCOUNT IS `brokeredAccountFor`'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 one is passed in, 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. For a Shared app the account is the deployment's rather than whoever is - * signed in, decided above and never by anything the request carries. - */ - const existing = await store.brokeredConnection({ toolkit, account }); - if (existing) { - /* - * Named with the step to take rather than only refused: a second link would attach a - * second account behind a row that already says connected, and the way to a new one is - * through the connection they have. - * - * THE APP'S TITLE, NOT THE ROW'S ID. `composio-linear` is this deployment's name for a - * table row; "Linear" is the name of the thing the person connected and the only one of - * the two they have ever seen on a screen. An internal key in a sentence addressed to a - * person is both unhelpful and a small leak of how the rows are keyed. - * - * WHOSE ALREADY, SAID PLAINLY FOR A SHARED APP. "You already have an account" is true for - * a Personal app and false for a Shared one — nobody signed in holds that account, the - * deployment does — so a deployment account's refusal names the deployment's connection - * rather than putting the actor's own claim on it. - */ - return context.json( - { - error: - account.holder === "deployment" - ? `${row.title} already has a shared account connected. Disconnect it first if you want to connect a different one.` - : `You already have an account connected to ${row.title}. Disconnect it first if you want to connect a different one.`, - }, - 409, - ); - } - - /* - * AN APP WHOSE SECRET THE PERSON HOLDS IS ANSWERED HERE AND NEVER SENT AT A CONSENT SCREEN. - * - * Most of Composio's catalogue connects this way rather than through a consent screen: the - * person already holds an API key, so there is nothing to consent to, no url to mint and no - * return leg to build — the press that opens a vendor page for a consent app has to answer - * with a form instead, and the press after it carries what was typed into it. One route - * serves all three arms because the browser asks the same question every time: connect me to - * this app. The third is below: an app that needs no credential at all, which is answered - * with the fact rather than with either flow. - * - * 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 every kind 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; - - /** - * WHAT THIS FORK ANSWERS FOR EACH KIND OF APP STILL REACHABLE HERE, AND EVERY ARM IS REACHED - * BY DECIDING. - * - * Type-only and erased; see {@link Decides} in `./broker`. A member of this three-name - * vocabulary left unanswered fails `tsc` here by name, which is what this roster is for: the - * fork below reads it with a chain of `if`s, and an `if` chain has no opinion about the - * answers it was not written for. - * - * `none` IS NOT A MEMBER OF IT, AND IS EXCLUDED RATHER THAN ANSWERED `unreachable`. The - * branch above — before `brokeredAccountFor` is ever called — already refused a `none` - * scheme in the route's own words, so `authScheme` read here can never be one: this fork is - * about the three kinds of app that branch does not already dispose of. - * - * CRITERION. Every arm below is entered because {@link schemeKind} said so. No arm is the - * fall-through of the others. - * - * REASON. This file used to import {@link isFieldScheme} and not `schemeKind`, and compare one - * raw literal beside it — so the fork was two tests and a fall-through, and the fall-through - * was the consent arm. A scheme this deployment cannot read — a null, or a literal nothing - * here writes, which the row that ANSWERS for an app is free to carry because - * `mcp_servers.url` has no unique index — therefore landed in the arm that mints an - * authorization link at Composio for an app with no consent config behind it. That is a real - * act performed at the vendor on behalf of an answer nobody decided, and its only possible end - * is a link the person cannot complete. The three readers of this column in `./store` already - * fail closed on that value; this one was the last that did not. - */ - type _ConnectForkDecides = Decides< - Exclude, - { - key: "answers with the form the app publishes, and connects what is typed into it"; - consent: "mints a link at Composio for the person to finish at the vendor's own screen"; - unreadable: "refuses, and asks Composio nothing — it is not a consent app and there is no link to mint"; - } - >; - const kind = schemeKind(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 }); - - /* - * AN APP WITH NO BOXES CANNOT BE CONNECTED BY SOMEBODY TYPING, WHICH IS THE ONLY WAY THIS - * BRANCH CONNECTS ANYBODY. - * - * {@link isFieldScheme} admits exactly the schemes whose secret a PERSON holds, so an - * empty published list is not a short form — it is a form with nowhere to put the one - * thing the scheme is for. Every check below passes such a submission for want of anything - * to check, and what it makes is an account Composio marks `ACTIVE` because Composio does - * not grade what it is handed. An app with no probeable action then has that row written - * and drawn as connected on every screen here. - * - * ON THE SUBMISSION AND NOT ON THE PRESS ABOVE IT. The empty list is a true answer to - * "what does this app ask for" — {@link ComposioBroker.connectionFields} says so, and - * distinguishes it there from the scheme the app no longer publishes at all, which refuses. - * What is false is the account the next press would make, so that is the press that is - * refused. - * - * THE REMEDY IS AN ADMINISTRATOR'S, because nothing the person pressing Connect can do - * changes what the app publishes or which scheme this deployment recorded for it. - */ - if (published.length === 0) { - return context.json( - { - error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, - }, - 400, - ); - } - - /* - * AND AN APP THAT PUBLISHES BOXES BUT REQUIRES NONE OF THEM IS THE SAME ACCOUNT WITH A FORM - * IN FRONT OF IT. - * - * THE GUARD BELOW READS `required` ON THE NAMES THAT ARRIVED; THIS ONE READS THE LIST. A - * published list with no required field in it leaves that guard nothing to be about, so - * `{"values":{}}` passes it vacuously — and so does a box holding spaces, and so does the - * default the form seeded itself from. What each of the three makes is an account carrying - * no credential, which Composio answers `ACTIVE` for because it does not grade what it is - * handed, and which an app with no probeable action leaves recorded and drawn as connected: - * the very end state the required guard exists to prevent, reached down the one path that - * guard cannot see. - * - * THE CLAIM THIS RESTS ON, STATED RATHER THAN ASSUMED: EVERY FIELD SCHEME IN COMPOSIO'S - * CATALOGUE PUBLISHES AT LEAST ONE REQUIRED FIELD, BECAUSE A FIELD SCHEME IS COMPOSIO'S OWN - * STATEMENT THAT THE PERSON HOLDS A CREDENTIAL. What makes it a claim about the catalogue - * rather than a guess is that "this app needs nothing" already has its own name there: - * `NO_AUTH` sits beside `API_KEY`, `BASIC`, `BEARER_TOKEN` and `BASIC_WITH_JWT` in the - * vendor's own scheme enum (`@composio/core` 0.18.1, `src/types/authConfigs.types.ts:9-24`), - * and this route answers it in its own branch a few lines above — so an app that reached - * HERE has told Composio it needs SOMETHING, and a field list requiring nothing is not that - * app saying it needs nothing after all. - * - * AND THE SDK SAYS WHAT THE SOMETHING IS, PER SCHEME. `connectedAccountAuthStates.types.ts` - * declares the shape of an ACTIVE account one scheme at a time: `BASIC` and `BASIC_WITH_JWT` - * require `username` and `password`, `BEARER_TOKEN` requires `token`, and `NO_AUTH` requires - * nothing at all — an ACTIVE no-auth account is the base row and no credential beside it. - * `API_KEY` is the one that marks `api_key` and `generic_api_key` optional, and it does so - * because the NAME of the box varies across the catalogue and both spellings occur in it, - * not because an API_KEY account may hold no key: the same declaration keeps a `catchall` - * for the third spelling. Three of the four field schemes therefore say in the vendor's own - * types that an account of theirs carries a credential, and the fourth says which credential - * it cannot name. - * - * WHAT WAS NOT DONE, AND IT IS THE HONEST LIMIT OF THE CLAIM: the live catalogue was not - * enumerated. There is no network and no Composio key on this path, so "no toolkit publishes - * a field scheme with an empty required list" is ARGUED from the vendor's own package rather - * than counted across its apps. The guard is written to fail in the safe direction for that - * reason. If such an app does exist, it becomes an app this deployment will not connect and - * SAYS SO, with a remedy addressed to the one person who could change it — which is the - * direction the empty-list guard directly above already chose, and the opposite of the - * silent credential-less account that is the alternative. - * - * THE STATE UNDER THIS GUARD IS ONE STATE AND NOT TWO, WHICH IS WHAT MAKES IT DECIDABLE AT - * ALL. "An app whose required flags we could not read" used to be indistinguishable from - * "an app that requires nothing", and it is no longer: {@link ComposioBroker.connectionFields} - * reads each flag through `flagOf`, which answers the caller's default for an ABSENT flag - * and refuses one that is PRESENT and not a boolean. So a list arriving here with every - * `required` false is the vendor's own no on every row — never a shape this deployment - * failed to read, because that shape never gets past the adapter. - * - * ON THE APP AND NOT ON THE SUBMISSION, AND THE SEEDED DEFAULT IS WHY. A guard that demanded - * one non-blank value would be satisfied by the form's own work: the form draws each box - * holding the default the app published and posts every published name back whether or not - * anybody touched it, so a person who types nothing at an app publishing an optional - * `base_url` submits that default — non-blank, unchosen, and a credential in no sense. The - * thing that is false here is the app's field list, so the app is what is refused. - * - * ON THE SUBMISSION AND NOT ON THE PRESS ABOVE IT, for the empty-list guard's reason: the - * list is a true answer to "what does this app ask for", and what is false is the account - * the next press would make. - * - * THE SENTENCE NAMES THE BOXES THE APP DOES PUBLISH AND NO VALUE OF ANY KIND. These names - * are the VENDOR's, read off the list just fetched rather than off the request, and they are - * the whole of what an administrator needs to see what the app is publishing now. Nothing - * submitted appears — not even a value that merely matches a published default, because on - * this request that value is the caller's text rather than the vendor's. - */ - if (published.every((field) => !field.required)) { - const optional = published.map((field) => field.name).join(", "); - return context.json( - { - error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one Composio publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, - }, - 400, - ); - } - - /* - * 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. - * - * AND THE BAG HOLDS ONLY WHAT WAS PUT IN IT, WHICH `{}` DOES NOT. Every name here is the - * VENDOR's — chosen by whoever publishes the app at Composio and never by this deployment — - * and a plain object literal answers for names nobody submitted: `values.constructor` and - * `values.toString` come back as functions off `Object.prototype`, so the required guard - * below calls `.trim()` on a function and answers a person who typed nothing wrong with a - * 500. A prototype value that happened to be TEXT would be worse still, because that guard - * would count a credential nobody supplied and connect the account anyway. The other - * direction fails silently: `values["__proto__"] = "acme"` on a literal reaches the - * prototype setter, which ignores a string, so a value somebody typed is dropped between - * the check that admitted it and the store that was meant to receive it — an account made - * at Composio without one of the values the app publishes, marked `ACTIVE` because Composio - * does not grade what it is handed, and drawn as connected on every screen here. - * - * A NULL-PROTOTYPE BAG IS THE WHOLE FIX AND IT IS THE SAME ONE THE FORM USES. Nothing is - * inherited, so a read answers `undefined` exactly when nobody submitted that name and a - * write stores what it was given under whatever name it was given. `Object.keys` — which is - * what the audit row is built from one layer down — reads the same either way, and the - * names are all this object's contents may ever appear as. - */ - const names = new Set(published.map((field) => field.name)); - const values: Record = Object.create(null); - 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; - } - - /* - * AND WHAT THE APP SAID IT CANNOT DO WITHOUT HAS TO BE THERE, WHICH THE LOOP ABOVE NEVER - * ASKED. That loop checks every name that ARRIVED; `required` is a fact about a name that - * did not. A body of `{"values":{}}` passes it vacuously — no name is unpublished, no value - * is not text — and what that connects is an account carrying no credential at all, which - * Composio accepts because it does not grade what it is handed and which an app with - * nothing safe to probe leaves recorded and drawn as connected. - * - * `required` HAD ONE READER AND IT WAS THE BROWSER. It rode the field out to the form and - * became an HTML attribute, which is a courtesy to somebody filling boxes in and no kind of - * guard: this route is reachable without that form, and the form itself accepts a space. - * - * BLANK IS ABSENT, AND WHITESPACE IS BLANK. The person's claim is that they filled the box - * in, and a box holding spaces is one they did not — no vendor reads a key made of - * whitespace, so admitting it buys exactly the account this guard exists to refuse, with a - * value in it that makes the state harder to read rather than easier. The JUDGEMENT is made - * on the trimmed text; what travels is what was typed, because trimming somebody's - * credential on its way out is editing it, and a key that really does carry padding is the - * vendor's to reject in its own words. - * - * OPTIONAL BOXES ARE UNTOUCHED BY THIS, and the distinction is the whole of why it reads - * `required` rather than counting values. The form seeds each box from the default the app - * published and posts every published name back whether or not anybody touched it, so a - * blank optional value is what an ordinary submission carries. - * - * THE SENTENCE NAMES THE FIELDS AND NOTHING ELSE. These names are the VENDOR's, read off - * the list this deployment just fetched rather than off the request — which is what sets - * them apart from the refusal above, where the offending name is the caller's own text — - * and no value of any kind appears here, for the reason the whole of this branch is built - * around: a submitted value is somebody's own credential and belongs in no message, no log - * and no row. - */ - const missing = published - .filter( - (field) => field.required && !(values[field.name] ?? "").trim(), - ) - .map((field) => field.name); - if (missing.length > 0) { - return context.json( - { - error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to Composio. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, - }, - 400, - ); - } - - 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, - account, - by: 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); - } - } - - /* - * A SCHEME THIS DEPLOYMENT CANNOT READ IS REFUSED, AND IS NOT WALKED INTO THE CONSENT FLOW. - * - * `unreadable` is a null column or a literal nothing here writes — which is an ordinary row - * rather than a corrupt one: `mcp_servers.url` carries no unique index, so the row that - * answers for an app is not always the row an enable wrote a scheme onto, and a row may have - * been inserted by hand or restored from a deployment that knew other names. Reached by - * elimination, it arrived at the arm below and minted an authorization link at Composio for an - * app this deployment holds no consent config for — a call spent at the vendor, and a link - * whose only possible end is the person failing to complete it at a screen that has nothing - * to ask them. - * - * THE REMEDY IS AN ADMINISTRATOR'S, because nothing the person pressing Connect can do - * changes which scheme this deployment recorded for the app. Re-adding it is what writes the - * column again, which is the same remedy the field branch above names for its own two - * unreadable-app states. 400 rather than 503: it is a fact about this one app's row and not - * about the deployment's settings. - */ - if (kind === "unreadable") { - return context.json( - { - error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to Composio and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way Composio connects it now.`, - }, - 400, - ); - } - - /* - * NO APP URL IS A REFUSAL, NOT A LINK WITH NO WAY BACK. - * - * The address below is where Composio sends this person once they have consented, and it has - * to be absolute: the consent screen is on another company's origin, so a relative path - * resolves against theirs. A deployment that cannot say where its own pages are cannot - * produce one — and minting the link anyway would leave somebody stranded on Composio's - * hosted page having just granted access, with no route back to the deployment that asked - * for it and nothing here knowing it happened. - * - * The OAuth flow below refuses for its missing `OPENBOT_PUBLIC_URL` in these same terms and - * for this same reason. `OPENBOT_APP_URL` is the setting here because the two addresses are - * genuinely different: the API is one origin and the browser app is another, and it is a - * page this person is coming back to rather than an endpoint. - * - * BELOW BOTH BRANCHES ABOVE AND NOT IN FRONT OF THEM, FOR THE REASON THIS BRANCH'S OWN HEADER - * GIVES ONE GUARD EARLIER. Neither an app whose secret the person types nor one that needs no - * credential at all mints a link or has a return leg, so this setting has no bearing on either - * flow — and standing before the fork, this guard meant no key app and no no-auth app could be - * connected on a deployment without `OPENBOT_APP_URL`, refused in the name of a remedy that - * would not have helped. It stays after the one-account guard for - * the reason that guard's own comment gives: somebody who already has an account attached is - * told the step to take, rather than handed an operator's configuration complaint about a - * link that was never going to be minted for them. - */ - if (!connect?.appUrl) { - return context.json( - { - error: - "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", - }, - 503, - ); - } - - /* - * WHERE THE CONSENT COMES BACK TO, BUILT HERE AND NEVER READ OFF THE REQUEST. - * - * Composio sends the person to this address when they are done, so whoever chooses it - * chooses where somebody lands holding a just-completed consent. A url taken from the body, - * the query or a header would therefore be an open redirect with a consent screen in front - * of it — the exact thing {@link ConnectOrigin} exists to stop on the OAuth flow below, and - * it is narrowed here in the same way: the caller may name one of two PAGES, and the origin - * underneath them is this deployment's configured app URL in both cases. - * - * Both pages confirm on load, which is what makes either of them a correct destination: the - * return trip carries nothing signed, so arriving proves nothing, and the page asks Composio - * whether the account is really attached before anything here says it is. - * - * FORCED TO "admin" FOR A DEPLOYMENT ACCOUNT, AND NOT LEFT TO THE QUERY. A Shared app's - * settings page shows nobody's own connection — the account the consent is about belongs to - * the deployment — so "settings" would land an administrator who just granted it back on a - * page with nothing of theirs to confirm. The admin screen is the one that reads a Shared - * app's row, and it is where this consent began. - */ - const returnTo: ConnectOrigin = - account.holder === "deployment" - ? "admin" - : context.req.query("returnTo") === "admin" - ? "admin" - : "settings"; - - /* - * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to the connection - * resolved above — this person's own for a Personal app, or the deployment's for a Shared - * one — 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, - * or the deployment's shared one, for as long as it stays valid — which is why the failure - * below answers with the vendor's sentence and never with what was being minted when it - * failed. - */ - let redirectUrl: string; - try { - ({ redirectUrl } = await composio.broker.authorize({ - account, - toolkit, - /* - * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. - * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` - * entry — and nothing between there and Composio has ever looked at it, so - * `openbot.example.com` with the scheme left off builds a callback that is not a - * callback. That failure lands after somebody has consented, on the vendor's page, where - * this deployment cannot tell them anything; the guard moves it to before the link is - * minted, where the sentence reaches an operator who can set the variable. - */ - returnUrl: brokerReturnUrl( - connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), - ), - })); - } catch (error) { - const refusal = brokerRefusal( - error, - `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, - ); - return context.json({ error: refusal.error }, refusal.status); - } - return context.json({ authorizationUrl: redirectUrl }); - } - - if (!connect?.publicUrl) { - return context.json( - { - error: - "This deployment has no public URL configured, so it cannot complete a consent flow. Set OPENBOT_PUBLIC_URL.", - }, - 503, - ); - } - - const entry = catalogueEntry(serverId); - if (entry?.auth.kind !== "user-oauth") { - return context.json( - { error: `${serverId} is not connected as an individual person.` }, - 400, - ); - } - - /* - * A dynamic entry introduces the deployment itself on first use; a manual one still waits - * for an administrator. Registration lives here, on the one handler that already refuses - * without OPENBOT_PUBLIC_URL — the redirect URI it registers is guaranteed to exist. - */ - /* - * A vendor in the catalogue that nobody has added to this deployment reaches here, gets past - * every check above — the entry is real — and then asks the store for a client it cannot have, - * because there is no server row to hold one. `ensureOAuthClient` says so by throwing, and - * unhandled that was a 500 on the one path where a person is trying to connect their account. - * - * The same 409 as a vendor whose client an administrator has not pasted in yet, because it is - * the same situation: the person pressing Connect has no step to take, and an administrator has - * one. The sentence names the step rather than the exception. - */ - let client: OAuthClient | null; - try { - client = - (await store.oauthClientFor(serverId)) ?? - (entry.auth.clientRegistration === "dynamic" - ? await store.ensureOAuthClient(serverId, actorEmail(context)) - : null); - } catch (error) { - if (error instanceof CatalogueEntryUnknownError) { - return context.json( - { - error: `${entry.title} has not been added to this deployment yet. An administrator has to add it first.`, - }, - 409, - ); - } - throw error; - } - if (!client) { - if (entry.auth.clientRegistration === "dynamic") { - return context.json( - { - error: `${entry.title} would not register this deployment, or could not be reached. Try again, and check the vendor's status if it persists.`, - }, - 502, - ); - } - return context.json( - { - error: `${entry.title} has no OAuth client registered yet. An administrator has to add one first.`, - }, - 409, - ); - } - - /* - * Where to come back to, as one of two names rather than a URL the caller chose. - * - * Read from the query and narrowed immediately, so an unrecognised value is the default rather - * than something carried into a sealed state. See {@link ConnectOrigin}: a destination that could - * name another origin is an open redirect with a consent screen in front of it. - */ - const returnTo = - context.req.query("returnTo") === "admin" ? "admin" : "settings"; - - const verifier = createVerifier(); - return context.json({ - authorizationUrl: authorizationUrlFor({ - auth: entry.auth, - clientId: client.clientId, - redirectUri: redirectUriFor(connect.publicUrl), - state: await sealConnectState( - { userId: context.var.actor.id, serverId, verifier, returnTo }, - connect.encryptionKey, - ), - codeChallenge: challengeFor(verifier), - }), - }); - }); - - /** - * Which app, and whose account in it, one of the four routes below is about, or the refusal that - * ends it — decided by the row's mode and the signed-in person, never by anything in the request. - * - * Each of them acts on a brokered connection and on nothing else, so each asks the same 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, or at a Shared app - * they have no business touching, should not be able to drift into four sentences. - * - * THE APP COMES OFF THE ROW'S URL AND NEVER OFF ITS ID, for the reason the directory route and - * the connect branch above both give: the url is where the transport reads which app a call is - * against, and the id is a row name that happens to look similar. - * - * ONE ROW, BY ID, AND CONFIRM IS WHY. Both brokered account screens call that route from an - * effect when they mount, so this read runs on every page load — and it used to be - * `listServers`, which materialises every tool and every grant in the deployment to answer - * whether one row is brokered. - * - * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this - * deployment really has — what is wrong is that its connection does not live at Composio, and - * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an - * id naming no row at all, which is the same answer from the caller's side. - * - * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is - * {@link BrokerUnconfiguredError}'s own message rather than a sentence written here. - * - * THE ACCOUNT ITSELF COMES FROM `store.accountRefFor`, WHICH READS THE ROW AND THE ACTOR AND - * NEVER THE REQUEST. A Personal app answers with the actor's own identity; a Shared app answers - * with the deployment's — one account, decided here, rather than whatever a caller could name in - * a body. A Shared app's account belongs to the deployment and not to whoever is signed in, so an - * actor who is not an administrator is refused here, once, instead of in four routes below. - * - * THE CONNECT ROUTE'S BROKERED BRANCH IS THE FOURTH, CALLED ONLY ONCE IT ALREADY KNOWS THE - * BRANCH IS TAKEN. That route still asks `toolkitOf` itself before this function is ever reached - * — a row that is not brokered has an OAuth flow below it to fall through to, and refusing it - * here, where there is no such flow to fall through to, would be wrong. Once that question is - * settled and the answer is yes, whose account the connection is for is exactly the question - * this function exists to answer once, rather than a fourth place a Shared app's account could - * be decided differently from the other three. - */ - const brokeredAccountFor = async ( - context: { var: AppVariables }, - serverId: string, - ): Promise< - | { - toolkit: string; - account: AccountRef; - title: string; - refusal?: undefined; - } - | { - toolkit?: undefined; - account?: undefined; - title?: undefined; - refusal: { error: string; status: 400 | 403 | 503 }; - } - > => { - const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; - if (!toolkit) { - return { - refusal: { - error: "That app is not reached through a broker.", - status: 400, - }, - }; - } - if (!composio) { - return { - refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, - }; - } - const resolved = await store.accountRefFor(serverId, context.var.actor.id); - if ("refusal" in resolved) { - return { refusal: { error: resolved.refusal, status: 400 } }; - } - if ( - resolved.ref.holder === "deployment" && - context.var.actor.role !== "admin" - ) { - return { - refusal: { - error: "An administrator connects shared apps.", - status: 403, - }, - }; - } - return { toolkit, account: resolved.ref, title: resolved.title }; - }; - - /** - * 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 AT THE ROUTE — `brokeredAccountFor` is where that - * happens instead. A Personal app's confirm is not an administrative act, so the route itself - * stays open to anybody signed in; a Shared app's is, and it is the resolver that tells the two - * apart by the account it resolves rather than this route carrying `requireAdmin` unconditionally. - */ - routes.post( - "/servers/:id/connection/confirm", - requireUser, - async (context) => { - const resolved = await brokeredAccountFor( - context, - context.req.param("id"), - ); - if (resolved.refusal) { - return context.json( - { error: resolved.refusal.error }, - resolved.refusal.status, - ); - } - - /* - * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, AND THERE IS NO SECOND SOURCE FOR IT. - * 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. `by` is the session's actor, for the - * trail, and is the same id for a Personal app's own account and an administrator confirming a - * Shared app's. - * - * The store's answer is passed straight back rather than restated here. `connected` is what - * Composio said, and a shape invented at this layer would be a second opinion about a fact - * only the vendor holds. - */ - try { - return context.json( - await store.confirmBrokeredConnection({ - toolkit: resolved.toolkit, - account: resolved.account, - by: context.var.actor.id, - }), - ); - } catch (error) { - /* - * A BROKER THAT WOULD NOT ANSWER IS NOT A CONNECTION THAT IS ABSENT. - * - * This route is called on every page load, so the tempting answer to a failure is - * `{ connected: false }` — and that would be this deployment inventing a fact only Composio - * holds, drawing "Not connected" over a live account and, one step on, deleting the row - * that says otherwise. The store deletes on a NO from the vendor, and a failure is not a - * no. So the page is told the ask failed, and what it goes on showing is the last answer - * Composio gave rather than a guess about this one. - */ - const refusal = brokerRefusal( - error, - "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", - ); - return context.json({ error: refusal.error }, refusal.status); - } - }, - ); - - /** - * Try this person's key against the app, because they pressed the button that asks. - * - * A BUTTON, AND NEVER A PAGE-LOAD EFFECT, which is the one thing a caller of this route has to - * know. Composio never re-checks a key — it accepts one when it is typed and says nothing about it - * again — so this is the only thing in the product that can correct a row whose key was rotated, - * revoked or left to expire. That is also the argument somebody will make for calling it from an - * effect on mount, and it is wrong: the call goes out to the app on the person's OWN account and - * against their own rate limit at the vendor, so verifying on every render would spend somebody's - * quota at Linear to redraw one word on a settings page. The confirm route above is the one that - * runs on mount; it asks Composio about its own records and costs the person nothing. - * - * AND IT IS NOT A CONNECT. The store's argument is made there in full: the account already exists, - * so nothing here creates one, nothing withdraws one when the key turns out to be bad — their - * account stays, it is their key that is wrong — and nothing changes but the verification and its - * date. - * - * A PROBE THAT RAN AND FAILED COMES BACK AS A FAILURE, never as a 200 saying `verified: false`. - * That flag is also what an app publishing nothing safe to call produces, and the row drawing this - * answer cannot tell the two apart — so an answer would quietly drop the Re-check button in - * exactly the state somebody needs it, having just fixed their key, while telling them nothing had - * ever been checked. The store raises with Composio's own sentence in it, and this passes that - * through as a refusal the browser surfaces. The only `verified: false` that arrives as an answer - * is the one carrying `probe: null`, which says there was nothing to check with. - * - * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, as on the two routes around it and for - * the sharper reason this one adds: an account 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 AT THE ROUTE, for confirm's reason: `brokeredAccountFor` - * is where a Shared app's account is told apart from a Personal one's, and only the former needs - * an administrator. - */ - routes.post( - "/servers/:id/connection/recheck", + const context: PluginRouteContext = { + routes, + store, requireUser, - async (context) => { - const resolved = await brokeredAccountFor( - context, - 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, - account: resolved.account, - by: 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 account — this person's own, or the deployment's for a Shared app — 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 account this app held, so a delete that ran before the revoke could leave a live - * grant on somebody's mailbox that nothing here can reach. What comes back is what was asked for - * — `vendorRevocationRequested` false is a grant that was already gone — and it is passed through - * rather than rewritten, because telling those two apart is the whole value of the field. - * - * `reason` IS "self" OR "admin" BECAUSE OF WHOSE ACCOUNT `brokeredAccountFor` RESOLVED, not - * because of who is asking in general: a Personal app's account is always the actor's own, so it - * ends as "self"; a Shared app's account is the deployment's, and only an administrator reaches - * this route for one, so it ends as "admin". The other word the store takes is `person_removed`, - * which belongs to an administrator offboarding somebody from the People screen, and - * `mode_switched`, filed when an app moves between Personal and Shared. The trail tells these - * apart by this word and by whether `by` and the account's own id differ. - * - * BEHIND `requireUser` AND NOT ADMIN-GATED AT THE ROUTE, for confirm's reason: - * `brokeredAccountFor` is where a Shared app's account is told apart from a Personal one's and - * refused to anybody but an administrator. - */ - routes.delete("/servers/:id/connection", requireUser, async (context) => { - const resolved = await brokeredAccountFor(context, context.req.param("id")); - if (resolved.refusal) { - return context.json( - { error: resolved.refusal.error }, - resolved.refusal.status, - ); - } - - /* - * WHOSE ACCOUNT THIS IS COMES FROM `brokeredAccountFor`, here as on confirm and for a sharper - * reason: an account a caller could name would be a DELETE that revokes somebody else's grant - * at the vendor. `by` is read once, from `context.var.actor`, and is the acting administrator - * for a Shared app's account rather than the account's own id — nothing in the body or the - * query is looked at at all. - */ - try { - return context.json( - await store.disconnectBrokered({ - toolkit: resolved.toolkit, - account: resolved.account, - by: context.var.actor.id, - reason: resolved.account.holder === "deployment" ? "admin" : "self", - }), - ); - } catch (error) { - /* - * REPEATING IT IS THE RECOVERY, AND THE SENTENCE SAYS SO RATHER THAN GUESSING HOW FAR IT GOT. - * - * The revoke runs before anything here is deleted, which is what makes a second press safe: - * whatever this failed at, the state it leaves is access dead or access untouched, never - * access live with nothing here able to reach it. Claiming "nothing was changed" would be a - * guess — a delete that succeeded and an audit write that did not is the same throw — and the - * one thing a person needs is the button to press, not this deployment's theory of where it - * stopped. - */ - const refusal = brokerRefusal( - error, - "Composio would not end this account, and gave no reason. Press Disconnect again: the revoke at Composio runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's Composio key if it persists.", - ); - return context.json({ error: refusal.error }, refusal.status); - } - }); - - /** - * Where the vendor sends somebody back. - * - * Deliberately not behind `requireUser`. The person arrives on a redirect from another company's - * server, and whose connection this is comes from the sealed state rather than from whatever - * session the browser happens to be carrying — which is what stops a callback delivered to the - * wrong browser from attaching one person's Google account to another person's row. - * - * Having no session is what makes the access check below necessary. Every other route asks the - * question by being behind a guard; this one has to ask it out loud. - * - * Every failure ends the same way: back at Settings with a word about what happened, and nothing - * written. There is no useful distinction here for the person between a forged state and an expired - * one, and spelling out which is which tells anybody probing this endpoint how far they got. - */ - routes.get("/oauth/callback", async (context) => { - const failed = connectedAccountsUrlFor(connect?.appUrl, { - failed: true, - }); - if (!connect?.publicUrl) return context.redirect(failed); - - /* - * EVERY WAY OUT OF THIS HANDLER IS A REDIRECT, AND THE `try` IS WHAT MAKES THAT TRUE. - * - * CRITERION. No request to this endpoint ends without a `Location`. - * - * REASON. The header above says every failure ends the same way, and that was true of - * every failure this handler ASKED for and of none of the failures its questions could - * raise. `personHasAccess` reaches the people store and `oauthClientFor` reaches the - * vault, and either of them throwing put somebody who had just consented at another - * company on a bodyless 500 with no `Location` at all — no page, no notice, nothing to - * press, and a browser left on this API's origin, which locally serves no pages at all. - * It is the exact answer this route exists to make impossible: `redeemAuthorizationCode` - * guards its own `fetch` against it in as many words, and the vault write below was given - * a `catch` for it — one call at a time, which is the shape that keeps leaving one out. - * - * SO IT IS THE WHOLE BODY RATHER THAN EACH CALL. A blanket `catch` is a promise about the - * route; a per-call one is a promise about the calls somebody remembered. Nothing is - * written before the vault, so a failure anywhere above it leaves the same nothing behind - * as an unreadable state does, and the person is told the same sentence either way. - * - * THE PERSON LEARNS NOTHING NEW FROM IT, deliberately, for the reason the header gives: - * there is no useful distinction here between a forged state and a store that would not - * answer, and spelling out which is which tells anybody probing this endpoint how far they - * got. The console is where the difference survives, and it carries the error stringified - * and nothing else — not the state, which names a person, and not the code, which is one - * redemption away from a refresh token. - */ - try { - const code = context.req.query("code"); - const state = await readConnectState( - context.req.query("state") ?? "", - connect.encryptionKey, - ); - if (!code || !state) return context.redirect(failed); - - /* - * Is the person in the state still somebody here? - * - * Asked here, before the code is redeemed and before anything is written, because a state is - * good for ten minutes and access can end inside them. Removing somebody deny-lists their - * address, deletes their sessions and retires the credentials they had already granted — and - * none of that reaches a consent already in flight at the vendor. Without this, that consent - * comes back and writes a fresh, live refresh token belonging to somebody who no longer has - * access, which nothing downstream will ever revoke because nothing knows it was created. - * - * The same anonymous failure as an unreadable state. Whether an address is deny-listed is not a - * fact this endpoint owes an unauthenticated caller. - */ - if (!(await connect.personHasAccess(state.userId))) { - return context.redirect(failed); - } - - const entry = catalogueEntry(state.serverId); - if (entry?.auth.kind !== "user-oauth") return context.redirect(failed); - - const client = await store.oauthClientFor(state.serverId); - if (!client) return context.redirect(failed); - - const grant = await redeemAuthorizationCode({ - tokenUrl: entry.auth.tokenUrl, - clientId: client.clientId, - clientSecret: client.clientSecret, - code, - redirectUri: redirectUriFor(connect.publicUrl), - verifier: state.verifier, - }); - if (!grant) return context.redirect(failed); - - /* - * The last thing that can fail, answered the same way as everything before it. - * - * A vault that will not take the grant is this deployment's problem, not the person's, and they - * have already done their part at the vendor. Unhandled, this threw past the handler and gave - * them the bare 500 that every other failure on this route was written to avoid, on the one - * path where they had most reason to think it had worked. - * - * Told, because unlike the refusals above this one is nobody's fault but ours, and the person's - * sentence deliberately says nothing about which failure it was. The refresh token is not - * logged: it is the one thing here worth stealing, and the row it belonged to was never written. - */ - try { - await store.recordConnection({ - serverId: state.serverId, - userId: state.userId, - refreshToken: grant.refreshToken, - scope: grant.scope, - }); - } catch (error) { - console.error( - JSON.stringify({ - type: "oauth-connection-not-recorded", - serverId: state.serverId, - note: "A person consented and the grant could not be stored. They were sent back to Settings with a failure and will have to connect again.", - /* - * READ THROUGH {@link reasonWithoutStatement}, BECAUSE THE FAILURE THIS LINE EXISTS FOR - * IS THE ONE WHOSE MESSAGE CARRIES THE REFRESH TOKEN. - * - * `recordConnection` runs `swapUserCredential` → `credentials.create/rotate`, whose - * bound parameters include `encryptedValue: await encryptSecret(key, refreshToken)`. - * drizzle wraps a driver failure as a `DrizzleQueryError` whose own message is - * `Failed query: insert into "credentials" … params: …, , …`, so - * `String(error)` here put the envelope on this deployment's stdout and into whatever - * sink collects it — for a person who was correctly told nothing was saved. Two consent - * callbacks for one `(serverId, userId)` landing together, or a statement timeout on - * the insert, is all it takes. - * - * The helper answers the driver's own complaint instead — `duplicate key value violates - * unique constraint`, `canceling statement due to statement timeout` — which is the - * useful half and names nothing anybody sent. - */ - error: reasonWithoutStatement(error), - }), - ); - return context.redirect(failed); - } - - return context.redirect( - connectedAccountsUrlFor( - connect.appUrl, - { serverId: state.serverId }, - // From the sealed state, so the destination is one this deployment chose, not the browser. - state.returnTo, - ), - ); - } catch (error) { - console.error( - JSON.stringify({ - type: "oauth-callback-failed", - note: "A person came back from a consent screen and a check on this route raised instead of answering. They were sent back to Settings with a failure, nothing was written, and they will have to connect again.", - // The outer catch of the branch above, so it stands over the same write and needs the - // same door. See the note on `oauth-connection-not-recorded`. - error: reasonWithoutStatement(error), - }), - ); - return context.redirect(failed); - } - }); - - /** - * Write a skill. - * - * Not admin-only. A skill is an instruction, not a capability: it can only ask a - * Bot to use tools that Bot was already granted, and every one of those calls is still decided, - * policy-checked and audited. Adding an MCP server is the opposite, and stays an administrator's. - * - * A person's skill is their own. `global` writes one for the whole deployment, which an - * administrator may do and nobody else. - */ - routes.post("/skills", requireUser, async (context) => { - const body = (await context.req.json().catch(() => null)) as { - slug?: unknown; - title?: unknown; - summary?: unknown; - instructions?: unknown; - global?: boolean; - tools?: unknown; - } | null; - /* - * The body is JSON, so the annotations are wishes: `{"slug":123}` passes a truthiness - * check and `RegExp.test` then coerces it to `"123"`, and `{"summary":{}}` reaches the - * store where the insert throws a 500. A slug, a title and instructions are non-empty - * strings here, and a summary is absent or a string. Anything else is a 400 before - * any refusal check, store write, or audit row. - */ - if ( - typeof body?.slug !== "string" || - typeof body?.title !== "string" || - !body.title.trim() || - typeof body?.instructions !== "string" || - !body.instructions.trim() - ) { - return context.json( - { error: "A slug, a title and instructions are required." }, - 400, - ); - } - if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(body.slug)) { - return context.json( - { error: "A slug is lower-case letters, numbers and hyphens." }, - 400, - ); - } - if (body.summary !== undefined && typeof body.summary !== "string") { - return context.json( - { error: "A summary is text when it is present." }, - 400, - ); - } - - const actor = skillActor(context); - if (body.global && !actor.isAdmin) { - return context.json( - { error: "Only an administrator writes a skill for the deployment." }, - 403, - ); - } - - // Editing an existing slug, which is what a repeated save is, needs the right to edit that - // skill. Without this, saving over somebody else's name would silently take it. - const refusal = await skillRefusal(context, body.slug); - if (refusal) return context.json({ error: refusal }, 403); - - /* - * Absent leaves the declarations alone, so a caller that predates this field does not silently - * clear one. An array, including an empty one, says what the skill needs now. - * - * Every entry must be a non-empty string: silently dropping mistyped entries would turn a - * client bug into a skill that declares nothing and answers success. `global` must be a - * boolean when present, so the string `"yes"` cannot create a deployment-wide skill. - */ - if (body.global !== undefined && typeof body.global !== "boolean") { - return context.json( - { error: "Global must be true or false when it is present." }, - 400, - ); - } - if (body.tools !== undefined) { - if ( - !Array.isArray(body.tools) || - body.tools.some((ref) => typeof ref !== "string" || !ref.trim()) - ) { - return context.json( - { error: "Tools are a list of serverId/toolName references." }, - 400, - ); - } - } - const tools = Array.isArray(body.tools) - ? (body.tools as string[]).map((ref) => ref.trim()) - : undefined; - - try { - await store.installSkill({ - slug: body.slug, - title: body.title.trim(), - summary: body.summary ?? "", - instructions: body.instructions.trim(), - ownerUserId: body.global ? null : actor.id, - ...(tools === undefined ? {} : { tools }), - by: actorEmail(context), - }); - } catch (error) { - // A ref naming no tool this deployment has seen. Answered rather than thrown, because it is - // something the person writing the skill can fix and the message says what to fix. - if (error instanceof PluginRefusedError) { - return context.json({ error: error.message }, 400); - } - throw error; - } - return context.json({ skills: await store.listSkills(actor) }); - }); - - routes.delete("/skills/:slug", requireUser, async (context) => { - const slug = context.req.param("slug"); - const refusal = await skillRefusal(context, slug); - if (refusal) return context.json({ error: refusal }, 403); - - await store.uninstallSkill(slug, actorEmail(context)); - return context.json({ ok: true }); - }); - - /** - * Grant and revoke, for both kinds, through one pair of endpoints. - * - * The store keeps one grant table because the question is the same either way; the API says the - * same thing, so a reader is never left wondering whether skills are governed differently. - */ - - /** - * The kinds of grant this API will act on. - * - * CHECKED AT RUNTIME, not only in the types. `kind` arrives in a JSON body, so a type annotation - * on it is a comment: before this, anything at all could be written into the grant table through - * the ordinary endpoint, and one kind that was never meant to be settable this way already could. - */ - const GRANT_KINDS = new Set(["mcp", "skill", "bot"]); - const asGrantKind = (value: unknown): PluginKind | null => - typeof value === "string" && GRANT_KINDS.has(value as PluginKind) - ? (value as PluginKind) - : null; - - /** - * May this person put this on that Bot? - * - * MCP is an administrator's, always: it reaches another company's system with a stored credential. - * A skill is an instruction, so somebody may put their own skill on a Bot they own, and neither - * half alone is enough. Both are checked here rather than in the store, because this is the only - * place that knows who is asking. - */ - async function enablementRefusal( - context: { var: AppVariables }, - kind: PluginKind, - ref: string, - agentId: string, - /** - * Which way this is going, because they are not symmetric. - * - * TAKING SOMETHING AWAY IS ALWAYS ALLOWED. The checks below decide whether a grant should exist, - * and applying them to a revoke turns every one of them into a trap: a `bot` grant made before - * the grantee moved to its own endpoint — or before this check existed — could never be removed, - * because the reason it is wrong is the same reason the revoke was refused. An administrator - * looking at a dead row in the UI would have had no way to delete it. - */ - intent: "grant" | "revoke", - ): Promise { - const actor = skillActor(context); - - if (kind === "mcp") { - if (!actor.isAdmin) { - return "An administrator decides which Bots may reach a tool."; - } - // Taking something away is always allowed: see the note on `intent`. It matters more here - // than anywhere else, because the rows this check exists to prevent are the same shape as the - // rows #572's migration had to delete, and an administrator has to be able to remove one by - // hand rather than wait for a migration. - if (intent === "revoke") return null; - - /* - * A grant that could never do anything is refused rather than stored, which is the rule the - * `bot` branch below already states and this one did not follow. - * - * Nothing checked that the app existed. `store.grant` is a bare upsert, so a ref naming an - * app this deployment has not added was stored and then invisible: the surface that reports a - * grant nothing advertises is built per server row, and there was no row. #572 fixed the way - * these rows were MADE — a removal used to leave its grants behind — and added the migration - * that deleted the ones already there. This is the other door into the same room. Add the app - * afterwards and the id is the same, the action names are the same, and every one of those - * grants resolves, with nobody having granted anything and no row in the trail saying so. - * - * THE SERVER HALF ONLY. A grant naming a tool the server has stopped advertising is a - * supported state, held and not offered, because what a vendor lists today is not what - * somebody decided yesterday — checking the tool here would refuse a re-grant of exactly the - * tool an administrator is trying to restore. A grant naming no app at all is not that state. - */ - const [serverId] = ref.split("/"); - if (!(await store.serverExists(serverId ?? ""))) { - return `${serverId} is not an app this deployment has added, so there is nothing for a Bot to reach. Add it first, and its tools can be granted then.`; - } - /* - * The Bot has to exist as well, which the `bot` branch below already says in the same words. - * `plugin_grants.agent_id` is a foreign key, so a grant naming a Bot nobody has reached the - * insert and failed there, and a person's mistyped Bot id answered 500 with no body where - * every other refusal on this route is a 403 with a sentence. - */ - if (!(await store.agentIsRegistered(agentId))) - return "There is no such Bot."; - return null; - } - - if (kind === "bot") { - /* - * THE ROLE IS CHECKED BEFORE ANYTHING IS LOOKED UP, and that ordering is the point. - * - * One Bot reaching another lets it spend that Bot's model calls, wake its computer and reach - * whatever it may reach, so it is an administrator's decision rather than something somebody - * attaches to a coworker they own. But this route only requires a signed-in user, so every - * refusal below is readable by anybody: checking whether the Bot exists, and whether it runs - * here, before this line handed out three distinguishable answers and turned a 403 into an - * oracle for other people's private Bots. `handoff.ts` in this same feature collapses exactly - * this, deliberately, and this had it backwards. - */ - if (!actor.isAdmin) { - return "An administrator decides which Bots may hand work to another Bot."; - } - // Taking something away is always allowed: see the note on `intent`. - if (intent === "revoke") return null; - - /* - * A grant that could never do anything is refused rather than stored, from both ends. - * - * The GRANTEE must exist. Remote Bots execute through the same signed callback and handoff - * desk as built-in Bots, so where their model loop runs does not change the grant. - * - * The TARGET only has to exist. Being handed work is not the same as being able to hand it on, - * so a target at its own endpoint is perfectly ordinary — but `ref` is bare text with no - * foreign key, so a typo stored happily and every hop then refused as not-granted. - */ - /* - * A Bot cannot be granted itself. The desk refuses a self-hop outright — "a Bot cannot hand - * work to itself" — so the row is dead the moment it is written, and reads as configured. - */ - if (ref === agentId) { - return "A Bot cannot be granted itself to hand work to."; - } - if (!(await store.agentIsRegistered(agentId))) - return "There is no such Bot."; - if (!(await store.agentIsRegistered(ref))) { - return `There is no Bot called ${ref} to hand work to.`; - } - return null; - } - - if (actor.isAdmin) { - // Taking something away is always allowed: see the note on `intent`. - if (intent === "revoke") return null; - /* - * A grant naming no skill is refused for an administrator too, as the `mcp` branch refuses a - * grant naming no app. `store.grant` is a bare upsert and a Bot's skills are read by slug - * alone, so the row sat there granting nothing until somebody wrote a skill under that name, - * and then that skill was on this Bot with nobody having put it there. On a Bot the - * deployment shares, that is one person's instructions answering everybody: - * `skill-uninstall-grants.integration.test.ts` guards the same room from the uninstall side. - */ - if ((await store.skillOwner(ref)) === undefined) { - return `There is no skill called ${ref}.`; - } - // And the Bot, for the reason the `mcp` branch gives: an administrator's grant naming one - // nobody has failed on the foreign key. Everybody else is asked below, through `agentOwner`. - if (!(await store.agentIsRegistered(agentId))) - return "There is no such Bot."; - return null; - } - - const owner = await store.skillOwner(ref); - if (owner === undefined) return `There is no skill called ${ref}.`; - if (owner !== actor.id) { - return owner === null - ? `${ref} belongs to this deployment. An administrator decides which Bots use it.` - : `${ref} is somebody else's skill.`; - } - - const botOwner = await store.agentOwner(agentId); - if (botOwner === undefined) return "There is no such Bot."; - if (botOwner !== actor.id) { - // Including the shared Bots this deployment publishes, which have no owner at all: a skill - // one person wrote would otherwise change how a Bot answers everybody. - return "You can only put your own skills on Bots you own."; - } - return null; - } - - routes.post("/grants", requireUser, async (context) => { - const body = (await context.req.json().catch(() => null)) as { - kind?: unknown; - ref?: string; - agentId?: string; - approval?: unknown; - } | null; - const kind = asGrantKind(body?.kind); - /* - * The body is JSON, so the annotation is a wish: `{"ref":123,"agentId":[]}` passes a - * truthiness check and then reaches the store, where Drizzle compares a text column against - * a number and the request answers 500. A ref and a Bot id are non-empty strings here. - */ - if ( - !kind || - typeof body?.ref !== "string" || - !body.ref.trim() || - typeof body.agentId !== "string" || - !body.agentId.trim() - ) { - return context.json( - { error: "A kind, a ref and a Bot are required." }, - 400, - ); - } - /* - * Trimmed before it is checked and before it is stored, the way the DELETE twin below already - * does. Without this a padded ref refuses with the padding quoted back (`" app" is not an app`), - * and a grant that did pass would be stored under a ref no revoke could match. - */ - const grantRef = body.ref.trim(); - const grantAgentId = body.agentId.trim(); - const refusal = await enablementRefusal( - context, - kind, - grantRef, - grantAgentId, - "grant", - ); - if (refusal) return context.json({ error: refusal }, 403); - - /* - * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. Without an approval - * the gate refuses every call the grant just allowed, so a grant with none sent approves the - * Bot's exposure as it stands now — unless an approval is already recorded, which is an - * administrator's earlier decision and not this request's to widen. "Is Shared" is the app's - * answer (its answering row), never the granted row's own column. - */ - let approval: SharedUseApproval | null = null; - let appId = ""; - if (kind === "mcp" && shared) { - const [serverId] = grantRef.split("/"); - appId = await shared.use.appIdOf(serverId ?? ""); - if ((await store.serverAddress(appId))?.accountMode === "shared") { - if (body?.approval !== undefined) { - approval = asSharedUseApproval(body.approval); - if (!approval) { - return context.json( - { error: "That approval is not one this deployment can read." }, - 400, - ); - } - } else if (!(await shared.use.approvalFor(grantAgentId, appId))) { - approval = exposureOf(await shared.use.botFacts(grantAgentId)); - } - } - } - await store.grant(kind, grantRef, grantAgentId, actorEmail(context)); - if (approval && shared) { - await shared.use.setApproval({ - botId: grantAgentId, - serverId: appId, - approval, - by: context.var.actor.id, - }); - } - return context.json({ ok: true }); - }); - - routes.delete("/grants", requireUser, async (context) => { - const kind = asGrantKind(context.req.query("kind")); - const ref = context.req.query("ref"); - const agentId = context.req.query("agentId"); - /* - * Query params are always strings, so truthiness is not enough: `" "` is truthy and used - * to pass this check, delete zero rows by exact match, still write a `plugin_revoked` audit - * row naming whitespace, and answer `ok:true`. The POST twin already requires non-empty - * strings; this requires the same and acts on the trimmed values. - */ - if ( - !kind || - typeof ref !== "string" || - !ref.trim() || - typeof agentId !== "string" || - !agentId.trim() - ) { - return context.json( - { error: "A kind, a ref and a Bot are required." }, - 400, - ); - } - const trimmedRef = ref.trim(); - const trimmedAgentId = agentId.trim(); - const refusal = await enablementRefusal( - context, - kind, - trimmedRef, - trimmedAgentId, - "revoke", - ); - if (refusal) return context.json({ error: refusal }, 403); - - await store.revoke(kind, trimmedRef, trimmedAgentId, actorEmail(context)); - return context.json({ ok: true }); - }); - - /** What one Bot holds. The runtime reads this to decide what to offer a model. */ - routes.get("/for/:agentId", requireUser, async (context) => { - const agentId = context.req.param("agentId"); - // A grant list is a fact about the Bot it belongs to. Left open it says which tools somebody - // else's private coworker has been given. - if (!(await canUseBot(context.var.actor, agentId))) { - return context.json({ error: "There is no such Bot." }, 404); - } - return context.json(await store.listForAgent(agentId)); - }); - - /** - * Call a tool, as a Bot. - * - * The grant, the policy and the audit row all happen inside the store, so this endpoint cannot - * accidentally satisfy one of them and skip another. A refusal comes back as 403 with the reason - * the model and the person are both shown, which is the same sentence written to the trail. - * - * NOTHING IN THIS REPOSITORY CALLS IT. It is what the browser used to post to when a Bot's tool - * loop ran client-side; that loop moved to the server, and the client helper for this went with it. - * Kept rather than removed, because #37 hardened it with `canUseBot` after that move — so removing - * it belongs in a change that says so, not in a merge resolution. - */ - routes.post("/call", requireUser, async (context) => { - const body = (await context.req.json().catch(() => null)) as { - ref?: string; - args?: Record; - agentId?: string; - } | null; - // Same shape lie as `/grants` above: JSON numbers, objects and arrays are truthy, so they - // must be refused here rather than inside `canUseBot` or the tool call. - if ( - typeof body?.ref !== "string" || - !body.ref.trim() || - typeof body.agentId !== "string" || - !body.agentId.trim() - ) { - return context.json({ error: "A tool and a Bot are required." }, 400); - } - // `args` reaches `Object.entries` inside the store, where a string fans out into indexed - // entries, a number becomes no entries, and an array passes as an object — all escaping as - // a vendor 502 instead of a 400 for a malformed call. - if ( - body.args !== undefined && - (typeof body.args !== "object" || - body.args === null || - Array.isArray(body.args) || - Object.getPrototypeOf(body.args) !== Object.prototype) - ) { - return context.json({ error: "Tool arguments must be an object." }, 400); - } - - // Asked before the grant is looked up, and before anything reaches a vendor. The grant says this - // Bot may use the tool; it says nothing about whether this person may act as this Bot, and the - // call goes out on the deployment's own credential either way. - if (!(await canUseBot(context.var.actor, body.agentId))) { - return context.json({ error: "There is no such Bot." }, 404); - } - - try { - const result = await store.callTool({ - ref: body.ref, - args: body.args ?? {}, - botId: body.agentId, - /* - * The user id, not the address. - * - * `callTool` keys a per-person connection on `users.id`, so an address here finds nothing and - * every call through this route would be answered "you have not connected your account" — - * about a connector the person has connected. It never surfaced because a Bot's own tool loop - * runs on the server and does not come through here. - * - * The other uses of `actorEmail` in this file are `by:` on configuration changes, where an - * address is the useful thing to record. This one is an identity being resolved, not a name - * being written down, and the two are not interchangeable. - */ - actorId: context.var.actor.id, - // A signed-in person calling the tool themselves: say so, because a shared account refuses a - // call that does not say what started it. - initiator: PERSON_INITIATOR, - }); - return context.json(result); - } catch (error) { - if (error instanceof PluginRefusedError) { - return context.json({ error: error.message, rule: error.rule }, 403); - } - 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( - { - error: - error instanceof Error - ? error.message - : "The server did not answer.", - failed: true, - }, - 502, - ); - } - }); + canUseBot, + connect, + composio, + shared, + actorEmail, + skillActor, + skillRefusal, + }; + // In the order they were always registered, which is the order Hono matches them in. + mountServerRoutes(context); + mountComposioAppRoutes(context); + mountConnectionRoutes(context); + mountOAuthCallbackRoute(context); + mountSkillRoutes(context); + mountGrantRoutes(context); return routes; } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index ed479568d..556bedac7 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,7962 +1,54 @@ -import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { type ApprovalGate, currentApprovalContext } from "../approvals/types"; -import { - type AuditInitiator, - type AuditStore, - recordAuditEvent, -} from "../audit"; -import { - type ActionPolicy, - evaluateActionPolicy, - type PolicyContext, - policyInitiator, -} from "../computer/policy"; -import { - type CredentialExecutor, - type CredentialSecretReader, - type CredentialStore, - CredentialUnusableError, - decryptCredentialForUse, - decryptSecret, - encryptSecret, -} from "../credentials"; -import type { Database } from "../db/client"; -import { - databaseComplaint, - isQueryFailure, - reasonWithoutStatement, - withoutStatement, -} from "../db/query-failure"; -import { - agentProfiles, - agents, - brokeredConnections, - 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, - mcpServers, - mcpTools, - mcpUserCredentials, - pluginGrants, - skills, - skillTools, - users, -} from "../db/schema"; -import type { CheckPrivateShare } from "../proactive/private-share"; -import { - accessFor, - type ServerAccess, - ServerUnresolvableError, -} from "./access"; -import { - type BrokerConnection, - BrokerRefusalError, - BrokerUnconfiguredError, - type ComposioBroker, - type Decides, - isFieldScheme, - type RecordedScheme, - type SchemeKind, - schemeKind, -} from "./broker"; -import { - type CatalogueEntry, - catalogueEntry, - classifyTool, - customUrlRefusal, - resolveServerUrl, - serverCredentialKind, -} from "./catalogue"; -import { - asksForArguments, - askAction as composioAskAction, - toolkitOf, - VERSION_ARG, -} from "./composio"; -import { inspectToolArguments } from "./content-governance"; -import { type ListedTool, McpServerError } from "./mcp"; -import { registerDynamicClient } from "./oauth"; -import { shareTargetOf } from "./share-target"; -import { - type AccountAnswer, - type AccountMode, - type AccountRef, - accountFor, -} from "./shared-accounts"; -import type { SharedUseGate } from "./shared-use-store"; -import { transportFor } from "./transport"; - -/** - * Plugins: what this deployment has added, which Bots may use it, and the one path a call takes. - * - * The grant and the policy are two different questions and both are asked on every call. The grant - * answers "is this Bot allowed this tool at all", which an operator decides on the Plugins page. The - * policy answers "is this particular call permitted right now", which is written as a rule and can - * say things a grant cannot: not on this host, not this argument, not a write. Collapsing them would - * mean an operator who granted a Bot a server had also, invisibly, waived every rule about it. - */ - -/** - * What a grant is a grant OF. - * - * `bot` is one Bot's permission to hand work to another, and it lives here rather than in a table of - * its own on purpose: an administrator already understands "this Bot may use that", a fork's policy - * layer already applies to grants, and reachability between Bots is the same kind of decision as - * reachability to a vendor's tools. A second table would be a second thing to reason about and a - * second thing for a fork to reimplement. - */ -export type PluginKind = "mcp" | "skill" | "bot"; - -/** - * What an audit row about a grant is a row ABOUT. - * - * A mapping rather than a ternary, because a ternary quietly labelled everything that was not an MCP - * tool a skill. Adding a third kind made that wrong rather than merely terse: a grant letting one Bot - * address another would have been filed in the trail as a skill, which is the sort of small lie an - * investigation trips over months later. - */ -function grantTargetType(kind: PluginKind): string { - if (kind === "mcp") return "mcp_tool"; - if (kind === "bot") return "agent"; - return "skill"; -} - -export type ToolRecord = { - serverId: string; - name: string; - description: string; - inputSchema: Record; - /** `/`. 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[]; -}; - -/** - * A grant naming a tool this server does not currently advertise. - * - * Held and not offered. `listForAgent` reads the grant against the tool list, so nothing reaches a - * model — but that is a property of what the vendor is advertising today rather than of the grant, and - * it changes the moment the vendor advertises the name again. Google's Drive entry says so in its own - * comment: the REST transport is one line from being swapped back to MCP, and "tool names match - * Google's MCP server exactly, so grants survive the swap in either direction". - * - * So it is reported rather than pruned. A grant is the record of a decision somebody made, and the - * refresh that would have deleted it is not a safe place to decide from: the tool list is replaced by - * a `delete` and then an `insert`, and a vendor answering with an empty list is a success, so one bad - * answer would revoke every grant on that server and stamp the refresh as healthy. - */ -export type WithdrawnGrant = { - /** `/`, exactly as the grant is stored. */ - ref: string; - /** The tool half, for a screen that already has the server. */ - name: string; - grantedTo: string[]; -}; - -export type ServerRecord = { - id: string; - title: string; - logo: string | null; - vendor: string; - url: string; - summary: string; - docsUrl: string; - /** `first-party` or `custom`. Shown wherever the server is, never inferred by a reader. */ - provenance: string; - hasCredential: boolean; - toolsRefreshedAt: string | null; - lastError: string | null; - addedBy: string | null; - /** - * Whether the catalogue entry registers its own OAuth client (RFC 7591) rather than waiting on - * an administrator to paste one in. So the admin screen can hide the paste-a-client form where - * 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; - /** - * Whose account a brokered app acts in: each person's own, or the one the deployment holds. - * Null on a row that needs no account at all, and on every row that is not brokered. - */ - accountMode: "personal" | "shared" | null; - /** - * Whether a Shared app's deployment-held account is connected yet. - * - * ALWAYS FALSE WHILE {@link accountMode} IS NOT `"shared"`, even where a deployment row happens - * to stand at the app's url: a Personal app never acts in that account, so reporting it as - * connected would tell the page something that changes nothing a call does. - */ - sharedVendorConnected: boolean; - tools: ToolRecord[]; - /** - * Grants on tools this server no longer advertises. - * - * Empty for a healthy connector. Non-empty is the discrepancy an administrator should be reading - * about, which is why it is here rather than inferred by a screen comparing two lists. - */ - withdrawn: WithdrawnGrant[]; -}; - -/** - * A server row as the surfaces that only need to know where it is see it. - * - * Four columns of {@link ServerRecord} and none of what hangs off it, because the callers this is - * for ask one question: which vendor is this row addressed at. The title travels with the url - * because their refusals name it — "You already have an account connected to Linear" is the app's - * name, which is the only one of the two a person has ever seen on a screen. - */ -export type ServerAddress = { - id: string; - title: string; - url: string; - /** - * How this row's authorization config was created, for the brokered rows that have one. - * - * THE RECORDED SCHEME, NEVER A FRESH CATALOGUE READ. An app's config was created as one - * particular scheme and every connection standing against that config depends on it, so the - * connect path has to open the flow this column names rather than the one the catalogue - * publishes for the app today. Re-derived from a listing instead, a vendor that starts - * advertising a new scheme would silently move live connections onto a different flow — minting - * a consent link against a config that holds keys, or asking for a key where a consent screen is - * waiting. - * - * The same vocabulary the column holds, which the schema comment spells out: the vendor's own - * scheme literals, never a {@link BrokerConnection} kind. Null is not an older brokered row; it - * is a row that is not brokered. - */ - authScheme: string | null; - /** - * Whose account the app this row names is used under: each person's own, or one the deployment - * holds for everybody. - * - * THE APP'S, READ OFF THE ROW THAT ANSWERS FOR IT, exactly as {@link authScheme} is. Two rows may - * name one app and only one of them decides whose account it is, so a page that drew the Shared - * treatment off the row it opened could show one answer while every connect, grant and call - * behind its buttons acted on the other. Null is a row that is not brokered, and also a brokered - * row whose app has no answering row left to say. - */ - accountMode: AccountMode | null; -}; - -export type SkillRecord = { - id: string; - slug: string; - /** Whose it is. Null means the deployment's, written by an administrator or shipped. */ - ownerUserId: string | null; - title: string; - summary: string; - instructions: string; - origin: string; - installedBy: string | null; - grantedTo: string[]; - /** - * The tools this skill says it needs, as `/` refs. - * - * A declaration, not a grant: what a Bot may call is `grantedTo` on the tool side and nothing here. - * See the comment on `skillTools` in the schema for why that separation is load-bearing. - */ - tools: string[]; -}; - -/** - * Who is asking, for the surfaces where the answer depends on it. - * - * An administrator sees and governs the whole deployment. Everybody else sees the deployment's - * skills and their own, and may act only on their own. - */ -export type SkillActor = { id: string; isAdmin: boolean }; - -/** What one Bot holds. Everything the runtime needs to offer it, and nothing it does not. */ -export type GrantedPlugins = { - tools: { - ref: string; - /** The name the model is offered, which is the ref with the separator a tool name allows. */ - toolName: string; - description: string; - inputSchema: Record; - }[]; - skills: { - slug: string; - title: string; - summary: string; - instructions: string; - /** - * What this skill says it needs, as refs. Never a superset of `tools` above in effect: selection - * intersects the two, because a skill naming a tool the Bot lacks must load nothing rather than - * make it callable. - */ - tools: string[]; - }[]; -}; - -export type PluginDecision = - | { allowed: true } - | { allowed: false; reason: string }; - -/** - * What one check of somebody's brokered account spent, and what came of it. - * - * FOUR OUTCOMES AND NOT A PAIR OF NULLABLE FIELDS, because the fourth one is what the pair could - * not say and its absence was destructive. `verified: false` used to cover both "the app published - * nothing safe to try" and "it ran and the vendor refused the key", which is why the action's name - * travels beside the verdict at all — and a THIRD thing was quietly landing in the second of those: - * a vendor nobody could reach. `callTool` never throws, so a Composio outage, a socket that closed - * and a package that cannot parse an answer all arrived as `isError: true` and were read as the - * vendor rejecting the key. See {@link ActionAnswer} in `./composio` for the reading that settles - * it, and {@link composioConnections.probeAction} for what each of these may be written down as. - * - * `probe` IS THE ACTION A CHECK CAN BE SHOWN TO HAVE SPENT, and it is null on both outcomes where - * nothing can be shown — which is the whole structural point of this shape rather than a nicety. - * That field is what the writer records in `probe_action`, and a name beside `verified: false` is - * the accusation "it ran and the vendor refused your key". An unreachable vendor therefore has no - * name to give the writer, and cannot make that claim even by mistake; what was attempted is - * carried separately, for the trail and for the sentence somebody reads. - */ -export type BrokeredProbe = - /** The app published nothing safe to call, or no row for it is left. Nothing was tried. */ - | { outcome: "nothing"; probe: null } - /** It ran in this person's account and the vendor took the key. */ - | { outcome: "answered"; probe: string } - /** - * It ran in this person's account and the app answered with a failure. `sentence` is Composio's - * own account of it. - * - * AND THAT IS THE WHOLE OF WHAT IT PROVES, WHICH IS WHY IT IS NOT CALLED `refused`. It was, and - * every reader took the name at its word: the connect path DELETED the account it had made - * seconds earlier and told the person what they entered did not work, and the re-check path - * wrote the named-probe-beside-`verified: false` pair, which the settings page draws as "your - * key was checked and rejected". - * - * NOTHING IN THE ENVELOPE SUPPORTS EITHER CLAIM. What arrives is Composio's - * `{ data, error, successful }` — no HTTP status, no error code, nothing structured about the - * credential — so a rate limit, a scope the key legitimately lacks for this one action, and a - * 404 for a resource the read action happens to name are all indistinguishable here from a key - * the vendor rejected. A person pasting a VALID key into an app that is rate-limiting had their - * brand-new working connection revoked with a message blaming them, and that is not recoverable - * by pressing anything: the key has to be fetched and typed again. - * - * SO THE TWO DESTRUCTIVE READINGS ARE GONE AND THE NAME NO LONGER INVITES THEM. The account - * stands, the row records the check as failed with the action it was spent on, and the sentence - * the person is shown is the app's own words rather than a verdict about their credential. If - * Composio ever publishes a status or an error code on this envelope, telling the two apart is - * a fifth outcome and the roster below is what will make somebody decide it everywhere. - */ - | { outcome: "complained"; probe: string; sentence: string } - /** - * The vendor was not reached, or answered something nothing here can read, so NOTHING WAS - * LEARNED — about the key, and about whether the action ran at all. `attempted` is what would - * have been spent, which is a fact about this deployment's intent rather than about the account. - */ - | { - outcome: "unreachable"; - probe: null; - attempted: string; - sentence: string; - }; - -/** - * The four outcomes as a ROSTER, which is what anything enumerating them is checked against. - * - * A union is a thing a fifth member can be added to in one line with nothing anywhere failing: - * every reader of this vocabulary asks `probed.outcome === "complained"` or `=== "unreachable"`, and a - * chain of equality tests has no opinion about the answer it was not written for. `unreachable` is - * itself the proof — it was added in round two and the screen that draws these outcomes went on - * rendering it with the sentence written for a different case. See {@link Decides}. - * - * PINNED TO THE UNION IN BOTH DIRECTIONS. `satisfies` holds this list inside the union, so a name - * misspelled here fails; {@link _ProbeRosterNamesTheWholeUnion} holds the union inside this list, so - * a member added to the union and not to this roster fails. Neither direction is worth having on its - * own: the pair is what makes the roster a restatement that cannot drift. - * - * SO A FIFTH OUTCOME COSTS TWO STEPS AND CANNOT SKIP EITHER. Adding it to the union fails `tsc` - * here and at every consumer's `Decides<…>` roster; adding it here to satisfy that then fails the - * table in `tests/composio-connection-kinds.test.ts`, which reads its rows off this list and has no - * cell for the new name. The compiler settles what must decide and the table settles what it does. - */ -export const BROKERED_PROBE_OUTCOMES = [ - "nothing", - "answered", - "complained", - "unreachable", -] as const satisfies readonly BrokeredProbe["outcome"][]; - -/** The other direction of {@link BROKERED_PROBE_OUTCOMES}' pin. Type-only; erased entirely. */ -type _ProbeRosterNamesTheWholeUnion = Decides< - BrokeredProbe["outcome"], - Record<(typeof BROKERED_PROBE_OUTCOMES)[number], string> ->; - -/** - * THE FOUR STATES A CONNECTION ROW MAY BE LEFT IN, named so the pair can be enumerated. - * - * `verified` and `probe_action` are one fact recorded in two columns, and - * {@link composioConnections.probeAction} enumerates the four readings of that pair in prose. Prose - * is what the client then modelled as three — the outage and the never-checked case share the - * mildest pair, and the screen drew one of them with the other's sentence. This is the same four - * with names, so a consumer that has to decide on them can be held to naming all four. - * - * NOT A COLUMN, NEVER WRITTEN ANYWHERE, AND NOT EXPORTED. The row carries the pair; this is the - * reading of it, and its one job is to be the value type {@link _ProbeOutcomeLeaves} is checked - * against. - * - * `unchecked` — null probe, not verified. Nothing was tried, so nothing is known about the key. - * `consented` — null probe, verified. A consent connection; the vendor's own yes is the evidence. - * `checked` — a named probe, verified. It ran in this account and the vendor took the key. - * `failed` — a named probe, not verified. It ran and the app answered with a failure, and the - * account still stands. The one state an operator has to act on. - * - * `failed` RATHER THAN `refuted`, AND THE RENAME IS THE FIX RATHER THAN A TIDY-UP. It was - * `refuted` — "the vendor refused the key", described in this file as "the accusation" — and - * nothing that writes this pair can establish that. See {@link BrokeredProbe}'s `complained` - * member: the envelope the verdict is read out of carries no status and no error code, so the - * pair says a check was spent and did not come back clean, and it stops there. Everything that - * renders it says the same, in the app's own words. - */ -type RecordedCheck = "unchecked" | "consented" | "checked" | "failed"; - -/** - * WHICH OF THE FOUR A PROBE OUTCOME MAY EVER LEAVE BEHIND, the seam the two vocabularies meet at. - * - * Type-only and erased. It is a statement about {@link BrokeredProbe} rather than about any one - * writer, and it is here because `failed` is the state the whole shape of that type exists to - * withhold: an outcome carrying no probe name has no name to record, so it cannot reach that pair - * even by mistake. A fifth outcome has to say which of the four it may leave behind before anything - * compiles, which is precisely the question that went unasked when `unreachable` was added. - * - * THE ANSWERS ARE NARROWED TO {@link RecordedCheck} rather than left as free text, which is what - * makes this a contract and not a comment: a state invented here that the schema's four do not - * include fails, and so does one of the four renamed on one side of the seam only. - */ -type _ProbeOutcomeLeaves = Decides< - BrokeredProbe["outcome"], - { - nothing: "untouched"; - answered: "checked"; - complained: "failed"; - unreachable: "untouched"; - }, - RecordedCheck | "untouched" ->; - -export class PluginRefusedError extends Error { - constructor( - message: string, - readonly rule: string | null, - ) { - super(message); - this.name = "PluginRefusedError"; - } -} - -export class CatalogueEntryUnknownError extends Error { - constructor(key: string) { - super(`${key} is not a server this deployment will connect to.`); - this.name = "CatalogueEntryUnknownError"; - } -} - -/** A URL an administrator offered that this deployment will not point itself at. */ -export class CustomServerRefusedError extends Error { - constructor(message: string) { - super(message); - this.name = "CustomServerRefusedError"; - } -} - -/** - * A state this deployment's own code says cannot exist, found existing. - * - * CRITERION. Nothing here is a vendor's doing, a credential's doing or anything a person asking can - * act on, so no path may record one of these as though a vendor had misbehaved. - * - * REASON. `refreshTools` wrapped the listing, the replace and both audit writes in one `catch` that - * copied every message into `lastError` and answered `{ tools: 0 }`. A plain `Error` is what the - * narrowing throws in {@link createPluginStore}'s `connectionTokenFor` raise, so a row that resolved - * to a brokered credential with no app in its url — or to a per-person credential with no - * `user-oauth` entry — came out on the Plugins page as a sentence about the vendor, next to a - * refresh that looked like it had merely failed. An operator reading that is sent to somebody else's - * status page over a contradiction in our own tables. - * - * A class rather than a message, because telling these apart by prose is telling them apart by a - * substring that a reword would silently change. Distinct from {@link PluginRefusedError}, which is - * a refusal somebody CAN act on and which does belong in `lastError` — an administrator who has not - * connected their account is the honest reason a listing did not happen. - */ -export class PluginInvariantError extends Error { - constructor(message: string) { - super(message); - this.name = "PluginInvariantError"; - } -} - -/** - * Whether a throw is this deployment contradicting itself, rather than anything anybody asked for. - * - * CRITERION. Every audience boundary asks THIS instead of listing classes of its own. A fault it - * answers true for reaches an operator as its own sentence, on a surface only an operator can - * reach, and reaches everybody else as the fact that the call did not happen — no message, no - * column names, no instruction about a row. - * - * REASON. The distinction already existed and was drawn by hand, once, in each place that - * remembered to draw it: {@link PluginRefusedError} is relayed verbatim because it is a refusal - * the asker can act on, and everything else fell into a branch that copies `error.message` - * onwards. {@link ServerUnresolvableError} was caught by none of them — the refresh route rethrew - * it into the framework's default handler, which answers a bodiless 500, so the admin page said - * "That did not work" and named nothing; `grantedTools` put its message in a model's context, - * where a sentence telling an operator to correct a provenance column became a Bot's explanation - * to an end user of why their tool failed. Two audiences, one refusal, neither served. - * - * {@link PluginInvariantError} is on the same shelf and answers true for the same reason: it is - * this deployment finding a state its own code says cannot exist. That is not a vendor - * misbehaving and not a person's to act on mid-call, and its own docblock has said so since it - * was written — what it lacked was anywhere that asked. - * - * A PREDICATE RATHER THAN A SHARED BASE CLASS, because the two live in different modules and must - * keep doing so: `access.ts` is a leaf that `store.ts` imports, so the shelf cannot be declared - * once without one of them importing the other back. - */ -/** - * The one character no PostgreSQL `text` or `jsonb` value can hold, whatever the vendor sent. - * - * Not a length limit and not an encoding preference: the server rejects the statement outright, - * mid-transaction, and the rejection arrives as a query error rather than as anything about the - * value. - */ -const NUL = "\u0000"; - -/** - * A schema this deployment cannot turn into a row, whatever the column would have said. - * - * Its own class so the one place that raises it and the one place that contains it are joined by - * something other than a substring: {@link storableSchema} is the only thrower, and the `try` - * around the call that turns a listing into rows is the only catcher. Nothing branches on the class - * — it is recorded the way anything else a vendor's answer could not be made into would be — but - * naming it keeps a later reader from mistaking it for a fault of this deployment's own. - */ -class SchemaUnstorableError extends Error { - constructor(message: string) { - super(message); - this.name = "SchemaUnstorableError"; - } -} - -/** - * The same JSON value with every U+0000 gone from it — out of the strings, and out of the keys - * above them. - * - * CRITERION ONE. What is removed is the CHARACTER. A string that merely SPELLS the escape — a - * backslash and then `u0000`, which is how a JSON Schema excludes control characters and is by far - * the commonest place those six letters legitimately appear — is handed back untouched. - * - * CRITERION TWO. Everything else is rebuilt identical: the same keys, the same nesting, the same - * scalars. - * - * WHAT THIS REPLACED, and why walking the DATA is not a stylistic preference. The strip used to run - * over the SERIALISED schema — `JSON.stringify(schema).replaceAll("\\u0000", "")` — which is - * escape-blind by construction. `JSON.stringify` writes a real backslash inside a string value as - * two of them, so a pattern such as `"[^\u0000-\u001f]"` reached the strip with its backslash - * doubled and had the TAIL of it eaten, leaving `\-`. That is not a JSON escape, so the - * `JSON.parse` wrapped around it threw a `SyntaxError` — from a line that sat outside BOTH `try` - * blocks in `refreshTools`. It left as a bodiless 500 with `lastError` still holding whatever it - * held before, and on the add path, which refreshes before it answers, it aborted the add AFTER the - * server row and its audit row had committed. - * - * Serialised text cannot tell the byte from the six letters that name it; parsed data can only ever - * hold one of them. So walking the data is what makes CRITERION ONE expressible at all. - * - * A SELF-REFERENTIAL VALUE IS REFUSED rather than quietly truncated, and `ancestors` holds only the - * chain currently being descended — so one object reached twice side by side is copied twice, which - * is what a plain JSON document does anyway. Nothing off a wire can be cyclic; a value built inside - * this process can, and it is a value `jsonb` would refuse in any case. Refused HERE it is a - * sentence the caller can record; left to the driver it is a statement dump. - */ -function withoutNul(value: unknown, ancestors: Set): unknown { - if (typeof value === "string") return value.replaceAll(NUL, ""); - if (typeof value !== "object" || value === null) return value; - if (ancestors.has(value)) { - throw new SchemaUnstorableError( - "The schema refers back to itself, so it is not a value JSON can hold.", - ); - } - - ancestors.add(value); - const stripped: unknown = Array.isArray(value) - ? value.map((item) => withoutNul(item, ancestors)) - : Object.fromEntries( - Object.entries(value).map(([key, nested]) => [ - key.replaceAll(NUL, ""), - withoutNul(nested, ancestors), - ]), - ); - ancestors.delete(value); - return stripped; -} - -/** - * {@link withoutNul} over a schema, answering in the type the column and the row builder use. - * - * The walk rebuilds an object as an object and a list as a list, so what comes back is the shape - * that went in — which is the same promise the `JSON.parse(JSON.stringify(...))` round trip this - * replaced made about everything except the escape it could not see. The narrowing says out loud - * what the parameter type already claims. - */ -function storableSchema( - schema: Record, -): Record { - return withoutNul(schema, new Set()) as Record; -} - -/** - * 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 — - * and nothing else about what the vendor wrote is altered to achieve it, which is a criterion of - * its own because the first attempt at this one failed it. See {@link storableSchema}. - * - * CRITERION THREE. Whatever this raises, its caller catches — see the `try` around the one call - * site. Everything here runs OUTSIDE the vendor `try` and before the transaction's own, so a throw - * from here left `refreshTools` unhandled: a bodiless 500, a `lastError` still holding whatever it - * held before, and an add aborted after its server row and its audit row had already committed. - * - * REASON. Both of these used to abort the replace from INSIDE the transaction and OUTSIDE the - * vendor `try` above it, so they came out of `refreshTools` as a raw `DrizzleQueryError` — whose - * message is `Failed query: ` followed by `params:` and every value bound to - * it. That reached an operator's page and the logs as a SQL dump, which is the same disclosure - * shape as a leaked credential one layer out, and it left `lastError` holding whatever was there - * before: stale, or null, on a refresh that had in fact failed. - * - * FIXED BY NOT REACHING THE DATABASE WITH IT, rather than by catching it better. A vendor that - * names one action twice is answering about one action — `mcp_tools`' `(server_id, name)` primary - * key says so, and the first listing is as good an answer as the second, so the duplicate is - * dropped rather than made into an error somebody has to act on. A control character in a - * description is not content anybody wants to keep either. What is left after this is a - * transaction that fails for reasons that are genuinely not the vendor's, which is what the - * comment on the replace has always claimed. - * - * FIRST OCCURRENCE WINS, and the order is the vendor's own. Anything else needs a rule for which - * of two identical names is the real one, and there is no such rule. - */ -function storableTools(serverId: string, listed: ListedTool[]) { - const byName = new Map< - string, - { - serverId: string; - name: string; - description: string; - inputSchema: Record; - effect: "read" | "write" | null; - destructive: boolean; - version: string | null; - } - >(); - - for (const tool of listed) { - const name = tool.name.replaceAll(NUL, ""); - if (byName.has(name)) continue; - byName.set(name, { - serverId, - name, - /* - * Defaulted where the COLUMN has a default, because that is what the previous mapping leaned - * on: it passed these two straight through, so a transport handing back undefined got the - * `""` and `{}` the schema declares. Reading a method off the value instead would turn the - * same absence into a TypeError thrown from outside the vendor `try`. Both fields are - * required by `McpTool` and supplied by every transport here; this keeps the tolerance the - * insert already had rather than adding a new answer. - */ - description: (tool.description ?? "").replaceAll(NUL, ""), - /* - * By walking the parsed schema rather than its serialised text, because only one of those two - * can tell the character from the six letters that name it. See {@link storableSchema}: this - * used to strip the escape out of `JSON.stringify`'s output, which ate the tail of every - * legitimately-escaped backslash and left a string `JSON.parse` refused. - */ - inputSchema: storableSchema(tool.inputSchema ?? {}), - /* - * What the vendor said, when the vendor said anything. - * - * Only Composio publishes an effect and a version, and an MCP server publishes a - * destructive hint — see `mcp.ts`. All three stay null or false for a transport that says - * nothing, and `classifyTool` reads null as silence rather than as a value, which is what - * leaves Notion and Drive classified by their reviewed write list exactly as they were. - */ - effect: tool.effect ?? null, - destructive: tool.destructive ?? false, - version: tool.version?.replaceAll(NUL, "") ?? null, - }); - } - - return [...byName.values()]; -} - -export function isDeploymentFault(error: unknown): error is Error { - return ( - error instanceof ServerUnresolvableError || - error instanceof PluginInvariantError || - /* - * A query this database refused is on the shelf for the reason the other two are: it is not a - * vendor's doing, it is not the asker's to act on, and its message is the one thing here that - * must not travel. The replace in `refreshTools` was fixed at its own site; every other query - * on the call path — the advertised-tool read, the connection gate, the vault read, the locked - * credential swap — throws the same shape into a `catch` that copies `error.message` onward, - * so answering it here is what makes the four audiences agree without four more branches. - * - * Callers that SHOW the sentence to an operator must still ask {@link withoutStatement} for - * it rather than reading `.message`; this predicate settles who may be told, not what. - */ - isQueryFailure(error) - ); -} - -/** The operator-facing sentence for a fault on that shelf, with no statement in it. */ -export function deploymentFaultSentence(error: Error): string { - return withoutStatement(error); -} - -/** - * The vendor's `error` code, when a token endpoint refuses an exchange. - * - * {@link INVALID_CLIENT} is the one code this module ACTS on rather than reports, so it has to - * survive as a value. It used to travel inside the sentence, which meant the recovery in - * {@link createPluginStore}'s `refuseAndReplaceEvictedClient` hung on a substring of prose written for a - * person to read: rewording the sentence — translating it, dropping the parenthesis — would have - * turned self-registration off with every test still green. A field cannot be reworded by accident. - */ -export class TokenRefusedError extends McpServerError { - constructor( - message: string, - readonly code: string | null, - ) { - super(message); - this.name = "TokenRefusedError"; - } -} - -/** - * The vendor saying the CLIENT is the problem, rather than the grant. RFC 6749 §5.2. - * - * Told apart from every other refusal because it is the only one a deployment can do anything about - * on its own: a client it issued to itself, it can issue again. - */ -export const INVALID_CLIENT = "invalid_client"; - -/** - * A transaction, as the writes in this module hand one to each other. - * - * Named because two writes here are one decision — a secret in the vault, and the pointer that says - * what it is for — and the only way to say that is to run both on the same executor. `select`, - * `insert` and `update` alone would do for {@link CredentialExecutor}; this needs `execute` too, for - * the advisory lock that serialises the client path. - */ -type Transaction = Parameters[0]>[0]; - -/** - * A tool name the model can actually call. - * - * `/` is how a grant is stored, because a slash reads correctly to a person and cannot - * appear in either half. Model tool names may not contain one, so the offered name uses `__`. - * Converting in one place, both ways, keeps the two spellings from drifting. - */ -export const toolNameFor = (ref: string) => `mcp__${ref.replace("/", "__")}`; - -export function refFromToolName(toolName: string): string | null { - if (!toolName.startsWith("mcp__")) return null; - const rest = toolName.slice("mcp__".length); - const separator = rest.indexOf("__"); - if (separator <= 0) return null; - return `${rest.slice(0, separator)}/${rest.slice(separator + 2)}`; -} - -/** - * Advertised tool names this deployment's write list does not name, where that list is the whole - * barrier. - * - * WHY THIS IS WORTH A ROW. {@link classifyTool} reads an advertised name absent from `writeTools` as - * a READ. So under-inclusion is the failure mode of that list, and it is silent: a write the vendor - * offers and the entry forgot is offered to a model as a read, and nothing anywhere says so. Notion's - * entry says reconciling its list against the live tool list "is required, not cosmetic" — this is - * the mechanical half of that, so the reconciliation is somebody reading a trail rather than somebody - * remembering. - * - * Only where the list stands alone. A vendor that expresses SCOPES has something behind the list: a - * tool missing from Drive's `writeTools` still cannot write, because `drive.readonly` refuses it at - * 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 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. - */ -export function unlistedAdvertisedTools( - entry: CatalogueEntry | null, - advertised: readonly string[], -): string[] { - if (!entry || entry.writeTools.length === 0) return []; - if (entry.auth.kind !== "user-oauth" || entry.auth.scopes.length > 0) { - return []; - } - const writes = new Set(entry.writeTools); - return advertised.filter((name) => !writes.has(name)).sort(); -} - -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. - * - * NOT THE SAME AXIS AS `initiator_kind`, and a row carrying both is not contradicting itself. - * `initiator_kind` answers what set a run in motion; this field answers whose account it reached - * and who can be named for it. So `initiator_kind: "person"` beside `actor: "unattributed"` reads - * correctly as a person-initiated request whose person this deployment could not identify. That is - * the honest reading, and it is the reason this is NOT recorded as `deployment`: that would assert - * the call went out on the deployment's own credential, and it did not go out at all. - * - * `DEPLOYMENT_INITIATOR`'s own doc claims the case of "refusing a caller it could not identify", - * which overlaps this one and would answer it the other way. Nothing sends it there — the tool path - * defaults its initiator to person and `identifyActor` returns an empty id rather than a deployment - * — so the overlap is in the prose, not in the behaviour. It is left alone deliberately rather than - * resolved by widening either vocabulary unilaterally; whoever owns that constant should narrow its - * sentence, or a third initiator kind should exist, and neither is this branch's call to make. - */ -const DEPLOYMENT_ACTOR = "deployment"; -const UNATTRIBUTED_ACTOR = "unattributed"; - -/** - * Whose account this call went out as, for the trail. - * - * 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. - * - * 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 || UNATTRIBUTED_ACTOR - : DEPLOYMENT_ACTOR; - -/** - * Where this server actually is, when the stored row and the catalogue disagree. - * - * `mcp_servers.url` is written once, when a server is added, by copying what the catalogue said at - * the time. That makes it a cache of a reviewed decision — and a cache nothing invalidates. Moving - * Google Drive from its preview MCP host to its GA REST host changed the catalogue and left every - * deployment that had already added Drive calling the old address, with no way to tell from any - * screen: the row looks exactly as intentional as it did the day it was written. - * - * So for an entry with a PINNED host, the catalogue wins. It is the reviewed source contract, and a - * host it no longer names is a host this deployment has decided not to talk to. Editing the - * catalogue is the act of changing where a first-party server is, and it should take effect. - * - * The stored value still wins for the two cases where it is the only truth: a custom server an - * administrator added by URL, which has no entry at all, and a per-instance vendor whose `host` is - * null because the customer's own hostname is the answer. - */ -function effectiveUrl( - row: { id: string; url: string }, - entry: CatalogueEntry | null, -): string { - if (!entry || entry.host === null) return row.url; - return resolveServerUrl(row.id)?.url ?? row.url; -} - -/** - * Trade a refresh token for a short-lived access token, at the vendor's own token endpoint. - * - * `tokenUrl` comes from the catalogue entry and never from a caller, for the same reason the MCP - * host does not: this request carries the deployment's client secret and somebody's refresh token, - * so where it goes is a reviewed decision rather than a runtime one. - * - * The vendor's error body is deliberately not passed through — it is written for whoever registered - * the client, not for the person who asked a Bot a question, and it can name the client id. Its - * `error` CODE is, though, and only that: `invalid_client` is what tells a client the vendor has - * forgotten apart from a grant somebody withdrew, and those two have entirely different answers. - * It goes out as a field on {@link TokenRefusedError} as well as in the sentence, because the field - * is the copy the recovery reads. - * - * Exported for its own tests rather than for a caller. Every path through the store reaches it as - * the default `exchangeRefreshToken`, and the store's own suites inject a stub in its place — which - * leaves what this function does with a REAL vendor reply, honest or garbled, untested unless it can - * be called directly. - */ -export async function exchangeRefreshTokenOverHttp(input: { - tokenUrl: string; - client: OAuthClient; - refreshToken: string; -}): Promise { - const params = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: input.refreshToken, - client_id: input.client.clientId, - }); - // A public (DCR) client proves itself without one, and some vendors refuse an unexpected empty - // field outright. The same guard the authorization-code redemption in `oauth.ts` uses. - if (input.client.clientSecret) { - params.set("client_secret", input.client.clientSecret); - } - - const response = await fetch(input.tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: params, - /* - * A redirect is a refusal, not a detour to be followed. - * - * `tokenUrl` is pinned in the catalogue because this request carries the deployment's client - * secret and somebody's refresh token, and following a 302 would hand both to whatever address - * the answer named. Manual leaves the 3xx as the response, which is not `ok`, so it falls into - * the refusal below. The same guard the authorization-code redemption in `oauth.ts` uses. - */ - redirect: "manual", - signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), - }); - - if (!response.ok) { - /* - * The code, when the refusal is JSON and carries one. Read defensively: a token endpoint that - * is refusing may be refusing with an HTML error page, and a parse failure here would replace - * the vendor's status — the one fact we do have — with a syntax error. - */ - const refusal = (await response.json().catch(() => null)) as { - error?: unknown; - } | null; - /* - * Capped where it is read, because everything downstream of here shows it to somebody: the - * person who asked, the model, the connector's `lastError` on the admin page, and an audit - * payload. It is a short token in the protocol and vendor-controlled in fact, and nothing on - * those paths is a promise about length. - */ - const code = - typeof refusal?.error === "string" ? refusal.error.slice(0, 64) : null; - throw new TokenRefusedError( - `The vendor would not renew this access (${response.status}).${code ? ` (${code})` : ""}`, - code, - ); - } - - /* - * A 200 is not a promise of JSON, and this branch used to read it as one. - * - * The refusal above already parses defensively; the success branch did not, so a CDN interstitial - * or a maintenance page answering 200 with HTML threw a SyntaxError from here — out through - * `callTool`, which records the failure with the thrower's message, and the parser's message - * quotes the body it choked on. So a vendor's HTML reached an audit payload and the person who - * asked, as a crash rather than as the refusal every other unusable reply produces. - */ - const body = (await response.json().catch(() => null)) as { - access_token?: unknown; - expires_in?: unknown; - refresh_token?: unknown; - } | null; - if (!body) { - throw new McpServerError( - "The vendor answered this renewal with something other than a token.", - ); - } - if (typeof body.access_token !== "string" || !body.access_token) { - throw new McpServerError("The vendor renewed this access with no token."); - } - return { - accessToken: body.access_token, - expiresInSeconds: - typeof body.expires_in === "number" ? body.expires_in : undefined, - /* - * Only when the vendor sent one, and only a non-empty one. - * - * A rotating vendor replies with a new refresh token and invalidates the one it was shown; a - * vendor that does not rotate sends none. Reading an absent or empty field as a rotation would - * repoint a working connection at nothing. - */ - refreshToken: - typeof body.refresh_token === "string" && body.refresh_token - ? body.refresh_token - : undefined, - }; -} - -/** How long a vendor's token endpoint gets. Shorter than a call: it is one round trip, or nothing. */ -const TOKEN_TIMEOUT_MS = 10_000; - -/** - * The deployment's OAuth client for one vendor, as it is held in the vault. - * - * Both halves live in the encrypted value rather than the id sitting in `metadata` and the secret - * here. One read gets a usable client, which keeps {@link CredentialSecretReader} the only vault - * interface this module needs. The id is also copied into `metadata` for the credentials page to - * show — a deliberate duplication of something that is not a secret, so that a screen listing what - * the deployment holds does not have to decrypt anything to name it. - */ -export type OAuthClient = { clientId: string; clientSecret: string }; - -/** - * Whether a value read back out of the vault is a client this deployment can actually present. - * - * Guarding the parse is only half of the question. `JSON.parse` answers for SYNTAX, and the - * `as OAuthClient` cast behind it answers for nothing at all — so a row holding - * `{"client_id":"","client_secret":""}`, which is what a hand-repair or a half-written row leaves, - * parses cleanly and yields a client whose `clientId` is `undefined`. - * - * SHAPE AND SYNTAX ARE ONE CONCERN, which is why all three readers ask this beside their parse - * rather than only around it: either way the deployment holds a client it cannot use, and the - * operator's signal has to survive both. `unusableClient` is that signal, and it is deliberately - * distinct from `noClient`'s holding none. - * - * The shape half earns the check by ending WORSE than the syntax half beside it. An `undefined` - * client id is sent to the vendor, the vendor answers `invalid_client`, and - * {@link refuseAndReplaceEvictedClient} reads that as the vendor having disowned our registration — - * so a corrupt LOCAL row replaces the deployment-wide client every existing consent was granted - * against, and reports it as the vendor's doing rather than naming the credential that broke. - * - * The id has to be there; the secret only has to be a string. A public client registered - * dynamically proves itself with PKCE and is stored with an empty secret ON PURPOSE — - * `registerDynamicClient` checks the id exactly this way and defaults the secret to `""` — so - * demanding a non-empty secret here would refuse every self-registering entry in the catalogue. - */ -function isUsableClient(value: unknown): value is OAuthClient { - if (typeof value !== "object" || value === null) return false; - const { clientId, clientSecret } = value as Partial; - return ( - typeof clientId === "string" && - clientId !== "" && - typeof clientSecret === "string" - ); -} - -/** - * The client and when the vault row holding it was written. - * - * The date is not about the client: it is how long ago this deployment last introduced itself, which - * is the one thing that distinguishes a client the vendor has evicted from one a re-registration - * minted moments ago. Null only for a row that has since disappeared, which the read refuses first. - */ -type StoredClient = { client: OAuthClient; registeredAt: Date | null }; - -/** - * How long a freshly stored OAuth client is left alone after `invalid_client`. - * - * Re-registering once per refusal is right for one call and wrong for a deployment: a vendor that is - * simply down answers every exchange `invalid_client`, and every tool call anywhere then mints a - * client of its own, because each of them is the first refusal IT has seen. A client younger than - * this was already the product of a re-registration, so registering again inside the window is - * amplification rather than recovery — the honest answer is the vendor's refusal, unedited. - */ -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; - expiresInSeconds?: number; - /** - * The refresh token to present next time, from a vendor that rotates. - * - * Absent from Google's replies and present in every one of Notion's. When it is here it is the - * only one that still works — the token just spent is dead at the vendor — so it has to be - * persisted before the access token beside it is used for anything. - */ - refreshToken?: string; -}; - -export type PluginStoreOptions = { - approvalGate?: ApprovalGate; - /** - * The owner's permission before a connector call sends content to other people. - * See plugins/share-target.ts and proactive/private-share.ts. Absent asks nothing. - */ - privateShareCheck?: CheckPrivateShare; - /** Who may steer a run that uses a Shared app's account. Absent, every shared call is refused. */ - sharedUse?: SharedUseGate; - database: Database; - auditStore: AuditStore; - /** - * The vault, read and write. - * - * Writing is here rather than left to the browser posting `/api/admin/credentials` first. An OAuth - * client belongs to the server registration and a refresh token belongs to a connection, so both - * are written by the code that owns those acts — otherwise the first of two calls can succeed and - * the second fail, leaving a secret in the vault that nothing points at and nobody knows to revoke. - * - * `revoke` is part of it because a key holds at most one live credential now. `removeServer` - * retires the server's token, and the two write paths here replace rather than add, so re-adding a - * server or re-authorizing a connection does not meet its own leftover on - * `credentials_active_key_idx`. - */ - credentials: CredentialSecretReader & CredentialStore; - encryptionKey: string; - /** Read at call time, never captured, so a policy changed a moment ago applies to this call. */ - policy: () => ActionPolicy; - /** - * Speaking MCP to the vendor. Defaults to the real client. - * - * Injected so a test can assert what a call was about to go out with. Whose credential is chosen - * is the security property of this module, and asserting it otherwise needs a vendor to be - * reachable, which means the property most worth testing would be the one thing never tested. - */ - callVendor?: ( - connection: { - url: string; - token?: string; - actorId?: string; - botId?: string; - }, - toolName: string, - args: Record, - ) => Promise<{ text: string; isError: boolean }>; - /** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */ - exchangeRefreshToken?: (input: { - tokenUrl: string; - client: OAuthClient; - refreshToken: string; - }) => Promise; - /** RFC 7591 self-registration, for entries whose clientRegistration is dynamic. */ - registerClient?: (input: { - registrationUrl: string; - redirectUri: string; - }) => Promise; - /** - * Composio the broker, absent on a deployment that has not configured one. - * - * OPTIONAL BECAUSE ITS ABSENCE IS A STATE RATHER THAN A MISCONFIGURATION. An unset - * `COMPOSIO_API_KEY` is the documented default: where it is unset there is nothing to connect, - * nothing to grant and no brokered tool for a Bot to call, and what is left on screen is one row - * that goes nowhere under More apps on the admin Plugins page. So the store is constructible - * without one and every path that needs one says so by raising {@link BrokerUnconfiguredError}. - * A required field would make every caller that never enables an app — the routes, the tests - * above — invent a broker to get a store. - */ - broker?: ComposioBroker; - /** Where the vendor sends people back; needed to (re)register a dynamic client. */ - redirectUri?: string; - /** - * What this deployment calls itself (`DEPLOYMENT_ID`, else the tenant package id). The prefix of - * a shared account's vendor identity, read only when an app is switched to Shared. - */ - deploymentId?: string; -}; - -/** - * The literal recorded on the row, which is the scheme a later call must keep using. - * - * TYPED AS {@link RecordedScheme} RATHER THAN `string`, so that what this writes and what - * {@link schemeKind} recognises are held to one set by the compiler. A literal spelled here that - * the reader does not know would be read as `unreadable` — a key app that connects nobody, or a - * consent app no confirm can verify — and nothing but this annotation would say so. - */ -function schemeFor(connection: BrokerConnection): RecordedScheme | 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; - } -} +import { callMethods } from "./plugin-store/calls"; +import { connectionMethods } from "./plugin-store/connections"; +import { createStoreContext } from "./plugin-store/context"; +import { grantMethods } from "./plugin-store/grants"; +import type { PluginStoreOptions } from "./plugin-store/model"; +import { serverMethods } from "./plugin-store/servers"; +import { skillMethods } from "./plugin-store/skills"; + +export type { CatalogueEntry } from "./catalogue"; +export type { + AccessToken, + BrokeredProbe, + GrantedPlugins, + OAuthClient, + PluginDecision, + PluginKind, + PluginStoreOptions, + ServerAddress, + ServerRecord, + SkillActor, + SkillRecord, + ToolRecord, + WithdrawnGrant, +} from "./plugin-store/model"; +export { + BROKERED_PROBE_OUTCOMES, + CatalogueEntryUnknownError, + CustomServerRefusedError, + deploymentFaultSentence, + exchangeRefreshTokenOverHttp, + INVALID_CLIENT, + isDeploymentFault, + PluginInvariantError, + PluginRefusedError, + refFromToolName, + TokenRefusedError, + toolNameFor, + unlistedAdvertisedTools, +} from "./plugin-store/model"; export function createPluginStore(options: PluginStoreOptions) { - const { database, auditStore, credentials, encryptionKey } = options; - /* - * Held rather than resolved, because the transport is a property of the entry and is not known - * until a call names one. An injected vendor still wins over both, which is what keeps a test able - * to assert what a call was about to go out with. - */ - const injectedVendor = options.callVendor; - 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 - * token it was shown, so two concurrent calls that both present the old one would have the - * second refused through no fault of anybody's. The chain serialises them; the map entry is - * removed when the chain drains so the map cannot grow past the set of active connections. - */ - const exchangeChains = new Map>(); - function serialized(key: string, work: () => Promise): Promise { - const previous = exchangeChains.get(key) ?? Promise.resolve(); - const next = previous.catch(() => {}).then(work); - exchangeChains.set(key, next); - /* - * The refusal belongs to the caller, who is holding `next` and will see it. This branch exists - * only to forget the key, so it swallows before it cleans up: `next.finally(…)` on its own - * derives a SECOND rejected promise that nobody is holding, and a refused call — a withdrawn - * credential, say — then surfaces as an unhandled rejection somewhere unrelated. - */ - void next - .catch(() => {}) - .finally(() => { - if (exchangeChains.get(key) === next) exchangeChains.delete(key); - }); - return next; - } - - async function grantsFor(kind: PluginKind, refs: string[]) { - if (refs.length === 0) return new Map(); - const rows = await database - .select() - .from(pluginGrants) - .where(and(eq(pluginGrants.kind, kind), inArray(pluginGrants.ref, refs))); - const byRef = new Map(); - for (const row of rows) { - byRef.set(row.ref, [...(byRef.get(row.ref) ?? []), row.agentId]); - } - return byRef; - } - - /** - * Every MCP grant belonging to these servers, whether or not the tool is still advertised. - * - * {@link grantsFor} asks about refs somebody already has, which is the wrong question when the - * point is to find the ones nothing else knows about: called with the advertised refs it can only - * ever return a subset of them, so a grant on a withdrawn tool is invisible by construction. - * - * Matched on the server half in the query rather than by reading every grant and splitting here. - * `split_part` rather than a `LIKE` prefix, because a server id is text a person can choose for a - * custom server and `%` in one would silently widen the match. - */ - async function mcpGrantsForServers(serverIds: string[]) { - if (serverIds.length === 0) return new Map(); - const rows = await database - .select({ ref: pluginGrants.ref, agentId: pluginGrants.agentId }) - .from(pluginGrants) - .where( - and( - eq(pluginGrants.kind, "mcp"), - inArray(sql`split_part(${pluginGrants.ref}, '/', 1)`, serverIds), - ), - ); - const byRef = new Map(); - for (const row of rows) { - byRef.set(row.ref, [...(byRef.get(row.ref) ?? []), row.agentId]); - } - return byRef; - } - - /** The refs each of these skills declares, keyed by skill id. Skills with none are absent. */ - async function toolsDeclaredBy(skillIds: string[]) { - if (skillIds.length === 0) return new Map(); - const rows = await database - .select() - .from(skillTools) - .where(inArray(skillTools.skillId, skillIds)) - .orderBy(asc(skillTools.ref)); - const bySkill = new Map(); - for (const row of rows) { - bySkill.set(row.skillId, [...(bySkill.get(row.skillId) ?? []), row.ref]); - } - return bySkill; - } - - /** - * Which of these refs name a tool this deployment has actually seen. - * - * Asked when a skill is saved, so a typo is refused where it was written rather than becoming a - * skill that quietly selects nothing. Not asked at run time: a refresh deletes and rewrites a - * server's tool rows, so a ref can be legitimately absent for a moment, and a run must read that as - * "load nothing" rather than as a failure. - */ - async function knownToolRefs(refs: string[]) { - if (refs.length === 0) return new Set(); - // Narrowed in the query to the servers actually named, rather than reading the whole catalogue - // and filtering here. A deployment aiming at a thousand tools should not scan all of them to - // check three. - const servers = [...new Set(refs.map((ref) => ref.split("/")[0] ?? ""))]; - const rows = await database - .select({ serverId: mcpTools.serverId, name: mcpTools.name }) - .from(mcpTools) - .where(inArray(mcpTools.serverId, servers)); - const known = new Set(rows.map((row) => `${row.serverId}/${row.name}`)); - return new Set(refs.filter((ref) => known.has(ref))); - } - - /** - * Who did it goes in the payload, never in `actorUserId`. - * - * That column is a foreign key to `users.id`, and everything here holds an email. Writing one - * there does not fail loudly: the insert violates the constraint and the entire audit row is lost. - */ - - /** - * A credential out of the vault, decrypted for one call and never held. - * - * A revoked credential is turned into a refusal rather than left as the vault's thrown error. The - * two reach a person very differently: an error becomes "that tool could not be called", which is - * what a vendor being down looks like, while a withdrawn grant is nobody's fault and has an - * obvious next step. `reconnect` says which of the two to name. - * - * WHICH OF THE TWO IS DECIDED BY CLASS. {@link CredentialUnusableError} is the one thing the vault - * raises that means the credential is gone, and everything else that can come out of this call is - * a fault: a query this database refused, a connection it would not open, an envelope that would - * not decrypt. Those are rethrown untouched, which puts a query failure on the - * {@link isDeploymentFault} shelf where the four audiences already agree about it. - * - * WHAT THIS USED TO BE, and why the difference is not cosmetic. It asked whether `error.message` - * CONTAINED "revoked" or "not found". drizzle's message for a failed query begins `Failed query: - * select "encrypted_value", "revoked_at" from "credentials" …` — the column this very read selects - * — so every database fault on the vault read matched the first substring and was converted into - * `onRevoked`: a `PluginRefusedError`, which is the one class this codebase relays VERBATIM. A - * Postgres that was down told the model the credential had been withdrawn, told a browser the same - * through the routes that pass a refusal out as a 400, and wrote it into `mcp_servers.last_error` - * for an operator to act on — sending somebody to re-add a credential that was never the problem - * while the real fault was reported nowhere. The same argument {@link TokenRefusedError} carries a - * `code` for: a decision that reads prose is one rewording away from being wrong in silence, and - * this one did not even need the rewording. - */ - async function secretFor( - credentialId: string, - onRevoked: string, - ): Promise { - try { - return await decryptCredentialForUse( - encryptionKey, - credentials, - credentialId, - ); - } catch (error) { - if (error instanceof CredentialUnusableError) { - throw new PluginRefusedError(onRevoked, null); - } - throw error; - } - } - - /** - * The account a call, a connect or a check acts for, from the row and the person — never from a - * request. A Shared app answers with the deployment's stored identity whoever asks. - */ - async function accountRefFor( - serverId: string, - actorId: string, - ): Promise< - | { ref: AccountRef; title: string; mode: AccountMode; appId: string } - | { refusal: string } - > { - const [dialled] = await database - .select({ - title: mcpServers.title, - provenance: mcpServers.provenance, - url: mcpServers.url, - authScheme: mcpServers.authScheme, - accountMode: mcpServers.accountMode, - sharedVendorUserId: mcpServers.sharedVendorUserId, - }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!dialled) - return { - refusal: `${serverId} is not an app this deployment has added.`, - }; - /* - * THE NAME IS THE DIALLED ROW'S; THE ACCOUNT IS THE APP'S. - * - * CRITERION. For a brokered row whose url names an app, the scheme, the mode and the deployment - * identity are read off the row {@link brokeredAppRow} answers for that app, and `appId` names - * that row. Only the title — the words a person is shown — comes from the row they dialled. - * - * REASON. The url carries no unique index, so a second row can sit at an app's url carrying - * account columns of its own. Read off the dialled row, those columns let a duplicate decide - * the account for the app: `personal` at a Shared app's url took a non-administrator through - * Connect to a personal account on the team's app and took calls round the audience gate, and - * `shared` at a Personal app's url sent a person's call out as the deployment. The connection - * this decides is then looked up by toolkit, which is the app — so the decision has to be the - * app's too, or its two halves are about different rows. - * - * A BROKERED ROW WITH NO ANSWERING ROW IS NOT AN APP ANY MORE. The dialled row sits at the url, - * so only a removal racing this read gets here, and it is refused as the missing row is above. - */ - const toolkit = - dialled.provenance === "composio" ? toolkitOf(dialled.url) : null; - const app = toolkit ? await brokeredAppRow(toolkit) : null; - if (toolkit && !app) - return { - refusal: `${serverId} is not an app this deployment has added.`, - }; - const row = app - ? { - title: dialled.title, - provenance: dialled.provenance, - authScheme: app.authScheme, - accountMode: app.accountMode, - sharedVendorUserId: app.sharedVendorUserId, - } - : dialled; - const appId = app?.id ?? serverId; - const answer = accountFor(row, actorId); - type _RefDecides = Decides< - AccountAnswer["kind"], - { - none: "refused here — there is no account to connect, check or end"; - person: "the actor, under their own id"; - deployment: "the deployment, under the identity minted when the app became Shared"; - ambiguous: "refused with accountFor's own sentence"; - } - >; - if (answer.kind === "none") - return { refusal: `${row.title} needs no account.` }; - if (answer.kind === "ambiguous") return { refusal: answer.message }; - if (answer.kind === "person") { - if (!actorId) - return { - refusal: `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, - }; - return { - ref: { holder: "person", userId: actorId, vendorUserId: actorId }, - title: row.title, - mode: "personal", - appId, - }; - } - if (!row.sharedVendorUserId) { - throw new PluginInvariantError( - `${appId} is Shared and holds no deployment identity; switch it to Personal and back.`, - ); - } - return { - ref: { holder: "deployment", vendorUserId: row.sharedVendorUserId }, - title: row.title, - mode: "shared", - appId, - }; - } - - /** The WHERE clause for one account's row, by holder. */ - const accountRow = (toolkit: string, account: AccountRef) => - account.holder === "person" - ? and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - eq(brokeredConnections.holder, "person"), - eq(brokeredConnections.userId, account.userId), - ) - : and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - eq(brokeredConnections.holder, "deployment"), - ); - - /** - * The token one call goes out with, and whose it is — decided from `access.credential`, so that - * this function and the audit row cannot disagree about whose account a call ran in. - * - * For a `deployment-token` server this is what it always was: the one credential an administrator - * gave the server, used for everybody. A `none` server reaches the same branch and finds nothing - * to decrypt, which is the right answer for an endpoint that takes no credential at all. - * - * For a `brokered` server there is no token here AT ALL. The deployment's one key belongs to the - * transport and never travels through this function, so nothing here can leak it into a connection - * object, an error or an audit row. What this function contributes instead is the two refusals - * that have to happen before a call is spent at the broker: a run nobody is attributed for, and an - * asker who has not connected the app — so a person is told their own next step rather than shown - * the broker's error about an account it cannot find. A third refusal sits between those two, for - * a brokered row whose url names no Composio app; nothing in the product creates such a row, so no - * person's situation reaches it. - * - * For a `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 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; - title: string; - credentialId: string | null; - /* - * NO `authScheme` HERE, DELIBERATELY. The scheme that decides whether a brokered call needs a - * connection row is the APP'S, read through `brokeredAppScheme` below — and this row's own - * column is the near-miss that reading replaced. A field kept here for convenience would be - * the wrong answer sitting in the parameter list of the function that must not use it. - */ - }, - entry: CatalogueEntry | null, - actorId: string, - access: ServerAccess, - /** - * The mode a call's audience gate was decided about, where one was; see the check beside - * {@link accountRefFor} below. Absent for a path with no such gate, the tool refresh. - */ - gatedMode?: AccountMode | null, - ): Promise<{ token?: string; vendorUserId?: string }> { - /* - * A brokered app, where the deployment holds one key and Composio keeps the accounts apart. - * - * Refused HERE rather than in the transport, for the two reasons the `user-oauth` branch below - * is: a person gets a sentence naming the step they can take, and no call is spent at the - * vendor finding out. The transport refuses an unattributed run again as a last line, so - * deleting either that guard or this one has to turn a test red. The unconnected case has no - * such twin: the transport has no notion of a connection at all, so the last line there is - * Composio itself — which is what refusing locally earns its place for, since it turns the - * broker's error about an account it cannot find into a sentence naming the person's own next - * step. - * - * The throw between the two is a third refusal, but not one anybody can act on: it fires only - * for a brokered row whose url names no Composio app, which nothing in the product can create. - * It is what keeps this gate keyed on the app the url names, and its own comment says why - * neither fallback is available. - * - * There is no token. The key belongs to the transport and never travels through this function, so - * nothing here can leak it into a connection object, an error or an audit row. - */ - if (access.credential === "brokered") { - if (!actorId) { - throw new PluginRefusedError( - `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, - null, - ); - } - - /* - * Narrowing, and a refusal that is genuinely reachable. - * - * `access.toolkit` is the app slug read off this row's url in `access.ts`, and it is NULL - * whenever that url does not name a Composio app — `accessFor` still answers `brokered` for - * any row whose provenance column says composio, so `{ credential: "brokered", toolkit: null }` - * is a state a hand-edited or restored row really produces. The test beside `accessFor` - * asserts it, and `plugin-store.integration.test.ts` gates this branch end to end. - * - * A throw rather than a fallback, for the reason the `user-oauth` narrowing below throws: both - * alternatives fail open. Falling back to `row.id` checks the connection against a spelling - * nothing dials, and skipping the gate spends the deployment's shared key on a connector whose - * whole purpose is to keep one person's account out of another's. The compiler forces SOME - * narrowing here — drizzle's `eq` will not take `string | null` — but only the test named - * above stops that narrowing from being the fallback. - */ - if (!access.toolkit) { - throw new PluginInvariantError( - `${row.id} resolves to a brokered credential with no Composio app in its url.`, - ); - } - - /* - * AN APP THAT NEEDS NO AUTHENTICATION HAS NO ROW TO FIND, AND CANNOT EVER HAVE ONE. - * - * `brokered_connections` is the whole of the permission for a brokered call, and every row in - * it means one thing: an account at this app was granted to this deployment — a person's own, - * or for a Shared app the team account an administrator connected. 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. - * - * AND IT IS THE APP'S RECORDED SCHEME, NOT THIS ROW'S, WHICH IS THE SAME KEYING THE GATE - * BELOW ALREADY USES. - * - * CRITERION. Whether a brokered call needs a connection row is decided by the app the url - * names, out of the one row that answers for it — see {@link brokeredAppRow}. - * - * REASON. This read `row.authScheme`, the column on whichever row the call was dialled - * through, while the gate two lines down looks the connection up by TOOLKIT. Two rows may - * name one app, so the two halves of one decision were about different rows — and this half - * fails open: a duplicate row recording `NO_AUTH` at a key app's url skips the per-person gate - * entirely, and the deployment's own Composio key runs a call for somebody who connected - * nothing. The other direction merely refuses a call that could have gone through. A gate and - * its exemption have to be keyed on the same thing, and the gate's key is the app. - * - * ONE SMALL READ PER BROKERED CALL, which is the price of that. `mcp_servers` holds one row - * per connector on any deployment, and the call it guards is a network round trip to the - * vendor. - */ - if ((await brokeredAppKind(access.toolkit)) === "none") return {}; - - /** - * WHAT THAT GATE ANSWERS FOR EACH KIND OF APP, WRITTEN DOWN BECAUSE THE LINE ABOVE DOES NOT. - * - * Type-only and erased; see {@link Decides}. This was the ONE scheme read in the file that - * compared a raw literal instead of asking {@link schemeKind}, so the vocabulary it decided on - * was not the vocabulary it read: `NO_AUTH` was a CONSENT-kind scheme, and this line exempted - * it from inside that member while every other consumer of `consent` demanded a row. One - * member answered two ways is the drift a closed vocabulary exists to make impossible, and the - * cost was paid next door — {@link confirmBrokeredConnection} asked the classifier, was told - * `consent`, and wrote the verified connection row this gate and the connect route both exist - * to keep out of that table. `none` is now its own member, and this asks for it by name. - */ - type _NoAuthGateDecides = Decides< - SchemeKind, - { - key: "demands a connection row, because a key app has an account to hold one"; - consent: "demands a connection row, because somebody consented and the row is that grant"; - none: "lets the call through with no row at all — there is no account, so there is nothing anybody could have granted"; - unreadable: "demands a connection row, which is the closed direction and the right one"; - } - >; - - /* - * Keyed on the app the call will run in, which is the one the url names, and on the account - * the ROW says acts — the asker's own for a Personal app, the deployment's for a Shared one. - * - * `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 resolved = await accountRefFor(row.id, actorId); - if ("refusal" in resolved) - throw new PluginRefusedError(resolved.refusal, null); - /* - * THE ACCOUNT MUST BE THE ONE THE AUDIENCE GATE WAS ASKED ABOUT. - * - * `callTool` decides whether to run the Shared audience gate off the app's mode as it read it, - * and this reads the mode again. An administrator switching the app to Shared between the two - * reads would otherwise send an ungated call out on the team account. Refused rather than - * re-gated: the switch is rare, asking again is cheap, and a refusal cannot be the hole. - */ - if (gatedMode && resolved.mode !== gatedMode) - throw new PluginRefusedError( - `${row.title} changed how it is shared while this call was being checked, so it was not called. Ask again.`, - null, - ); - const [connected] = await database - .select({ vendorUserId: brokeredConnections.vendorUserId }) - .from(brokeredConnections) - .where(accountRow(access.toolkit, resolved.ref)) - .limit(1); - if (!connected) { - throw new PluginRefusedError( - resolved.ref.holder === "deployment" - ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.` - : `You have not connected your ${row.title} account. Connect it in Settings and ask again.`, - null, - ); - } - /* - * THE ROW'S IDENTITY, NOT A RECOMPUTED ONE. A shared account was connected under the id minted - * when the app became Shared; reading it back here is what makes a later change to - * DEPLOYMENT_ID unable to move the account a call lands in. - */ - return { vendorUserId: connected.vendorUserId }; - } - - if (access.credential !== "person-oauth") { - const token = row.credentialId - ? await secretFor( - row.credentialId, - `${row.id} needs a credential this deployment no longer holds. An administrator has to add it again.`, - ) - : undefined; - return { token }; - } - - /* - * Narrowing, not a second decision. - * - * `access.credential === "person-oauth"` is derived in `access.ts` from exactly this auth kind, - * so the branch above has already established it — but the derivation runs through a lookup - * table the compiler cannot follow back to `entry`. Nothing below re-decides whether this is a - * per-person server; it only reads the OAuth details that kind carries. - * - * A throw rather than a fallback. If the descriptor and the entry ever did disagree, answering - * out of the deployment's own credential is precisely the failure the comment above this function - * says must be impossible. - */ - if (entry?.auth.kind !== "user-oauth") { - throw new PluginInvariantError( - `${row.id} resolves to a per-person credential with no user-oauth catalogue entry.`, - ); - } - - /* - * The anonymous actor is the empty string, and an empty string must never match a row. - * - * `identifyActor` answers with `{ id: "" }` when it cannot resolve who is asking. Letting that - * reach the lookup would mean a run nobody can be held accountable for picking up whichever - * grant sorted first, so it is refused before the query rather than trusted to miss. - */ - if (!actorId) { - throw new PluginRefusedError( - `${row.id} answers as the person asking, and this run is not attributed to anybody.`, - null, - ); - } - - /* - * Whether this person has connected at all, which is a refusal worth reaching before anything - * queues behind another call. WHICH credential they hold is read again inside the critical - * section below, because a reconnection can move it while this call waits its turn — and even - * when the row stays put, the secret inside it does not. - */ - const [held] = await database - .select({ credentialId: mcpUserCredentials.credentialId }) - .from(mcpUserCredentials) - .where( - and( - eq(mcpUserCredentials.serverId, row.id), - eq(mcpUserCredentials.userId, actorId), - ), - ) - .limit(1); - - if (!held) { - throw new PluginRefusedError( - `You have not connected your ${entry.title} account. Connect it in Settings and ask again.`, - null, - ); - } - - // Held before the critical section, because narrowing does not survive into a closure and this - // is where the entry is known to be a `user-oauth` one. - const { tokenUrl } = entry.auth; - const { title } = entry; - /* - * Where to register again, for a vendor that issues its own clients — and undefined for one an - * administrator registered with by hand, where there is nothing this deployment could do about a - * client the vendor no longer honours. - */ - const registrationUrl = - entry.auth.clientRegistration === "dynamic" - ? entry.auth.registrationUrl - : undefined; - - /* - * What to tell the person when the deployment holds no client they can be called on, in the - * words of whoever can actually change that. - * - * A hand-registered client is an administrator's paperwork — they pasted it in from the vendor's - * console, and only they can paste one in again. A self-registered one is nobody's paperwork: - * there is no console entry to re-create, and the deployment introduces itself again the next - * time somebody connects. Naming an administrator there would send the person to somebody with - * no step to take, which is worse than saying nothing. - */ - const noClient = registrationUrl - ? `${title} has no OAuth client for this deployment, so this cannot be called. Connect ${title} again in Settings: the deployment registers itself with the vendor when somebody connects.` - : `${title} has no OAuth client registered for this deployment, so this cannot be called. An administrator has to add one.`; - const unusableClient = registrationUrl - ? `${title} has no usable OAuth client for this deployment. Connect ${title} again in Settings: the deployment registers itself with the vendor on the next connect.` - : `${title} has no usable OAuth client for this deployment. An administrator has to add one again.`; - /** - * What to say when the vendor has forgotten the client this person's grant was issued under. - * - * The same register as the two above, and the same instruction, because it is the same situation - * from the person's side: nothing they can be called on. What is different is that the - * deployment CAN do its half — introduce itself again — and has, by the time this is thrown. So - * the sentence says that too, otherwise "connect again" reads as a thing to keep trying. - * - * Their refresh token is not carried across. A grant belongs to the client it was issued to (RFC - * 6749 §6, §10.4), so re-presenting it under the new client is a request a conforming vendor - * refuses — and one that only ever appears to work against a vendor whose acceptance would - * itself be the vulnerability. A new consent is the only thing that produces a usable grant. - */ - const clientReplaced = `${title} no longer recognises this deployment's OAuth client, so this cannot be called. The deployment has registered itself again — connect ${title} again in Settings.`; - - if (!row.credentialId) { - // The person did their part; the deployment has not. Refused before anything queues, because - // a deployment holding no client has the same answer for everybody asking. - throw new PluginRefusedError(noClient, null); - } - - /** - * The client as the deployment holds it right now, or the refusal for holding none. - * - * Read from the server row each time rather than from the row this call came in with: a retry - * that registered again — this connection's own, a moment ago — replaced it, and the pointer - * carried in from before the queue names the evicted one. - */ - async function currentClient(): Promise { - /* - * When the client was stored comes back with it, from the vault row itself rather than from a - * column of our own. It is what the retry below measures its backoff against, and a left join - * keeps it one query: a server pointing at nothing is the refusal on the next line, and a - * pointer to a row that is no longer there is `secretFor`'s to refuse. - */ - const [server] = await database - .select({ - credentialId: mcpServers.credentialId, - registeredAt: credentialRows.createdAt, - }) - .from(mcpServers) - .leftJoin( - credentialRows, - eq(credentialRows.id, mcpServers.credentialId), - ) - .where(eq(mcpServers.id, row.id)) - .limit(1); - if (!server?.credentialId) { - throw new PluginRefusedError(noClient, null); - } - /* - * Decrypted outside the guard below, so that `secretFor`'s own refusal for a revoked or - * missing row is not caught here and relabelled. Only the parse is guarded. - */ - const decrypted = await secretFor(server.credentialId, unusableClient); - let parsed: unknown; - try { - parsed = JSON.parse(decrypted); - } catch { - /* - * Unreadable is the same as none, exactly as it is for {@link heldOAuthClient} and - * {@link storedOAuthClient}: there is nothing here to present to a vendor. Those two answer - * null because their callers are deciding whether a consent flow can start; this one is - * already mid-call, and its every caller would have to turn a null into this same refusal - * on the next line — so it raises it. - * - * THE PARSER'S OWN WORDS ARE NEVER CARRIED. `JSON.parse` reports failure by quoting the - * input it choked on, and the input here is the DECRYPTED OAuth client. Rethrowing it puts - * a fragment of the client secret — under Bun's parser, the whole of it when the stored - * value is a bare token — into the `mcp.call_failed` payload and into - * `mcp_servers.last_error`, two durable stores that the Plugins page draws for anybody who - * can read it. - * - * A reader should conclude that the signal is not lost, only the bytes: `unusableClient` - * says the deployment holds a client it cannot use, which is distinct from `noClient`'s - * holding none, and it names connecting again as what replaces it. An operator can tell - * the credential is broken; nobody learns what was in it. - */ - throw new PluginRefusedError(unusableClient, null); - } - /* - * The same refusal for a value that parsed and is not a client — see {@link isUsableClient}, - * which is where the criterion and the reason live, because all three readers share both. - * - * Raised rather than answered null, for the reason above: this caller is mid-call. It is the - * third throw in a row here — `noClient`, then the parse, then this — and that is the shape - * of the contract rather than a repetition to collapse. Each names a different state of the - * deployment's credential, and only the sentence is shared between the last two. - */ - if (!isUsableClient(parsed)) { - throw new PluginRefusedError(unusableClient, null); - } - return { client: parsed, registeredAt: server.registeredAt }; - } - - /* - * The exchange, one call at a time for this connection, reading the connection fresh inside. - * - * Both halves of what goes out are read in here rather than carried in from above, because both - * can move while this call waits its turn. The refresh token rotates, and the token read a - * moment ago is then already spent — presenting it would be refused by the vendor for no reason - * the person could act on. The client is replaced by a re-registration, and presenting the - * evicted one would have every queued call discover that separately and register around it. - * - * TWO things serialise this, and they are not redundant. The map above queues calls made in THIS - * process; the row lock below serialises the whole deployment. Only the lock is a correctness - * property — a second replica has a map of its own and is not in ours — and the map is what - * keeps a burst of calls on one connection from piling N transactions onto that one row lock, - * each of them holding a pooled connection while it waits its turn. - * - * An evicted client is handled AFTER the transaction rather than inside it. Nothing about - * re-registering needs this person's lock — the client is per server — and doing it inside would - * have a second pooled connection opened while this one holds a row lock, which is the shape the - * pool note in `db/client.ts` is about. - */ - return await serialized(`${row.id}:${actorId}`, async () => { - /* - * The client, read before the transaction opens rather than inside it. - * - * It is per SERVER and is not what the lock protects, and reading it here keeps the vault read - * off a second pooled connection while this call holds one open for the whole exchange. Still - * inside the critical section, so the ordering that matters is unchanged: a queued call reads - * the client after whatever ran before it replaced it. - */ - const stored = await currentClient(); - - /* - * The vault row, locked for as long as the token it holds is being spent. - * - * A rotating vendor kills the refresh token it was shown, so two replicas that both read the - * stored token and both present it do not merely race: the second presentation looks to the - * vendor like a stolen token being replayed, and refresh-token-reuse detection answers it by - * revoking the whole token family. The connection is then bricked, and nobody did anything - * wrong. `SELECT … FOR UPDATE` is what makes the second replica wait for the first, exactly as - * a person reconnecting already waits (`credentials.rotate`). - * - * Yes, the lock is held across an HTTP call to the vendor — bounded by the exchange's own - * timeout. That is the point rather than an oversight: the lock IS the cross-replica - * serialisation, and a lock released before the exchange would serialise nothing. - * - * The read comes AFTER the lock, never before. A replica that woke from the lock and used a - * token it had read on the way in would present the one the first replica just spent, which is - * the very double-spend this exists to prevent. - */ - try { - return await database.transaction(async (transaction) => { - const [current] = await transaction - .select({ - credentialId: mcpUserCredentials.credentialId, - scope: mcpUserCredentials.scope, - }) - .from(mcpUserCredentials) - .where( - and( - eq(mcpUserCredentials.serverId, row.id), - eq(mcpUserCredentials.userId, actorId), - ), - ) - .limit(1); - - // Disconnected while this call was queued. The same sentence as above: nothing is broken, - // and connecting again is the thing to do. - if (!current) { - throw new PluginRefusedError( - `You have not connected your ${title} account. Connect it in Settings and ask again.`, - null, - ); - } - - const [locked] = await transaction - .select({ - encryptedValue: credentialRows.encryptedValue, - revokedAt: credentialRows.revokedAt, - }) - .from(credentialRows) - .where(eq(credentialRows.id, current.credentialId)) - .for("update"); - /* - * A row that is gone or revoked, said the way `secretFor` says it: withdrawn access is - * nobody's fault and connecting again is the step. Reached by a replica that waited here - * while somebody disconnected, as well as by one that was told after the fact. - */ - if (!locked || locked.revokedAt) { - throw new PluginRefusedError( - `Your ${title} access was withdrawn. Connect it again in Settings.`, - null, - ); - } - const refreshToken = await decryptSecret( - encryptionKey, - locked.encryptedValue, - ); - - const minted = await exchangeRefreshToken({ - tokenUrl, - client: stored.client, - refreshToken, - }); - - /* - * A vendor that sent nothing back, or sent back the token we presented, rotated nothing — - * and writing either would be inventing a rotation, at the cost of a needless - * re-encryption of every connection on every call. - */ - if (minted.refreshToken && minted.refreshToken !== refreshToken) { - /* - * The vendor rotated the grant: the token we were just shown is now the only valid one. - * Persisting it is not optional bookkeeping — failing to would strand the connection on - * the next call — so a failure here refuses THIS call rather than returning an access - * token whose refresh token is already spent. - * - * In this transaction, so it commits with the lock that made the exchange ours: written - * outside it, the next replica in line would wake to the token this one just spent. - */ - await rotateConnectionToken( - { - credentialId: current.credentialId, - refreshToken: minted.refreshToken, - }, - transaction, - ); - } - - return { token: minted.accessToken }; - }); - } catch (error) { - /* - * Outside the transaction, so the row lock is already released and the vault read this does - * is not a second connection held behind this one's. - * - * Rethrows anything that is not the vendor disowning our client, which is every ordinary - * failure: a withdrawn grant, a vendor being down, a disconnect mid-queue. - */ - return await refuseAndReplaceEvictedClient({ - error, - clientRegisteredAt: stored.registeredAt, - registrationUrl, - serverId: row.id, - refusal: clientReplaced, - }); - } - }); - } - - /** - * The vendor has disowned this deployment's client: fix the deployment, refuse the call. - * - * `invalid_client` is the vendor saying the CLIENT is the problem, and for a client the deployment - * issued to itself there is nobody to tell — no console entry an administrator could re-create, so - * every connection to that server would otherwise sit behind a refusal nothing here can act on. - * Introducing itself again is the same act as the first registration, and it is worth doing: it is - * what makes the next CONSENT possible. - * - * IT DOES NOT MAKE THIS CALL POSSIBLE, and this function used to pretend otherwise. It registered a - * new client and re-presented the same refresh token under it. A refresh token is bound to the - * client it was issued to — RFC 6749 §6 has the token endpoint verify exactly that, and §10.4 is - * why — so a conforming vendor refuses the retry, and the only vendor it can work against is one - * whose acceptance would itself be the vulnerability. So the grant is never carried across, and the - * person is told the one thing that helps: connect again. - * - * The re-registration is still bounded by {@link CLIENT_REREGISTRATION_BACKOFF_MS}, and that bound - * is the whole protection here rather than a nicety. This runs for any non-admin's tool call, and - * it REPLACES the client every other connection in the deployment is bound to; a vendor that is - * simply down answers every exchange `invalid_client`, so without the window one outage would have - * each call in turn rotate the deployment-wide client. A client younger than the window is the - * product of the last refusal's re-registration, and is left exactly alone. - * - * Always throws. The refusal it raises when it did register is the caller's answer; anything it - * cannot act on is rethrown untouched, because the vendor's own words are better than ours. - */ - async function refuseAndReplaceEvictedClient(input: { - error: unknown; - /** When the client that was just refused was stored. */ - clientRegisteredAt: Date | null; - registrationUrl: string | undefined; - serverId: string; - /** What to tell the person once the deployment has registered itself again. */ - refusal: string; - }): Promise { - const { error, registrationUrl, serverId } = input; - /* - * The code, off the error itself. Never the sentence: that is written for a person, and a - * recovery that read it would be one rewording away from silently never running again. - */ - const code = error instanceof TokenRefusedError ? error.code : null; - const { redirectUri } = options; - if (code !== INVALID_CLIENT || !registrationUrl || !redirectUri) { - throw error; - } - - const registeredAt = input.clientRegisteredAt; - if ( - registeredAt && - Date.now() - registeredAt.getTime() < CLIENT_REREGISTRATION_BACKOFF_MS - ) { - throw error; - } - - const fresh = await registerClient({ registrationUrl, redirectUri }); - // The vendor would not have us either. The first refusal is the one worth reporting: it says - // what actually stopped the call, where this one says what stopped the recovery. - if (!fresh) throw error; - - await persistOAuthClient({ serverId, client: fresh, by: "deployment" }); - throw new PluginRefusedError(input.refusal, null); - } - - /** - * Point one person's connection at a new refresh token, revoking the one it replaces. - * - * For a person connecting, which is where a new row earns its keep: what they held before this is - * still a live grant at the vendor, and the revocation is how it stops being one. A vendor's own - * rotation is the other case entirely, and goes through {@link rotateConnectionToken}. - * - * Upserted on the pair, so it is the same act whether they are connecting or reconnecting. The - * credential the row used to point at is revoked in the same breath: a refresh token nothing - * points at is still a live grant at the vendor, and leaving it behind would mean somebody had two - * valid grants and could only ever see one of them to withdraw it. - * - * Says whether it replaced something, which is the one fact the caller writing the trail needs - * and cannot recover afterwards. - * - * ONE TRANSACTION, because these are two writes and one decision. The secret goes into the vault - * and the connection row is pointed at it; separately, a failure between them leaves the pointer - * naming the credential the rotation had just revoked — a connection that reads as live on the - * settings page and refuses every call, with the person's actual grant retired and no way back to - * it. `credentials.rotate` and `credentials.create` already accept the caller's executor for - * exactly this, and the pointer write runs on the same one. - */ - async function swapUserCredential(input: { - serverId: string; - userId: string; - refreshToken: string; - scope: string; - }): Promise<{ replaced: boolean }> { - const key = { - kind: "mcp_user_token" as const, - provider: input.serverId, - keyId: input.userId, - }; - const value = { - ...key, - metadata: { server: input.serverId, scope: input.scope }, - // Encrypted before the transaction opens: it is arithmetic, and it has no business happening - // while a pooled connection is held open behind row locks. - encryptedValue: await encryptSecret(encryptionKey, input.refreshToken), - }; - - return await database.transaction(async (transaction) => { - /* - * `credentials_active_key_idx` holds one live credential per key, so a second insert for the - * same person and server would be refused. Asked of the key rather than of the connection row, - * because the row can name a credential that has already been revoked while the key itself is - * free, and it is the key the index constrains. - */ - const live = await credentials.findLiveByKey(key, transaction); - const stored = live - ? await credentials.rotate( - { ...value, previousCredentialId: live.id }, - transaction, - ) - : await credentials.create(value, transaction); - - await transaction - .insert(mcpUserCredentials) - .values({ - serverId: input.serverId, - userId: input.userId, - credentialId: stored.id, - scope: input.scope, - }) - .onConflictDoUpdate({ - target: [mcpUserCredentials.serverId, mcpUserCredentials.userId], - set: { - credentialId: stored.id, - scope: input.scope, - updatedAt: new Date(), - }, - }); - - return { replaced: live !== null }; - }); - } - - /** - * Carry a connection over to the refresh token the vendor rotated to. - * - * In place, in the vault row the connection already points at — deliberately NOT the swap - * {@link swapUserCredential} performs. A rotating vendor issues a new refresh token on every - * exchange, so a swap here would mint a row and revoke a row per tool call, forever, on the - * hottest path there is. And the revocation would have nothing to withdraw: the token just spent - * was dead at the vendor the moment it answered, so the only live grant is the one being written. - * - * Deliberately WITHOUT the `mcp.account_connected` row, for the same reason as ever: rotation is - * the vendor's plumbing, not a person's act, and a trail that records it as one reads as a - * re-consent that nobody performed. - * - * The scope and the row are left alone. Nothing about what the vendor granted has changed — only - * which token presents it. - */ - async function rotateConnectionToken( - input: { - credentialId: string; - refreshToken: string; - }, - /** - * The transaction the caller spent the token in, and this write belongs to it. - * - * The caller holds a `FOR UPDATE` lock on the very row being written. On its own pooled - * connection this write would be a second session waiting for a lock only the caller can - * release, and the caller cannot release it while awaiting this — so it would hang to the - * statement timeout rather than rotate. - */ - executor?: CredentialExecutor, - ): Promise { - await credentials.updateSecret( - input.credentialId, - await encryptSecret(encryptionKey, input.refreshToken), - executor, - ); - } - - /** The one vault key a server's OAuth client is ever stored under. */ - const oauthClientKey = (serverId: string) => ({ - kind: "mcp_oauth_client" as const, - provider: serverId, - keyId: `oauth-client-${serverId}`, - }); - - /** - * One writer at a time for one server's OAuth client, across the whole deployment. - * - * Everything that stores a client reads "is there a live one" and then writes accordingly, and the - * gap between those two used to be unserialised. `POST /connect` is `requireUser`, so two people - * pressing Connect on a fresh connector is not a rare interleaving: both read no live client, and - * then the second `create` meets the first on `credentials_active_key_idx` as a raw 23505 — a 500 - * where a consent URL belonged — or, when there was a client to replace, the second `rotate` finds - * its own predecessor already revoked and says so. - * - * An ADVISORY lock rather than a row lock, because the thing being protected is the ABSENCE of a - * row as much as a row: there is nothing to lock `FOR UPDATE` on a first registration. Held for - * the transaction, so it is released by the commit or the rollback and never by us forgetting. - * - * `hashtext` collisions are harmless here. Two servers sharing a hash would take turns registering - * clients, which is slower and not wrong. - */ - async function withOAuthClientLock( - serverId: string, - work: (transaction: Transaction) => Promise, - ): Promise { - return await database.transaction(async (transaction) => { - await transaction.execute( - sql`select pg_advisory_xact_lock(hashtext(${`oauth-client-${serverId}`}))`, - ); - return await work(transaction); - }); - } - - /** - * {@link storedOAuthClient}'s question, asked on the caller's own transaction. - * - * The same question deliberately — the server row's pointer, and the row it names being live — - * because the callback redeems against `oauthClientFor`, which reads exactly that. A read that - * accepted a live client the server row does NOT name would hand somebody a consent screen for a - * client the callback then cannot find, and the connect would fail after the vendor said yes. - * - * On the transaction rather than through `storedOAuthClient` because the caller is inside - * {@link withOAuthClientLock} and holding a pooled connection: a read on a second connection would - * be a session queueing behind sessions that cannot finish until it returns. - */ - async function heldOAuthClient( - transaction: Transaction, - serverId: string, - ): Promise { - const [server] = await transaction - .select({ credentialId: mcpServers.credentialId }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!server?.credentialId) return null; - - const [held] = await transaction - .select({ - encryptedValue: credentialRows.encryptedValue, - revokedAt: credentialRows.revokedAt, - }) - .from(credentialRows) - .where(eq(credentialRows.id, server.credentialId)) - .limit(1); - if (!held || held.revokedAt) return null; - - try { - const parsed: unknown = JSON.parse( - await decryptSecret(encryptionKey, held.encryptedValue), - ); - // A value that parsed and is not a client is unreadable in the same way and for the same - // caller — see {@link isUsableClient}. Null, because that is what this reader's caller acts - // on: it goes and registers one, which is the answer to holding none. - return isUsableClient(parsed) ? parsed : null; - } catch { - // Unreadable is the same as none: there is nothing to send anybody to consent with. - return null; - } - } - - /** - * The two writes that store a client, on one transaction: the vault row, and the pointer to it. - * - * One transaction because they are one decision. Separately, a failure between them leaves - * `mcp_servers.credential_id` naming the credential the rotation had just revoked — a connector - * that looks configured on every screen and cannot complete a consent flow. - * - * The caller is expected to hold {@link withOAuthClientLock}, which is what makes the read below - * safe to act on. - */ - async function writeOAuthClient( - input: { serverId: string; client: OAuthClient }, - transaction: Transaction, - ): Promise<{ replaced: boolean }> { - const key = oauthClientKey(input.serverId); - const value = { - ...key, - metadata: { server: input.serverId, clientId: input.client.clientId }, - encryptedValue: await encryptSecret( - encryptionKey, - JSON.stringify(input.client), - ), - }; - - const live = await credentials.findLiveByKey(key, transaction); - const stored = live - ? await credentials.rotate( - { ...value, previousCredentialId: live.id }, - transaction, - ) - : await credentials.create(value, transaction); - - await transaction - .update(mcpServers) - .set({ credentialId: stored.id, updatedAt: new Date() }) - .where(eq(mcpServers.id, input.serverId)); - - return { replaced: live !== null }; - } - - /** - * The trail row for a client this deployment now holds. - * - * Written AFTER the transaction that stored it, not inside. The audit store has its own handle on - * the database, so writing from inside would open a second pooled connection while the first is - * held — the shape the pool note in `db/client.ts` warns about, and the one that turns a busy - * deployment into a hang. A trail row lost to a crash in that window is a worse trade than a - * deadlock, but only just, and this way round the client is at least the thing that is certain. - */ - async function recordClientRegistered( - input: { serverId: string; client: OAuthClient; by: string }, - replaced: boolean, - ): Promise { - await recordAuditEvent(auditStore, { - eventType: "mcp.oauth_client_registered", - targetType: "mcp_server", - targetId: input.serverId, - payload: { - actor: input.by, - server: input.serverId, - // The id, never the secret. It identifies the client that was registered, which is what - // somebody reading the trail needs in order to check it against the vendor's console. - clientId: input.client.clientId, - replaced, - }, - }); - } - - /** - * Store the deployment's OAuth client for a `user-oauth` server, whoever obtained it. - * - * Both halves go into one encrypted value, so a single vault read yields a usable client. The id is - * copied into `metadata` as well — it is not a secret, and a page listing what the deployment holds - * should be able to name it without decrypting anything. - * - * Replacing a client revokes the previous one rather than orphaning it, so "what does this - * deployment hold" keeps having one answer per server. Nobody's connection breaks in the sense that - * matters here — a refresh token is the person's — but nobody's connection SURVIVES either: a grant - * belongs to the client it was issued to, so replacing the client is asking everybody to connect - * again. That is why the two callers that replace one both say so to whoever is listening. - * - * Shared by an administrator pasting one in and by the deployment registering its own, so `by` is - * the only difference between the two in the trail — which is the honest one. - */ - async function persistOAuthClient(input: { - serverId: string; - client: OAuthClient; - by: string; - }): Promise { - const { entry } = await requireServer(input.serverId); - if (entry?.auth.kind !== "user-oauth") { - throw new CustomServerRefusedError( - `${input.serverId} is not reached with an OAuth client.`, - ); - } - - const { replaced } = await withOAuthClientLock( - input.serverId, - (transaction) => - writeOAuthClient( - { serverId: input.serverId, client: input.client }, - transaction, - ), - ); - - await recordClientRegistered(input, replaced); - } - - /** - * The deployment's OAuth client for a server as it stands, or null if there is none to read. - * - * Decrypted, because both halves are needed: the id to build a consent URL and the secret to - * redeem the code it comes back with. Held for the length of one request, like every other secret - * this module reads. - */ - async function storedOAuthClient( - serverId: string, - ): Promise { - const [row] = await database - .select({ credentialId: mcpServers.credentialId }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!row?.credentialId) return null; - - try { - const parsed: unknown = JSON.parse( - await decryptCredentialForUse( - encryptionKey, - credentials, - row.credentialId, - ), - ); - // A client that parsed and is not one is as unusable as the revoked or missing row the catch - // below answers for, and is the same none to every caller — see {@link isUsableClient}. - return isUsableClient(parsed) ? parsed : null; - } catch { - // A revoked, missing or unreadable client is the same as none for every caller: there is - // nothing to send anybody to consent with, and the answer is to obtain one again. - return null; - } - } - - /** - * The credential a server is being pointed at is of the kind that server can spend. - * - * Both add paths dereference the pointer before they return, so this is checked where the pointer - * is accepted rather than where it is used. `mcp` is the only kind that answers "this server's own - * token". A `mcp_user_token` is one person's grant and a `mcp_oauth_client` identifies the - * deployment to a vendor; spending either here uses a credential on behalf of somebody who never - * agreed to it, which is the same objection `POST /api/admin/credentials` already makes when it - * refuses to mint those two by hand. - * - * The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a value - * that is not one makes the query itself fail rather than return no rows, and the caller gets a - * database error where a refusal belongs. - * - * One message for both "wrong kind" and "no such credential", deliberately. A caller who can tell - * those apart can ask this endpoint which credential ids are real. - */ - async function requireCredentialOfKind( - serverTitle: string, - serverId: string, - credentialId: string, - kind: "mcp" | null, - ): Promise { - /* - * A server that takes no credential when it is added is refused here rather than at the caller, - * so that offering an id is one question with one answer wherever it is asked. The wording says - * what is true of both kinds that reach it: a `user-oauth` server's client arrives through the - * call that mints it, and a server needing no credential has nothing to be given. - */ - if (!kind) { - throw new CustomServerRefusedError( - `${serverTitle} takes no credential when it is added.`, - ); - } - - const looksLikeId = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - credentialId, - ); - /* - * Live, as well as the right kind and the right owner. - * - * A revoked credential cannot be decrypted, so attaching one only ever produced a server that - * fails on its next call. Refusing it here says so at the moment somebody can still act on it, - * and it closes the case where a token was retired precisely because it should stop being used. - */ - const [named] = looksLikeId - ? await database - .select({ - kind: credentialRows.kind, - provider: credentialRows.provider, - }) - .from(credentialRows) - .where( - and( - eq(credentialRows.id, credentialId), - isNull(credentialRows.revokedAt), - ), - ) - : []; - - /* - * Whose it is, as well as what it is. - * - * `provider` is the server a token was minted for: `storeMcpToken` sets it to the server id and - * is the only way the plugins screen makes one. Without this, any `mcp` row in the vault could - * be attached to any server, and since the refresh spends it against that server's address, a - * token given to one vendor was deliverable to another. Reading a credential back is otherwise - * impossible by design, so this closes the one field that accepts a reference to a secret rather - * than the secret itself. - */ - if (named?.kind !== kind || named.provider !== serverId) { - throw new CustomServerRefusedError( - "That is not a credential this server can use. Add the server's own token instead.", - ); - } - } - - /** - * A brokered app's row is not something another add path may write through. - * - * CRITERION. `addServer` and `addCustomServer` refuse outright when the id they are about to - * upsert already holds a row {@link accessFor} answers `brokered` for. Both of those paths write a - * url of their own choosing, and neither may write one over an app somebody is connected to. - * - * REASON. The url is the ONLY place the app slug is written down. `connectionTokenFor` reads it to - * decide whose account a call runs in, `removeServer` reads it to find the accounts to end at - * Composio, and `retireConnectionsFor` finds a person's rows by the same string — so the moment - * another path rewrites that url, every `composio_connections` row behind it stands for an app - * nothing in this deployment can name any more. Consent that no operation can withdraw is the one - * state this feature must not reach, and it is reachable by two ordinary administrative acts. - * - * REFUSED RATHER THAN REPAIRED, WHICH IS NOT THE ANSWER THE OTHER DIRECTION GETS. - * {@link addBrokeredApp} converts a row it lands on, because there the url it writes is the app - * and the row comes out saying so. Here the opposite is true: writing `provenance = "custom"` - * alongside the new url would make the row self-consistent and lose the accounts just the same. - * Consistent and orphaned is no better than contradictory and orphaned, so there is nothing to - * write — only an act to decline. - * - * AND THE ADMINISTRATOR IS LEFT A REAL STEP. Removing the app ends every account at the vendor on - * the way out and takes the row with it, after which the name is free for an endpoint. The - * refusal says that, because the alternative reading — "this name is taken forever" — is not true - * and would send somebody editing the database. - * - * ASKED WITH NO ENTRY, the reading {@link removeServer} uses, and for its reason: an entry can - * only ever SUPPRESS the brokered answer, so passing one a colliding id looked up would hide the - * brokered state of the very row most in need of protecting. - */ - function requireNotBrokered( - serverId: string, - existing: - | { provenance: string; url: string; authScheme: string | null } - | undefined, - ) { - if (!existing) return; - if (accessFor(existing, null).credential !== "brokered") return; - throw new CustomServerRefusedError( - `${serverId} is a Composio app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at Composio — and the name is then free.`, - ); - } - - async function requireServer(serverId: string) { - const [row] = await database - .select() - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .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 - // deployment still had it added. Refused rather than reached: the pinned host that made it - // admissible no longer exists to check against, so there is nothing left that says this URL - // is one we agreed to talk to. - throw new CatalogueEntryUnknownError(row.id); - } - /* - * 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. - * - * AND RESOLVED AGAINST THE APP'S ACCOUNT COLUMNS, NOT THE DIALLED ROW'S. `reachedAs` is read off - * the scheme and the mode, both of which belong to the app — see {@link BrokeredAppRow} — so a - * brokered row is handed to {@link accessFor} carrying the answering row's two columns. Done - * here rather than at each consumer because this is the one resolution every store path reads, - * which is fewer places to remember than the callers; `listServers` reports the same two columns - * off the same row for the one reader outside the store, the memory ingestion. `app` goes back - * with it, so the audience gate decides on and keys its approval by the row that answers rather - * than the one that was dialled. Null for every row that is not a brokered app. - */ - const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; - const app = toolkit ? await brokeredAppRow(toolkit) : null; - const access = accessFor( - app - ? { ...row, authScheme: app.authScheme, accountMode: app.accountMode } - : row, - entry, - ); - return { row, entry, access, app }; - } - - /** - * What a row that answers for an app is asked for: which row it is, how the app connects, and - * whose account it runs in. - * - * THE ACCOUNT COLUMNS TRAVEL WITH THE SCHEME FOR THE SCHEME'S OWN REASON. Whether an app is Shared - * and which deployment identity it holds are facts about the APP, exactly as its scheme is, and a - * duplicate row at the same url is not a second app. Read off whichever row a call was dialled - * through, a duplicate recording `personal` at a Shared app's url skipped the audience gate and - * let anybody connect a personal account to the team's app, and one recording `shared` at a - * Personal app's url sent a person's call out as the deployment. Every account decision — - * {@link accountRefFor}, the audience gate in `callTool`, the trail's `reachedAs`, the listing — - * now reads these from here, so none of them can be about a different row than the others. - */ - type BrokeredAppRow = { - id: string; - authScheme: string | null; - accountMode: AccountMode | null; - sharedVendorUserId: string | null; - }; - - /** - * The one `mcp_servers` row that answers for the app a url names: its id, and its scheme. - * - * 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. A row called `gmail` at `composio://slack` would - * have somebody's Slack key attached to a scheme read off Gmail's row — and, through the id this - * also answers, a probe chosen off Gmail's action list and spent on their Slack key. That stake is - * shared by every caller: {@link connectBrokeredWithFields}, {@link recheckBrokeredConnection}, - * {@link confirmBrokeredConnection} and {@link disconnectBrokered} read the scheme, - * {@link probeBrokeredConnection} reads the id. - * - * AND ORDERED, BECAUSE THE URL IS NOT A KEY. `mcp_servers.url` has no unique index behind it, so - * two rows may name one app and `limit(1)` over them is the planner's choice rather than an - * answer. Each caller used to take it unordered and separately: the same person, the same app, and - * five readings free to disagree with each other and with themselves between two page loads. What - * that costs is a live key connection refused in words about a sign-in screen nobody used — - * "connect it the way it asks for", over an app connected exactly the way it asked — and, on the - * id, a Re-check the listing offers off the app's own row whose press resolves to the OTHER row, - * finds no action published there and reports that nothing could be tried: the `checkable`/`probe` - * deadlock, reached through the duplicate rather than through a composed id. - * - * SO THE READ IS ONE FUNCTION AND NOT A RULE EACH CALLER REPEATS. The lower id answers, which is - * the rule {@link brokeredConnectionsFor} names the app by, so the row the page shows an app under - * is the row every one of these reads off — and a sixth caller cannot re-derive the choice - * differently, because there is nothing here to re-derive. - * - * NULL FOR AN APP WITH NO ROW AT ALL, and that is an answer rather than a gap: a person can hold - * an account at an app this deployment has since removed, nothing names the scheme it was - * connected under any more, and every caller treats the null as "not an app we hold a key for" — - * or, for the probe, as "there is no app left to check". - */ - async function brokeredAppRow( - toolkit: string, - ): Promise { - const url = `composio://${toolkit}`; - return (await brokeredAppRowsAt([url])).get(url) ?? null; - } - - /** - * The same answer for several urls at once, and the ONE PLACE the ordering rule is written. - * - * THE RULE IS SQL'S `order by id` AND THE FIRST ROW SEEN PER URL, which is what - * {@link brokeredAppRow} asks for one app and what {@link brokeredConnectionsFor} and - * {@link listServers} ask for many. Those three had their own spellings of it, and one of them — - * the listing — spelled it as a JavaScript `<` over rows it had already fetched. That is UTF-16 - * code unit order; this is the deployment's collation. They agree for ASCII on a `C` database and - * are free to disagree anywhere else, and where they disagreed the settings page named an app - * under one row while every read behind its buttons was about another. So the rule is a function - * and the callers have nothing left to re-derive. - * - * A URL WITH NO ROW IS SIMPLY ABSENT from the map, which is the null {@link brokeredAppRow} - * answers and the connection {@link brokeredConnectionsFor} drops: a person can hold an account - * at an app this deployment has since removed, and there is no row to name it by. - * - * EMPTY IN, EMPTY OUT AND NO QUERY, because `inArray` with no values is a statement no database - * needs to be asked. - */ - async function brokeredAppRowsAt( - urls: string[], - ): Promise> { - const answering = new Map(); - if (urls.length === 0) return answering; - const rows = await database - .select({ - id: mcpServers.id, - url: mcpServers.url, - authScheme: mcpServers.authScheme, - accountMode: mcpServers.accountMode, - sharedVendorUserId: mcpServers.sharedVendorUserId, - }) - .from(mcpServers) - .where(inArray(mcpServers.url, urls)) - .orderBy(asc(mcpServers.id)); - for (const { url, ...row } of rows) { - if (!answering.has(url)) answering.set(url, row); - } - return answering; - } - - /** {@link brokeredAppRow}'s scheme, for the callers that ask only what the app is connected with. */ - async function brokeredAppScheme(toolkit: string): Promise { - return (await brokeredAppRow(toolkit))?.authScheme ?? null; - } - - /** - * What that scheme decides, which is what every caller actually branches on. - * - * ONE CLASSIFICATION OVER ONE ROW, and the second half of what {@link brokeredAppRow} is for. - * That function ends the disagreement about WHICH ROW answers for an app; this one ends the - * disagreement about what its column MEANS. They were separate questions and were answered - * separately: four callers each asked {@link isFieldScheme} and treated everything else — a - * consent scheme, a literal from another deployment, a null — as one answer, and only three of - * them could survive being wrong about it. The confirm is the fourth, and it WRITES. - * - * SO THE THIRD ANSWER TRAVELS, rather than being flattened at the call site. See - * {@link SchemeKind}: `unreadable` is what a caller needs in order to fail closed, and a boolean - * cannot carry it. - */ - async function brokeredAppKind(toolkit: string): Promise { - return schemeKind(await brokeredAppScheme(toolkit)); - } - + const context = createStoreContext(options); + // In the order the methods were always declared. Each group's methods call each other through + // `this`, which is the whole store when a method is called on it. return { - accountRefFor, - /** - * Add a server from the catalogue. - * - * The URL is resolved from the catalogue rather than accepted from the caller, so the only thing - * a person can influence is which entry and, for a per-instance vendor, their own instance - * hostname, which is then checked against that vendor's anchored pattern before anything is - * stored. - */ - async addServer(input: { - key: string; - instanceHost?: string; - credentialId?: string; - by: string; - }): Promise { - const resolved = resolveServerUrl(input.key, input.instanceHost); - if (!resolved) throw new CatalogueEntryUnknownError(input.key); - - /* - * The pointer is checked here for the same reason it is on the path below: the refresh that - * runs before this returns dereferences whatever it names. - * - * What that reaches is narrower on this path, because the URL is the catalogue's rather than - * the caller's, so a credential cannot be delivered to an address somebody chose. That is a - * property of today's catalogue rather than of this function: the one entry it holds is - * `user-oauth`, and the catalogue's own comment invites a fork to re-add the vendors that were - * taken out. The first `deployment-bearer` entry restores the full shape, so the check belongs - * here now rather than in the review that re-adds one. - */ - const credentialId = input.credentialId?.trim() || undefined; - if (credentialId) { - await requireCredentialOfKind( - resolved.entry.title, - resolved.entry.key, - credentialId, - serverCredentialKind(resolved.entry), - ); - } - - /* - * Not over a brokered row. See {@link requireNotBrokered} for what such a row would lose. - * - * Unreachable from the shipped product as it stands — `addBrokeredApp` mints `composio-` - * and no catalogue entry is spelled that way — and asked anyway, because the row the check is - * about is by definition one that arrived some other way: a hand edit, a restore, a build - * whose catalogue named an app a past build brokered. This path writes the catalogue's url - * over whatever is there, which is exactly the write that strands the accounts. - */ - const [existing] = await database - .select({ - provenance: mcpServers.provenance, - url: mcpServers.url, - // Part of what `accessFor` resolves a row with — see there. Nothing on this path reads - // the answer's `reachedAs`, but the column travels with the other two so no caller has - // to know which of the four answers the one it wants depends on. - authScheme: mcpServers.authScheme, - }) - .from(mcpServers) - .where(eq(mcpServers.id, resolved.entry.key)); - requireNotBrokered(resolved.entry.key, existing); - - await database - .insert(mcpServers) - .values({ - id: resolved.entry.key, - title: resolved.entry.title, - vendor: resolved.entry.vendor, - url: resolved.url, - credentialId: credentialId ?? null, - addedBy: input.by, - }) - .onConflictDoUpdate({ - target: mcpServers.id, - set: { - url: resolved.url, - /* - * THE WHOLE IDENTITY THE CATALOGUE DECIDES, not the url alone. - * - * These three columns and the url are one statement — this row is that reviewed entry — - * and an update that moved one of them and left the rest was a row describing two - * different servers at once. `provenance` is read by `requireServer`, which refuses a - * `first-party` row with no entry, and by `accessFor`, which reads it whenever there is - * no entry to overrule it; `vendor` is what the first-party rule is checked against. - * Left behind, a row that arrived by another path kept saying so at an address only the - * catalogue chose — so every surface that asks how a server got here answered with the - * way it USED to get here. - * - * The entry wins over the row everywhere else too (see `accessFor`), so this is that - * same order written down at the one moment the row is being established rather than - * read. - */ - title: resolved.entry.title, - vendor: resolved.entry.vendor, - provenance: "first-party", - /* - * Left alone when the caller sends none, rather than cleared. - * - * `registerOAuthClient` keeps the client it minted in this column, and adding the server - * again to change an instance host is not a statement about that client. Clearing it - * orphaned the credential row, which nothing then revokes, and told everybody who had - * connected that the deployment has no OAuth client registered. There is no longer a way - * to hand it back through this call either, since a `user-oauth` entry now refuses a - * credential id, so the pointer has to survive here. - */ - ...(credentialId ? { credentialId } : {}), - addedBy: input.by, - updatedAt: new Date(), - }, - }); - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: resolved.entry.key, - payload: { - actor: input.by, - change: "mcp_server_added", - server: resolved.entry.key, - url: resolved.url, - }, - }); - - // Refreshed immediately so the page that added it can show what it offers, and so a bad - // credential is reported now rather than the first time a Bot tries to use it. - await this.refreshTools(resolved.entry.key); - const servers = await this.listServers(); - const added = servers.find((server) => server.id === resolved.entry.key); - if (!added) throw new CatalogueEntryUnknownError(input.key); - return added; - }, - - /** - * Add a server that is not in the catalogue, by URL. - * - * The administrator's path is different from pressing Add on a curated entry. That - * one picks a reviewed vendor at a pinned host; this one points the deployment at an address - * somebody typed. Both are useful and only one of them can be reviewed in advance, so this one - * is guarded at the URL, recorded with its provenance, and every tool it offers is treated as a - * write because nothing here knows otherwise. - */ - async addCustomServer(input: { - id: string; - title: string; - url: string; - credentialId?: string; - by: string; - }): Promise { - const refusal = customUrlRefusal(input.url); - if (refusal) throw new CustomServerRefusedError(refusal); - - // A custom server may not take a curated entry's slug. The slug prefixes tool names and is - // what a grant and a policy rule are written against, so allowing a shadow would let a custom - // server inherit rules an operator wrote about the vendor. - if (catalogueEntry(input.id)) { - throw new CustomServerRefusedError( - `${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.", - ); - } - - /* - * NOR THE NAMESPACE THE BROKERED ROWS ARE MINTED IN, which is the reservation above one step - * further out. - * - * CRITERION. No server added by URL may take an id beginning `composio-`, whether or not a row - * is sitting there today. - * - * REASON. {@link addBrokeredApp} composes its id as `composio-` from the app an - * administrator enabled, so that space is already spoken for by a path that writes - * `composio://` urls into it. Unreserved, the two paths write one row: enabling an app over a - * typed endpoint, or typing an endpoint over an app somebody is connected to. The second of - * those is the one that cannot be undone — see {@link requireNotBrokered} — and this is what - * stops either from arising in the first place, rather than catching them one row at a time. - * - * A PREFIX RATHER THAN A LOOKUP OF WHAT IS ENABLED TODAY. "Is there a brokered row at this id" - * is a question whose answer changes: an app removed this morning frees the name, and the next - * press of Add takes it back from whoever typed it in between. The namespace is what a reader - * of a grant or a policy rule can rely on without asking the database what year it is. - * - * It is the same objection the curated-slug refusal above makes, and the same one - * `addBrokeredApp` records for its own id: the id prefixes every tool name and is what a grant - * and a policy rule are written against, so a row shadowing another path's namespace inherits - * rules that were written about something else. - */ - if (input.id.startsWith("composio-")) { - throw new CustomServerRefusedError( - `Names beginning composio- belong to the Composio apps this deployment enables, so ${input.id} is not a name a server added by URL can take. 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.", - ); - } - - /* - * The pointer is checked here because the add is what dereferences it. - * - * `refreshTools` runs before this method returns, and for a custom server there is no - * catalogue entry, so `connectionTokenFor` decrypts whatever `credential_id` names and - * `listTools` sends it to the URL from this same request. An unchecked pointer therefore is - * not "a wrong token later", it is this call delivering that secret to an address the caller - * chose, before any grant, policy check or Bot exists. - * - * `mcp` is the only kind that answers "this server's own token". A `mcp_user_token` is one - * person's grant and a `mcp_oauth_client` identifies the deployment to a vendor; neither is - * this deployment's bearer token for this server, and spending either here would be using a - * credential on behalf of somebody who never agreed to it. `POST /api/admin/credentials` - * already refuses to mint those two by hand for that reason, and its comment says so; this is - * the same objection at the point they are referenced rather than created. - * - * One message for both "wrong kind" and "no such credential", deliberately. A caller who can - * tell those apart can ask this endpoint which credential ids are real. - */ - /* - * A credential is spent at the address it was given to, or not spent. - * - * Adding a server that is already here rewrites its URL, and the refresh that follows sends - * whatever credential it holds to the new one, in the same call. That is the same disclosure - * as naming another server's token and it needs no trick at all: the token really does belong - * to this server, and only the address moved. A check on whose credential it is cannot see it, - * which is why this rule is here and not folded into that one. - * - * Refused rather than repaired, because the two harmless readings of the request are both - * served by something else. Correcting a title or retrying an interrupted add sends the same - * URL and is unaffected, and genuinely moving a server means the vendor is at a new address, - * where the honest act is to remove it and add it again with the token that address is - * supposed to hold. - * - * Only this path. A curated server's URL comes from the catalogue rather than the request, so - * the most a caller can influence is an instance hostname, and that is matched against the - * vendor's own anchored pattern before anything is stored. Re-adding one cannot point it at an - * address of the caller's choosing, which is the whole of what this refuses. - */ - const credentialId = input.credentialId?.trim() || undefined; - const [existing] = await database - .select({ - url: mcpServers.url, - credentialId: mcpServers.credentialId, - // Read for the refusal below. The namespace rule above already keeps this path away from - // every id `addBrokeredApp` mints; this is the same rule asked of the ROW, which is what - // covers one that arrived before the namespace was reserved, or by restore. - provenance: mcpServers.provenance, - // The third of what `accessFor` resolves a row with. See there. - authScheme: mcpServers.authScheme, - }) - .from(mcpServers) - .where(eq(mcpServers.id, input.id)); - - /* - * Before the address rule below, because it is the stronger statement about the same write. - * - * That one is about a credential being carried to an address, and it lets an add through when - * no token is involved. This one is about the address ITSELF being the only record of which - * app a set of connections belongs to, and no absence of a credential makes that write - * survivable. See {@link requireNotBrokered}. - */ - requireNotBrokered(input.id, existing); - - if ( - existing && - existing.url !== input.url && - (existing.credentialId || credentialId) - ) { - throw new CustomServerRefusedError( - `${input.id} is already here at a different address and holds a credential. Remove it and add it again, with the token the new address is meant to have.`, - ); - } - - if (credentialId) { - // Always `mcp`: a server added by URL is reached with the one token the deployment holds for - // it, whatever the vendor is, because nothing here knows the vendor. - await requireCredentialOfKind( - input.title, - input.id, - credentialId, - "mcp", - ); - } - - await database - .insert(mcpServers) - .values({ - id: input.id, - title: input.title, - vendor: new URL(input.url).hostname, - url: input.url, - provenance: "custom", - credentialId: credentialId ?? null, - addedBy: input.by, - }) - .onConflictDoUpdate({ - target: mcpServers.id, - set: { - title: input.title, - url: input.url, - /* - * WHAT THE ROW NOW IS, written beside the address that made it that. - * - * `vendor` is derived from the url on the way in, so leaving it behind while the url - * moved left the column naming a host this row no longer addresses — and it is what the - * first-party rule is checked against, not a caption. `provenance` is the same fact one - * level up: a row whose entry a build removed still reads `first-party`, and - * `requireServer` refuses exactly that shape as a vendor it can no longer check a pinned - * host for. Adding it by URL is what makes it a typed address rather than a reviewed - * one, so this is the act that settles the column. - * - * The one conversion this cannot make is out of a brokered row, which is refused above - * rather than written here: there the url is the only record of which app a set of - * consents belongs to, so a rewrite loses them whatever else is written alongside. - */ - vendor: new URL(input.url).hostname, - provenance: "custom", - /* - * Kept when the caller names none, rather than cleared, for a reason beyond tidiness. - * - * Clearing it left the credential live with nothing pointing at it, and `removeServer` - * retires a token by reading it off the row: with the pointer gone it revoked nothing - * and deleted the server, so the token outlived the server it was minted for. It could - * then be attached to a freshly created server at any address, because the rule above - * compares against a row that no longer existed. Three ordinary acts, and the address - * this server was entrusted to stopped meaning anything. - * - * So the pointer survives, `removeServer` finds it, and a removed server's token is - * dead rather than loose. Detaching a token without removing the server is not a thing - * this endpoint does, and nothing asks it to. - */ - ...(credentialId ? { credentialId } : {}), - addedBy: input.by, - updatedAt: new Date(), - }, - }); - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: input.id, - payload: { - actor: input.by, - change: "mcp_server_added", - server: input.id, - url: input.url, - // Named in the trail, because "who added a server nobody reviewed" is a question somebody - // will ask and the answer should not require reading the catalogue of a past build. - provenance: "custom", - }, - }); - - await this.refreshTools(input.id); - const added = (await this.listServers()).find( - (server) => server.id === input.id, - ); - if (!added) throw new CatalogueEntryUnknownError(input.id); - 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; - logo?: string | null; - by: string; - /** - * How this app connects, resolved from the catalogue row the administrator chose. - * - * Taken rather than derived here, because the caller has already read it off that row and a - * second derivation is a second answer — the one thing {@link BrokerConnection} exists to - * prevent. It decides what config is created at the vendor, and it is what this row records. - */ - connection: BrokerConnection; - }): Promise { - // Before anything at all. A deployment with no key has no catalogue for this app to have been - // chosen from, so there is nothing here to half-do and nothing to say but the setting. - if (!broker) throw new BrokerUnconfiguredError(); - - const url = `composio://${input.slug}`; - if (toolkitOf(url) !== input.slug) { - throw new CustomServerRefusedError( - `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, - ); - } - - /* - * WHAT THE ROW ALREADY SAYS, READ BEFORE ANYTHING IS WRITTEN, because after the upsert this - * question cannot be asked any more — the insert below would have answered it. - * - * It is the scheme the app's ANSWERING row records, which is what every brokered reader - * resolves and is not always the row the upsert names. See {@link brokeredAppRow}. - */ - const recorded = await brokeredAppScheme(input.slug); - - const configured = await broker.ensureAuthConfig({ - toolkit: input.slug, - name: input.title, - connection: input.connection, - }); - - /* - * THE SCHEME THIS ENABLE MAY WRITE DOWN, WHICH IS NOT ALWAYS THE ONE THE CATALOGUE RESOLVES. - * - * CRITERION. What this row records is what the authorization config standing at Composio was - * created as — never what today's catalogue says the app could be connected with. - * - * REASON. {@link ComposioBroker.ensureAuthConfig} reuses a config of ours whatever scheme it - * holds, and it used to say nothing about having done so, so every path here wrote the - * catalogue's answer. An app enabled while Composio published only a key, and later given - * managed OAuth, came out of a second press of Add recording `OAUTH2` beside a config that is - * still `API_KEY`: `brokeredAppKind` then says `consent`, so `connectBrokeredWithFields` - * refuses every submission while `authorize` mints consent links against a key config — the - * exact "sent to a consent screen that had nothing to ask them for" failure that method's own - * comment describes. And pressing Add again could never repair it, because the reuse is what - * caused it. - * - * `standing` IS THEREFORE THE ONE ANSWER THAT KEEPS WHAT WAS THERE, and it falls back to the - * catalogue only where nothing was recorded at all — a row restored without the column, or an - * app whose config was made before this deployment recorded one. A guess is the best available - * answer there and it is no worse than what stood before; everywhere else the recorded word - * wins. - * - * AND `created` MAY WRITE FREELY, connections or no connections: the config those connections - * were made against is gone from Composio — that is why one had to be created — so the new - * scheme is the only true thing to record about the app. - */ - const scheme = - configured === "standing" - ? (recorded ?? schemeFor(input.connection)) - : schemeFor(input.connection); - - /* - * THE COMPOSED NAME IS SPELLED WHERE IT IS MINTED AND IS GIVEN NO BINDING TO BE REUSED FROM. - * - * It used to be `const id`, and everything below this statement then had a plausible-looking - * server id to reach for — which is how four review rounds each moved one site onto the - * resolved row and left the next one standing. See {@link _EnableNamesTheRow}: after this - * statement there is exactly one id in scope, and it is the answering row's. - */ - await database - .insert(mcpServers) - .values({ - id: `composio-${input.slug}`, - title: input.title, - logo: input.logo ?? null, - // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule - // is checked against, and Composio is who this deployment is actually talking to. - vendor: "Composio", - url, - provenance: "composio", - // Nothing for the vault to hold. A brokered call runs as the person asking, on their own - // connection at the vendor, which is a `composio_connections` row rather than a secret. - credentialId: null, - /* - * What the config standing at Composio was created AS, which is the scheme every later - * connection against it has to keep using. - * - * THE RESOLVED ONE AND NOT THE CATALOGUE'S, WHICH THIS BRANCH USED TO WRITE UNGUARDED. - * The write-once rule below protects only the UPDATE, and this INSERT fires exactly when - * no row stands at the composed id — which is not the same as no row standing for the - * APP. In the two-rows-at-one-url state this method is built around, a restored or - * hand-added `gmail` at `composio://gmail` holding `API_KEY` and live key connections is - * untouched by the guard: the insert at `composio-gmail` fired, wrote `OAUTH2`, and - * `composio-gmail` sorts first, so `brokeredAppRowsAt` made the new row the answering - * one. `confirmBrokeredConnection` then classified the app `consent`, saw `verified: - * false` on a key row, and wrote `verified: true, probeAction: null` over it — erasing a - * recorded failed check on the next page load. See the derivation of `scheme` above. - */ - authScheme: scheme, - /* - * PERSONAL ON A NEW ROW, AND NULL WHERE THERE IS NO ACCOUNT TO HOLD. - * - * A freshly enabled app is reached through each asking person's own account until an - * administrator deliberately switches it to Shared — sharing one account across everyone - * a Bot reaches is a decision somebody makes, never a default this method makes for them. - * A `no-auth` app has no account at all, so it has no mode to choose between, and the - * column says so rather than claiming one; see {@link mcpServers.accountMode}. - * - * WRITTEN ON THE INSERT ONLY. The update below is the branch a second press of Add takes, - * and an app an administrator has already made Shared must not be quietly turned back - * into Personal by it. - */ - accountMode: input.connection.kind === "no-auth" ? null : "personal", - addedBy: input.by, - }) - .onConflictDoUpdate({ - target: mcpServers.id, - set: { - title: input.title, - logo: input.logo ?? null, - url, - /* - * WRITTEN BESIDE THE URL, BECAUSE THE TWO ARE ONE FACT AND A ROW HOLDING HALF OF IT IS - * NOT A SERVER AT ALL. - * - * CRITERION. Every row this method leaves behind says `composio`, whatever it said - * before. The url above and this column are what `accessFor` reads to answer that a call - * is brokered and which app it is against, and no path may write one without the other. - * - * REASON. This branch rewrote the url and left `provenance` standing, so enabling an app - * at an id a custom server already held produced a row reading `custom` at a - * `composio://` address. That is not a display inconsistency: `accessFor` answers - * `deployment-token` for it, so the transport dialled the row as an ordinary MCP - * endpoint on the deployment's own credential while the Composio screen went on - * attaching people's real accounts to the app its url named. The per-person gate was not - * weakened but SKIPPED — `connectionTokenFor` only ever asks for a connection down the - * brokered branch — which is the whole property this connector exists for. And - * `removeServer` finds the accounts to end at the vendor through that same answer, so it - * revoked nothing and reported the connector gone. - * - * A CONVERSION HERE, A REFUSAL IN THE OTHER DIRECTION, and the asymmetry is the point. - * What this method writes IS the app — the auth config stands at Composio before the row - * is touched, and the url names the app the connections will be keyed on — so a row it - * lands on comes out saying exactly what it now is, with nothing lost. Going the other - * way, the url being overwritten is the only record of which app a set of consents - * belongs to, and no column written alongside brings it back; see - * {@link requireNotBrokered}. - * - * `vendor` for the same reason one notch quieter: it is what the first-party rule is - * checked against, and a row reached over the broker whose vendor column still names the - * host somebody typed is answering that rule about a server this deployment no longer - * dials. - */ - provenance: "composio", - vendor: "Composio", - addedBy: input.by, - updatedAt: new Date(), - /* - * `credential_id` is neither written here nor cleared here. - * - * Every row this method creates has none and no path in this module attaches one, so - * there is nothing for an enable to set. Clearing it anyway would matter in the single - * case it could apply — a pointer that arrived by hand edit or restore — because - * `removeServer` retires a server's secret by reading it off this column, and a null - * written over it leaves that secret live with nothing left to name it. - */ - /* - * `auth_scheme` is not written here either, and for a neighbouring reason. - * - * This is the branch a second press of Add takes, and the scheme is the one thing on - * the row that live connections depend on rather than merely display. Rewriting it - * here would move them; the statement below rewrites it only where there are none. - */ - }, - }); - - /* - * WHICH ROW THIS DEPLOYMENT WILL ANSWER FOR THE APP WITH, RESOLVED ONCE AND FOR EVERYTHING - * BELOW. - * - * CRITERION. After the upsert, every act of this method — the scheme it records, the actions - * it lists, the row it files on the trail and the record it hands its caller back — is about - * the row {@link brokeredAppRow} names for this app. The composed `composio-` is what - * the statement above MINTS and is a stand-in for nothing. - * - * REASON. `mcp_servers.url` has no unique index, deliberately — see {@link brokeredAppRow} — - * so the row this method names and the row every brokered reader resolves are allowed to be - * different rows, and in the ordinary two-rows-at-one-url state they are. Every act keyed on - * the composed id was then an act performed somewhere nothing looks. FOUR ROUNDS OF REVIEW - * EACH MOVED ONE OF THEM AND LEFT THE NEXT STANDING: the probe was resolved by url, then - * `brokeredAppRow` was introduced and the probe and the confirm routed through it, then the - * scheme WRITE was moved here — and the actions went on being listed under the composed name - * the whole time. That last one is the deadlock the single-row rule exists to close: the - * chooser reads actions by server id, finds none on the answering row, so `checkable` is - * false on every page load, the browser draws no Re-check button, and the one press that - * could earn the app a verdict can never be made. - * - * THE ROW IS RE-READ RATHER THAN ASSUMED, because the upsert may have created it, found it, - * or landed beside an older row that sorts first — and which of those happened is exactly what - * decides the answer. The composed name is respelled as the fallback for the unreachable case - * of a row this method has just written not being found at its own url, which would mean the - * insert above and this read disagree about what was stored; it is respelled rather than held - * in a binding so that nothing below can take it by mistake. - */ - const answering = - (await brokeredAppRow(input.slug))?.id ?? `composio-${input.slug}`; - - /** - * WHICH ROW EACH ACT OF AN ENABLE NAMES, WRITTEN DOWN BECAUSE NOTHING ELSE CAN SAY IT. - * - * Type-only and erased; see {@link Decides}. Every other roster in this file is keyed on a - * vocabulary the compiler already knows — {@link SchemeKind}, {@link BrokeredProbe} — and - * this one is keyed on the acts of a single method, because the drift being pinned is not a - * branch that forgot a member but a SITE that reached for the wrong name. There is no type - * whose inhabitants are "the things an enable does", so the checklist is the union and the - * roster is what forces an answer out of it: a sixth act added to `BrokeredEnableAct` fails - * `tsc` here until somebody says which row it is about, and the two legal answers are a closed - * pair rather than free text so the seam cannot drift in the direction it has drifted four - * times. - * - * `minted` IS THE ANSWER FOR EXACTLY ONE ACT, and that is the whole shape of the rule. The - * upsert has to be keyed on the composed name — it is what converts a row an administrator - * typed at that id into the brokered app it now is, which is a property with its own test — - * and every act after it is about the row the readers read. - */ - type BrokeredEnableAct = - | "upsert" - | "scheme" - | "actions" - | "trail" - | "answer"; - type _EnableNamesTheRow = Decides< - BrokeredEnableAct, - { - upsert: "minted"; - scheme: "answering"; - actions: "answering"; - trail: "answering"; - answer: "answering"; - }, - "minted" | "answering" - >; - - /* - * WRITTEN EXACTLY WHERE A CONFIG WAS MADE TO WRITE IT ABOUT, AND NEVER OVER A STANDING ONE. - * - * 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. - * - * THE GUARD WAS "NOBODY HAS CONNECTED YET" ALONE, AND THAT HALF CANNOT SETTLE IT. It reasoned - * that with no connections there is nothing to strand, so the rewrite is safe and is how an - * operator picks up a vendor's change without removing and re-adding the app. The second - * clause was false, for one reason: {@link ComposioBroker.ensureAuthConfig} REUSES a config of - * ours whatever scheme it holds and creates nothing, so a press of Add does not pick the - * vendor's change up — it records a word beside a config that never moved. An app enabled as - * `API_KEY` and given managed OAuth by the vendor came out of that press recording `OAUTH2` - * against a key config, which makes `connectBrokeredWithFields` refuse every submission while - * `authorize` mints consent links that ask for nothing — and pressing Add again can never - * repair it, because the reuse is the cause. - * - * SO BOTH CLAUSES STAND, AND EACH CLOSES WHAT THE OTHER CANNOT. Nothing connected means no - * connection is moved by the write; a config this call actually CREATED means there is a - * config that was made as the word being written. `standing` writes nothing whatever the - * connection count, and remove-and-re-add is then the one act that genuinely changes an app's - * scheme, because it is the one that deletes the config. - * - * AND IT IS WRITTEN ON THE ROW THAT ANSWERS FOR THE APP, WHICH IS NOT ALWAYS THE ONE THE - * UPSERT NAMED. - * - * CRITERION. After this call, the scheme {@link brokeredAppScheme} answers with for the app is - * the scheme the authorization config standing at Composio was created as. - * - * REASON. The statement was keyed on the composed `composio-` while every reader of this - * column finds the app by its URL. Where those are two rows the write and the reads were about - * different rows: an app enabled with a key that every reader calls a consent app. What the - * person then meets is the connect form refusing them in a sentence about a sign-in screen - * that does not exist for this app, and a Re-check button that will not press. A writer keyed - * on a composed id has not recorded the fact; it has recorded it somewhere nothing looks. - */ - // Anyone at all, of either holder: a deployment's shared account was made against the - // standing config exactly as a person's was, and a rewrite would strand it the same way. - const connections = await database - .select({ app: brokeredConnections.app }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, input.slug), - ), - ) - .limit(1); - - if (configured !== "standing" && connections.length === 0) { - /* - * MODE PICKED FOR THE PERSON ONLY WHERE THIS REWRITE IS WHAT GIVES THE APP AN ACCOUNT AT - * ALL, AND NEVER WHERE ONE WAS ALREADY CHOSEN. A row enabled `no-auth` has `accountMode` - * null — there was nothing to hold a mode about, see the insert above — and if this same - * re-add is what turns it into a key or consent app, it needs the ordinary default an - * insert would have given it, or every later read that asks "personal or shared" finds - * null and has no answer. An administrator who already chose Shared keeps that choice. - */ - const [current] = await database - .select({ accountMode: mcpServers.accountMode }) - .from(mcpServers) - .where(eq(mcpServers.id, answering)) - .limit(1); - const bumpAccountMode = - schemeKind(recorded) === "none" && - schemeKind(scheme) !== "none" && - (current?.accountMode ?? null) === null; - - await database - .update(mcpServers) - .set({ - authScheme: scheme, - updatedAt: new Date(), - ...(bumpAccountMode ? { accountMode: "personal" as const } : {}), - }) - .where(eq(mcpServers.id, answering)); - } - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - /* - * THE ROW A READER WOULD GO AND LOOK AT, which is the one this deployment answers for the - * app with and not the one the upsert happened to key on. Where those differ the composed - * row exists too, so a trail naming it would send somebody to a row whose scheme, whose - * actions and whose Re-check button are all somewhere else — and `url` below names the app - * itself, so nothing about which app was enabled is lost by naming the answering row here. - */ - targetId: answering, - payload: { - actor: input.by, - change: "mcp_server_added", - server: answering, - 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. - * - * ONTO THE ANSWERING ROW, which is the site this whole block is about. `mcp_tools.server_id` - * is what {@link probeActionFor} reads actions by and what {@link listServers} joins them on, - * and every brokered caller hands those the id resolved above — so actions written under the - * composed name are actions nothing in the brokered path can see. - */ - await this.refreshTools(answering); - const added = (await this.listServers()).find( - (server) => server.id === answering, - ); - if (!added) throw new CatalogueEntryUnknownError(answering); - return added; - }, - - /** - * Remove a server, and stop every secret it was reached with being live. - * - * TWO KINDS OF SECRET, and both have to go. The server's own credential is whatever - * `mcp_servers.credential_id` names — a `mcp` bearer token an administrator added, or, for a - * `user-oauth` vendor, the deployment's OAuth client, keyed `oauth-client-`. Nothing - * else revokes it, so leaving it behind means re-adding the same server meets its own abandoned - * row on `credentials_active_key_idx`. - * - * The other kind is every PERSON'S grant for this server, keyed `mcp_user_token` on the server id. - * `mcp_user_credentials` cascades on the server row, so removing the connector used to delete - * every pointer and leave every refresh token live and unreferenced: reachable from no screen, - * revoked by no operation, and still a usable grant at the vendor. "We removed the connector" has - * to be true of the thing that matters, which is the token sitting at the vendor. - * - * Revoked rather than deleted, because the vault keeps revoked rows for audit. - * - * A THIRD KIND OF ACCESS THAT IS NOT A SECRET. A brokered app holds no per-person secret at all - * — Composio keeps the accounts and the deployment sends a user id — so the only thing standing - * between a person and their mailbox is a `composio_connections` row, and that table references - * nothing that would cascade it. Removing the app therefore left every one of them behind, and - * adding the app back turned them live again without anybody being asked. That row goes too — - * and before it goes, the account it stands for is ended at Composio, because clearing the row - * alone shuts a gate and leaves the mailbox attached. The deployment's auth config for the app - * goes last, once nobody is connected to it any more. - * - * The revokes go first. These are writes on two tables and the store exposes no transaction that - * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves - * a server whose secrets no longer work and which removing again will finish off, while - * delete-then-revoke leaves live secrets no server references and no operation can reach. - */ - async removeServer(serverId: string, by: string): Promise { - const [existing] = await database - .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, - // The third of what `accessFor` resolves a row with. See there. - authScheme: mcpServers.authScheme, - }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)); - - /** - * Whether that token is still live, read rather than inferred from a - * thrown error, so a token a previous attempt already revoked, or one - * whose row is gone entirely, is skipped while a database fault still - * propagates and leaves the server row in place to be removed again. - * - * Two queries rather than a join because `mcp_servers.credential_id` is - * `text` and `credentials.id` is `uuid`, so the two columns do not - * compare without a cast. - */ - const [live] = existing?.credentialId - ? await database - .select({ id: credentialRows.id }) - .from(credentialRows) - .where( - and( - eq(credentialRows.id, existing.credentialId), - isNull(credentialRows.revokedAt), - ), - ) - : []; - - if (live) { - await credentials.revoke(live.id); - await recordAuditEvent(auditStore, { - eventType: "credential.revoked", - targetType: "credential", - targetId: live.id, - payload: { - actor: by, - reason: "mcp_server_removed", - server: serverId, - }, - }); - } - - /* - * Every person's grant for this server, read out of the VAULT rather than through the join - * table. - * - * `credentials.provider` holds the server id for an `mcp_user_token`, so the vault can be asked - * directly — which matters because the join row is the thing about to be cascaded away, and a - * grant whose pointer has already gone (a person removed earlier) would otherwise be invisible - * here too. The same argument `retireConnectionsFor` makes from the other direction. - */ - const held = await database - .select({ id: credentialRows.id, keyId: credentialRows.keyId }) - .from(credentialRows) - .where( - and( - eq(credentialRows.kind, "mcp_user_token"), - eq(credentialRows.provider, serverId), - isNull(credentialRows.revokedAt), - ), - ) - .orderBy(asc(credentialRows.keyId)); - - for (const grant of held) { - await credentials.revoke(grant.id); - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: by, - server: serverId, - // Whose it was. `key_id` holds the user id for this kind, and it is the only place left - // to read it from once the join row has been cascaded away. - owner: grant.keyId, - /* - * Not "they disconnected" and not "they were removed": an administrator took the whole - * connector away, and the person did nothing. An auditor asking what happened to their - * access should see which of the three this was. - */ - reason: "mcp_server_removed", - vendorRevocationRequested: false, - }, - }); - } - - /* - * The third kind of access, which is not a secret at all: everybody's brokered connection. - * - * CRITERION. Removing an app must leave nobody holding brokered access to it, so that adding - * it back again grants nothing until each person has consented afresh. - * - * REASON. `composio_connections` is the whole gate on a brokered call and it references - * nothing — not `mcp_servers`, not `users` — so nothing cascaded it and removing the app left - * every row standing. Two ordinary administrative acts, remove and add back, then restored - * everybody's access at a url the second act chose, with nobody asked again and no screen - * saying it had happened. Consent that reattaches by itself is not consent. - * - * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a - * display key and nothing holds it equal to the slug in the url, so a delete by id would clear - * some other app's connections, or none, for the very row shape that gate already refuses to - * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. - * - * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An - * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row - * that has one — so passing the entry a colliding id looks up would hide the brokered state of - * the one row most in need of clearing, and would now refuse outright the very row this method - * exists to get rid of, leaving the collision unremovable. Nothing is dialled here, so there is - * no vendor for an entry to protect; the only question is which app's consent rows this row's - * own url stands for. - * - * Before the server row goes, for the reason the revokes above are: what a failure between - * two writes leaves has to be the recoverable half. A connection cleared with the app still - * present is fixed by removing it again; an app deleted with the connections standing is - * reachable by no operation at all, because the toolkit was only ever readable off its url. - * - * AND THE ACCOUNT IS ENDED AT THE VENDOR, not merely forgotten here. Deleting the row closes - * the gate this deployment owns and does nothing whatever to the account: the person's - * mailbox stays attached at Composio, the grant stays live, and an administrator who pressed - * "remove" was told the connector was gone. So every connected person is revoked through the - * broker first, exactly as {@link disconnectBrokered} revokes for one — the same - * shape at the scale of an app. - * - * REVOKE BEFORE DELETE, ALWAYS, and the argument is the one that method makes. The row is the - * only thing here that names which app this person connected, so a delete that ran first - * would leave a failed revoke with nothing to revoke under: a live grant on somebody's - * mailbox that no operation in this deployment can reach. The other order costs a repeat of - * an administrative act nobody minds repeating. Dead and reachable beats live and - * unreachable. - * - * WHICH MAKES THE FAILURE LOUD. Nothing is caught around the revokes: a broker that will not - * answer ends this method with the rows still standing and the app still present, rather than - * letting it report an ending that did not happen. - * - * THE AUTH CONFIG GOES LAST, after every account is dead and every row is gone, for the same - * reasoning one step out. An orphaned auth config grants nobody anything — it is a shape this - * deployment holds at Composio, not an account — while a live account whose config has - * already been deleted is access that nothing left here can end. - */ - const toolkit = existing ? accessFor(existing, null).toolkit : null; - - if (toolkit) { - /* - * Read before anything is deleted, because the revokes below need the people and the rows - * are where the people are. Sorted, so two removals of the same app revoke in the same - * order and write their trail rows in the same order. - */ - const connected = await database - .select({ - holder: brokeredConnections.holder, - userId: brokeredConnections.userId, - vendorUserId: brokeredConnections.vendorUserId, - }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - ), - ) - .orderBy( - asc(brokeredConnections.holder), - asc(brokeredConnections.userId), - ); - - /* - * BOTH HOLDERS, EACH NAMED AS WHAT IT IS. Removing an app ends every account standing for - * it — every person's own, and the one the deployment holds if the app was ever Shared, - * which may still be standing after a switch back that has not retired it yet. Each is - * revoked as the account its row says it is, under the vendor id the row was filed under, - * so the deployment's account is asked for as the deployment's and never as whoever - * pressed "remove". A person row always carries its user id (the table's holder check), - * so the fallback to the deployment form below is the shape a corrupt row would need, not - * a reading anything here relies on. - */ - const accountOf = ( - connection: (typeof connected)[number], - ): AccountRef => - connection.holder === "person" && connection.userId !== null - ? { - holder: "person", - userId: connection.userId, - vendorUserId: connection.vendorUserId, - } - : { holder: "deployment", vendorUserId: connection.vendorUserId }; - - /* - * What the broker was actually asked for each of them, kept so the trail below records the - * answer rather than the call. False where there is no broker at all: a deployment whose - * key has since been unset can still remove the app, and it could not have been calling it - * either way — but nothing was asked of Composio and the row must not claim otherwise. - */ - const vendorRevocationRequested: boolean[] = []; - for (const connection of connected) { - vendorRevocationRequested.push( - broker - ? await broker.revoke({ account: accountOf(connection), toolkit }) - : false, - ); - } - - await database - .delete(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - ), - ); - - for (const [index, connection] of connected.entries()) { - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - /* - * THE APP, not this row's id, and the same key `retireConnectionsFor` files under. - * - * CRITERION. Every `mcp.account_disconnected` row a brokered connection produces is - * keyed on the app at the broker, whichever act produced it, so one query answers - * what happened to one person's brokered access. - * - * REASON. The two acts that can end such a connection were keyed differently: this - * one on `mcp_servers.id`, offboarding on `composio_connections.toolkit` — which is - * all that row records and all that is left once the server row is gone. Nothing - * holds the two strings equal, so on any renamed row half the trail is filed under a - * name the other half never mentions, and the disagreement is invisible everywhere - * they happen to match. - * - * THE APP IS WHAT WAS CONSENTED TO. The gate is `(toolkit, user_id)`, the delete - * above is by toolkit, and the row outlives the server row entirely; the id is a - * display key that may not exist by the time somebody asks. Which server row was - * removed is not lost — the `configuration.changed` row written below names it. - */ - targetId: toolkit, - payload: { - actor: by, - server: toolkit, - // Whose account this was, by the rule {@link disconnectBrokered} names it by: a - // person's own under their id, the deployment's own as the deployment, never as - // the administrator who removed the app. - owner: - connection.holder === "person" && connection.userId !== null - ? connection.userId - : "deployment", - // The same three-way distinction the vault loop above draws, and the same answer: an - // administrator took the whole app away and the person did nothing. - reason: "mcp_server_removed", - /* - * What was asked of the vendor, not that a call was made — {@link - * ComposioBroker.revoke}'s own answer, passed through. True where an account was - * found and its withdrawal asked for, false where there was none to withdraw or - * where this deployment has no broker to have asked. The value of the field is - * exactly that a reader can tell an account this deployment acted on from one that - * outlives it somewhere else, so a constant here would be worse than none. It says - * "requested" because that is the strongest thing the vendor's answer supports: - * the upstream withdrawal runs as a background job nothing here can poll. - */ - vendorRevocationRequested: - vendorRevocationRequested[index] ?? 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); - } - - const released = await database.transaction(async (transaction) => { - const removed = await transaction - .delete(pluginGrants) - .where( - and( - eq(pluginGrants.kind, "mcp"), - eq(sql`split_part(${pluginGrants.ref}, '/', 1)`, serverId), - ), - ) - .returning({ ref: pluginGrants.ref, agentId: pluginGrants.agentId }); - await transaction.delete(mcpServers).where(eq(mcpServers.id, serverId)); - return removed.sort((left, right) => left.ref.localeCompare(right.ref)); - }); - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: by, - change: "mcp_server_removed", - server: serverId, - ...(released.length > 0 - ? { - releasedGrants: released.map((grant) => grant.ref), - bots: [...new Set(released.map((grant) => grant.agentId))], - } - : {}), - }, - }); - }, - - /** - * Ask a server what it offers and replace what we hold. - * - * Replaced wholesale, never merged. A tool a vendor withdrew has to stop being offered, and a - * merge would leave it in the list forever as a name the model will happily call. - * - * `actorId` is who is asking, and whether it is needed at all is the transport's answer rather - * than an assumption here. Where listing means asking a remote server — MCP — a `user-oauth` - * vendor has no deployment credential to ask with, so the listing runs on the grant of whoever - * pressed refresh, and an administrator who has not connected gets a refusal that lands in - * `lastError`. That is the honest state: until somebody has connected, this deployment genuinely - * does not know what that server offers. - * - * Where the tool list is this deployment's own code, nothing is asked and no credential is - * consulted. Requiring one anyway is what made setting Drive up a round trip through an - * administrator's personal settings page for a token that was then discarded. - * - * Absent for the refresh that happens right after a server is added, where nobody can have - * connected yet. It makes no difference to a `deployment-bearer` server, which never consults it. - */ - async refreshTools( - serverId: string, - actorId = "", - ): 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); - - /* - * 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. - * - * Where it is needed, it is taken from the same selection the call path uses rather than by - * decrypting `row.credentialId` — which is what this used to do, and which for a `user-oauth` - * server would have sent the deployment's OAuth client secret to the vendor as somebody's - * access token. One answer to "what token does this server get", and it cannot be a secret of - * the wrong kind. - * - * Where it is NOT needed, asking anyway is not a harmless extra check. For a `user-oauth` - * server that call refuses unless the person pressing the button has connected their own - * account — so an administrator setting Drive up was blocked at "refresh tools" and sent to - * their personal settings page to grant access, so that a token could be minted and handed to - * a function that discards it. The gate outlived the reason for it. - */ - const token = transport.listNeedsCredential - ? (await connectionTokenFor(row, entry, actorId, access)).token - : undefined; - - listed = await transport.listTools({ - url: effectiveUrl(row, entry), - token, - }); - } catch (error) { - /* - * Ours rather than a vendor's, asked as one question about the whole shelf. - * - * 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 (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; - } - - /* - * ASKED THROUGH THE ONE DOOR, like the `storableTools` catch forty lines below that writes - * the same column. - * - * `McpServerError` in the old test was doing nothing — it extends `Error`, so the second arm - * answered for it — and what the whole expression amounted to was - * `error instanceof Error ? error.message : String(error)`, which is precisely the reflex - * {@link reasonWithoutStatement} exists to replace: it reads `.message` directly and so walks - * straight past {@link withoutStatement}. - * - * NOTHING REACHABLE CHANGES BEHAVIOUR HERE TODAY, and that is the reason to write it rather - * than an argument against. The guard above answers every query failure before this line, so - * the shape whose message must never travel cannot arrive by any path anybody can name. What - * CAN arrive is a wrapper — `composio.listTools` rethrows `new Error(listingSentence(...))`, - * and `listingSentence` falls through to the thrown message verbatim — and a wrapper carries - * no `query` and no `params` of its own, so it is on nobody's shelf and answers to nobody's - * check. "No thrower reachable today" was also true of the credential envelope before the - * rotation path started writing one; the door costs nothing and does not need to be - * rediscovered at the next site. - */ - const message = reasonWithoutStatement(error); - // The failure is recorded rather than thrown away, because a server with no tools and no - // explanation reads as a server that offers nothing, and an operator would go looking in - // the wrong place. The tools already held are left alone: a vendor being briefly - // unreachable is not a reason to revoke what Bots are using. - await database - .update(mcpServers) - .set({ - /* - * Capped at the same 400 characters `callTool` caps its recorded failure at. - * - * Parts of this sentence come from a vendor, and it is drawn on the admin page — neither - * is a promise about length, and the two paths that show a vendor's words to an operator - * should not disagree about how much of them to keep. - */ - lastError: message.slice(0, 400), - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - return { tools: 0 }; - } - - /* - * An empty listing never destroys what a real listing recorded. - * - * CRITERION ONE. An empty answer must not be committed as a healthy refresh where doing so - * would delete actions this deployment holds, and must not clear `lastError`. - * - * CRITERION TWO. "This app advertises nothing" stays recordable: an app with nothing held has - * nothing to lose, so the empty answer falls through to the replace below and commits — a - * refresh stamp, no error, no actions. - * - * REASON. The replace below is a delete and an insert, so an empty answer committed here - * deletes every `mcp_tools` row for the server, taking the recorded `effect`, `destructive` - * and `version` with it. `version` is the one that cannot be reconstructed: `callTool` refuses - * an action without it, so a refresh that reported success broke every subsequent call, and - * the grants survived pointing at rows that no longer existed — absent from `listServers`, and - * revived only by a later refresh that worked. - * - * WHAT USED TO REACH THIS LINE, and no longer does. `composio.listTools` once answered `[]` - * for a url naming no app and for a deployment with no Composio client installed, neither of - * which is a vendor's answer, and the second of those is the state of every real deployment. - * Both throw now, so that particular arrival is closed at the seam rather than here. The guard - * stays because its argument never depended on who sent the empty answer. - * - * KEPT RATHER THAN TRUSTED, and that asymmetry is the whole argument. Holding actions the vendor - * has withdrawn is visible and reversible: the next listing replaces them. Deleting actions the - * vendor never withdrew is neither — `mcp_tools` is shared, so it is every replica at once, and - * only a refresh from a deployment that can actually reach the vendor puts it back. That holds - * for any vendor that suddenly lists nothing, whatever made it do so, which is why removing - * this would reopen the same data loss for a different reason. - * - * THE SEAM REQUIREMENT this leans on, and it is satisfied: a transport that could not ask - * anybody must THROW rather than return `[]`. `composio.listTools` opens with two throws that - * say which of the two it is — no app in the url, no client installed — and no transport has - * an early `return []` left in it at all: `builtin-routines` answers a static list, and `mcp` - * and `google-drive-rest` hand back only what a request returned. So an empty listing reaching - * this line is a vendor's own answer, which is what the sentence below says. Nothing here asks - * which transport it is talking to, and nothing has to: the requirement is met at each seam - * rather than branched on here. - */ - if (listed.length === 0) { - const held = await database - .select({ name: mcpTools.name }) - .from(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - - if (held.length > 0) { - await database - .update(mcpServers) - .set({ - // Named as the state it is, because "listed nothing" and "would not answer" send an - // operator to different places, and reaching this line settles which one it was: a - // listing that could not be made throws and lands in the `catch` above instead. So - // this sentence must not send anybody to check their configuration — that is the - // other state's sentence, written by the transport that refused. No - // `toolsRefreshedAt`: that column says when this deployment last learned what the app - // offers, and it did not learn it here. - lastError: `This app was asked and answered with no actions at all, so the ${held.length} already recorded for it were kept rather than deleted. Check whether it still publishes them, then refresh again.`, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - // What the app advertises, which is what it advertised before: the honest count, because - // nothing was replaced. - return { tools: held.length }; - } - } - - /* - * TURNING THE VENDOR'S ANSWER INTO ROWS, which is still the vendor's answer and so is still - * caught. - * - * CRITERION. Nothing a vendor can put in a listing leaves this method as an uncaught throw, - * and nothing a vendor can put in a listing half-applies the add that called it. - * - * REASON. Names are deduplicated and vendor text is made storable before a transaction is - * opened on any of it, because both of those failures used to abort the replace from inside - * one — see {@link storableTools}. Moving the work earlier moved the THROW earlier with it, - * to a line between the two `try` blocks and covered by neither. A schema that this could not - * make storable then left `refreshTools` raw: the refresh route has no mapping for it, so the - * admin page got a bodiless 500 and `lastError` kept whatever it held before; and `addServer` - * refreshes before it answers, so the add died AFTER its server row and its audit row had - * committed, leaving a configured app nobody had finished configuring. - * - * RECORDED RATHER THAN RAISED, which is the same answer the vendor `try` gives, because this - * is the same kind of event: the app was reached, it answered, and its answer is not something - * this deployment can write down. The actions it already holds are kept, no `toolsRefreshedAt` - * is stamped — nothing was learned — and the add completes with the failure on the row for an - * administrator to read. - * - * `withoutStatement` rather than `error.message`, so that the one shape whose message must - * never travel cannot reach the column even from a line that should never produce one. - */ - let storable: ReturnType; - try { - storable = storableTools(serverId, listed); - } catch (error) { - const reason = - error instanceof Error ? withoutStatement(error) : String(error); - await database - .update(mcpServers) - .set({ - // Capped where every other quoted failure in this file is capped, and for the same - // reason: part of this sentence comes from elsewhere and none of it is a promise about - // length. - lastError: - `This app answered with an action whose schema could not be stored as it arrived, so the actions already recorded for it were kept rather than replaced. ${reason}`.slice( - 0, - 400, - ), - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - return { tools: 0 }; - } - - /* - * COMMITTING WHAT THE VENDOR SAID. Nothing from here down is a vendor's doing, so nothing - * from here down is recorded as one — see the criterion on the vendor `try` further up, and - * the one on the `try` immediately above this, which is the last thing here that still is. - * - * 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. - */ - try { - await database.transaction(async (transaction) => { - await transaction - .delete(mcpTools) - .where(eq(mcpTools.serverId, serverId)); - if (storable.length > 0) { - await transaction.insert(mcpTools).values(storable); - } - }); - } catch (error) { - /* - * A database failure, with the statement and its parameters left behind. - * - * CRITERION. Nothing raised from here carries the SQL or the values bound to it. - * - * REASON. drizzle wraps every failure as a `DrizzleQueryError`, whose message is - * `Failed query:` followed by the whole statement and then every parameter — here, the - * vendor's entire tool list. That message is what an unhandled throw puts in the logs and - * what any caller that prints an error puts on a screen. A SQL dump on an error path is - * the same disclosure shape as a credential leak one layer out, and it is gratuitous: the - * driver's own complaint says what went wrong without any of it. - * - * RAISED, NOT RECORDED, which is what the paragraph above this transaction argues for and - * is now true rather than merely intended: with duplicate names and unstorable text - * removed before the statement is built, what is left is this database refusing something - * this deployment's own schema says it will take, and `lastError` is where a VENDOR's - * answer goes. - */ - throw new PluginInvariantError( - `${row.id}: the actions this app listed were not stored, so what it already had is unchanged. ${databaseComplaint(error)}`, - ); - } - - await database - .update(mcpServers) - .set({ - toolsRefreshedAt: new Date(), - lastError: null, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)); - - /* - * A grant left pointing at nothing goes in the trail, at the moment it starts pointing at - * nothing. - * - * Reporting it on a screen answers "what is true now", which somebody has to go and look at. - * This answers "when did it stop being offered, and what was holding it" — the question asked - * after a transport is swapped back and a name starts resolving again. Without the row, the - * only record of the gap is its absence. - * - * Not a refusal and not an error, so `configuration.changed` rather than a new event type: - * nothing was denied and the refresh succeeded. Written after the tool list is replaced, so - * what it names is what is actually left over — and only ever after a listing that was - * committed, because the guard above returns before this on an empty answer that would have - * named every grant the app holds. - */ - // The names as STORED, so a grant is compared against a row that exists: a duplicate the - // vendor listed twice is one row, and a name is spelled here the way the insert spelled it. - const advertised = new Set(storable.map((tool) => tool.name)); - const stranded = [...(await mcpGrantsForServers([serverId])).entries()] - .filter(([ref]) => !advertised.has(ref.slice(serverId.length + 1))) - .sort(([left], [right]) => left.localeCompare(right)); - - if (stranded.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: auditActor, - change: "grants_not_advertised", - server: serverId, - // The refs, because that is what a grant is keyed on and what an administrator revokes. - refs: stranded.map(([ref]) => ref), - bots: [...new Set(stranded.flatMap(([, agents]) => agents))], - note: "Held by a Bot and not offered to any model, because this server no longer advertises the tool. Offered again if it starts.", - }, - }); - } - - /* - * Tools the vendor advertises that this deployment's write list does not name. - * - * The mechanical half of the reconciliation Notion's catalogue entry says is required. See - * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an - * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list - * is silent, and for a vendor with no scope strings there is nothing else standing behind it. - * - * `configuration.changed` rather than a type of its own, the same as the stranded grants - * above and for the same reason: nothing was denied and the refresh succeeded. What changed - * is that the deployment now knows a name it had not classified. - */ - const unlisted = unlistedAdvertisedTools(entry, [...advertised]); - if (unlisted.length > 0) { - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "mcp_server", - targetId: serverId, - payload: { - actor: auditActor, - change: "unlisted_tools_advertised", - server: serverId, - tools: unlisted, - note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.", - }, - }); - } - - // What was recorded, which is what "this app offers N actions" means on the page. Counting - // the listing instead reported a duplicate the vendor named twice as two actions the - // deployment holds, when `mcp_tools` holds one row for it. - return { tools: storable.length }; - }, - - async listServers(): Promise { - const rows = await database - .select() - .from(mcpServers) - .orderBy(asc(mcpServers.title)); - if (rows.length === 0) return []; - - const tools = await database - .select() - .from(mcpTools) - .where( - inArray( - mcpTools.serverId, - rows.map((row) => row.id), - ), - ) - .orderBy(asc(mcpTools.name)); - - /* - * Every grant on these servers, not only the ones matching a tool that is still advertised. - * Asking about the advertised refs answers "who holds what is offered", which cannot report the - * grants that are the point here — see `mcpGrantsForServers`. - */ - const grants = await mcpGrantsForServers(rows.map((row) => row.id)); - const advertised = new Set( - tools.map((tool) => `${tool.serverId}/${tool.name}`), - ); - - /* - * HOW EACH APP CONNECTS, WHICH IS A FACT ABOUT THE APP AND NOT ABOUT THE ROW BESIDE IT. - * - * CRITERION. Every row here whose url names a Composio app reports the scheme - * {@link brokeredAppScheme} answers for that app — so two rows at one url report one answer, - * and it is the answer {@link connectBrokeredWithFields} will act on. - * - * REASON. The browser forks on this field: `brokered-account-row.tsx` draws a consent button, - * a form or a "nothing to connect" sentence out of it, and the press then lands in a store - * method that resolves the app by its URL. Reported off each row's own column those were two - * readings of one fact — a form drawn from this row and a submission refused by the other - * row's scheme, telling somebody to connect the app the way it asks for over an app they were - * asked exactly that way. {@link serverAddress} answers the same field the same way, so the - * page that lists an app and the route that connects it cannot come apart either. - * - * ONE EXTRA READ FOR THE WHOLE LIST, and none where the deployment has enabled no apps. - */ - const brokeredApps = await brokeredAppRowsAt( - rows.filter((row) => toolkitOf(row.url) !== null).map((row) => row.url), - ); - - /* - * WHICH APPS THE DEPLOYMENT HOLDS AN ACCOUNT AT, IN ONE READ FOR THE WHOLE LIST. Keyed by - * app rather than by server, because the account belongs to the app's url and not to a row: - * two rows at one url are one Composio app with one deployment account behind it. - */ - const deploymentHeld = new Set( - ( - await database - .select({ app: brokeredConnections.app }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.holder, "deployment"), - ), - ) - ).map((held) => held.app), - ); - - return rows.map((row) => { - const entry = catalogueEntry(row.id); - const toolkit = toolkitOf(row.url); - return { - id: row.id, - title: row.title, - logo: row.logo, - vendor: row.vendor, - url: effectiveUrl(row, entry), - summary: entry?.summary ?? "", - docsUrl: entry?.docsUrl ?? "", - provenance: row.provenance, - hasCredential: row.credentialId !== null, - toolsRefreshedAt: iso(row.toolsRefreshedAt), - lastError: row.lastError, - addedBy: row.addedBy, - dynamicClient: - entry?.auth.kind === "user-oauth" && - entry.auth.clientRegistration === "dynamic", - // The app's, for a row whose url names one; this row's own column for everything else, - // which is a null on every server that is not brokered. See the read above. - authScheme: toolkit - ? (brokeredApps.get(row.url)?.authScheme ?? null) - : row.authScheme, - /* - * The app's mode for the same reason as its scheme: a duplicate row at a Shared app's url - * is the Shared app, and the memory ingestion decides whose account a source reads from - * exactly these two fields. - */ - accountMode: toolkit - ? (brokeredApps.get(row.url)?.accountMode ?? null) - : row.accountMode, - sharedVendorConnected: - toolkit !== null && - brokeredApps.get(row.url)?.accountMode === "shared" && - deploymentHeld.has(toolkit), - tools: tools - .filter((tool) => tool.serverId === row.id) - .map((tool) => { - const ref = `${tool.serverId}/${tool.name}`; - return { - serverId: tool.serverId, - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema as Record, - ref, - effect: classifyTool(entry, tool.name, true, tool.effect), - destructive: tool.destructive, - grantedTo: grants.get(ref) ?? [], - }; - }), - /* - * Sorted by ref so the list is stable between reads, which matters because this is the one - * place a discrepancy is reported and a reader comparing two visits should see the same - * order. - */ - withdrawn: [...grants.entries()] - .filter( - ([ref]) => ref.startsWith(`${row.id}/`) && !advertised.has(ref), - ) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([ref, grantedTo]) => ({ - ref, - name: ref.slice(row.id.length + 1), - grantedTo, - })), - }; - }); - }, - - /** - * 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. - * - * AND THE SCHEME IS THE APP'S, NOT THIS ROW'S, WHICH IS THE ONE FIELD HERE THAT IS NOT ABOUT A - * ROW AT ALL. - * - * CRITERION. For a row whose url names a Composio app, `authScheme` is what - * {@link brokeredAppScheme} answers for that app. - * - * REASON. The connect route forks on this field — a form for a key app, a consent link for a - * consent one, a refusal for a no-auth one — and the store method that fork leads to, - * {@link connectBrokeredWithFields}, reads the scheme off the row that answers for the APP. Two - * rows may name one app, so those were two different reads of one fact: the page draws a form - * off this row and the submission is refused by the other row's scheme, in a sentence telling - * somebody to connect an app the way it asks for — over an app they were just asked exactly - * that way. The id and the title stay this row's own, because those name the row the page - * opened; the scheme is a fact about the app, and the app has one answer. - * - * `accountMode` IS THE APP'S FOR THE SAME REASON, off the same answering row and in the same - * read, never this row's own column; a row that names no app has no mode and answers null. - * - * ONE EXTRA READ, AND ONLY FOR A BROKERED URL. A row that names no app takes the read it always - * took. - */ - async serverAddress(serverId: string): Promise { - const [row] = await database - .select({ - id: mcpServers.id, - title: mcpServers.title, - url: mcpServers.url, - authScheme: mcpServers.authScheme, - }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!row) return undefined; - const toolkit = toolkitOf(row.url); - if (!toolkit) return { ...row, accountMode: null }; - const app = await brokeredAppRow(toolkit); - return { - ...row, - authScheme: app?.authScheme ?? null, - accountMode: app?.accountMode ?? null, - }; - }, - - /** - * 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); - }, - - /** Every account any holder has on a brokered app, as the broker would be asked to end it. */ - async accountsOn( - serverId: string, - ): Promise<{ toolkit: string; accounts: AccountRef[] } | null> { - const [row] = await database - .select({ url: mcpServers.url }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - const toolkit = row ? toolkitOf(row.url) : null; - if (!toolkit) return null; - const held = await database - .select({ - holder: brokeredConnections.holder, - userId: brokeredConnections.userId, - vendorUserId: brokeredConnections.vendorUserId, - }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - ), - ); - return { - toolkit, - accounts: held.map( - (row): AccountRef => - row.holder === "person" - ? { - holder: "person", - userId: row.userId ?? "", - vendorUserId: row.vendorUserId, - } - : { holder: "deployment", vendorUserId: row.vendorUserId }, - ), - }; - }, - - /** - * Write an app's mode and deployment identity — onto the row that ANSWERS for the app. - * - * Every reader takes these two columns off {@link brokeredAppRow}, so a write landing on a - * duplicate row at the same url would be a switch nobody reads: the page would say Shared and - * every call would go on running as it did. The id given is resolved to its url, the url to its - * app, and the app to its answering row; a row that names no app is written as itself, which is - * the only row there is to write. - */ - async setAccountModeColumns( - serverId: string, - mode: AccountMode, - sharedVendorUserId: string | null, - ) { - const [dialled] = await database - .select({ url: mcpServers.url }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - const toolkit = dialled ? toolkitOf(dialled.url) : null; - const app = toolkit ? await brokeredAppRow(toolkit) : null; - await database - .update(mcpServers) - .set({ accountMode: mode, sharedVendorUserId }) - .where(eq(mcpServers.id, app?.id ?? serverId)); - }, - - /** - * The skills this person may see: the deployment's, plus their own. - * - * An administrator sees every skill in the deployment, including other people's, because - * governing what Bots are told is the job of the surface they are looking at. - */ - async listSkills(actor?: SkillActor): Promise { - const visible = - !actor || actor.isAdmin - ? undefined - : or(isNull(skills.ownerUserId), eq(skills.ownerUserId, actor.id)); - const rows = await database - .select() - .from(skills) - .where(visible) - .orderBy(asc(skills.title)); - const grants = await grantsFor( - "skill", - rows.map((row) => row.slug), - ); - const declared = await toolsDeclaredBy(rows.map((row) => row.id)); - return rows.map((row) => ({ - id: row.id, - slug: row.slug, - ownerUserId: row.ownerUserId, - title: row.title, - summary: row.summary, - instructions: row.instructions, - origin: row.origin, - installedBy: row.installedBy, - grantedTo: grants.get(row.slug) ?? [], - tools: declared.get(row.id) ?? [], - })); - }, - - /** Whose a skill is, or `undefined` if there is no such skill. Null owner means the deployment's. */ - async skillOwner(slug: string): Promise { - const [row] = await database - .select({ ownerUserId: skills.ownerUserId }) - .from(skills) - .where(eq(skills.slug, slug)) - .limit(1); - return row ? row.ownerUserId : undefined; - }, - - /** - * Whose a Bot is, or `undefined` if there is no such Bot. - * - * Read here rather than through the coworker store because the only question this file asks is - * "may this person put their skill on that Bot", and a whole profile is more than that needs. - */ - /** - * Whether this Bot can be granted handing work on, or `undefined` if there is no such Bot. - * - * Any Bot that exists can: a built-in Bot runs `message_bot` in this process, and a Bot at its - * own endpoint calls back for it through the signed tool callback, which reaches the same desk. - */ - async agentCanHandOn(agentId: string): Promise { - const [row] = await database - .select({ id: agents.id }) - .from(agents) - .innerJoin(agentProfiles, eq(agentProfiles.agentId, agents.id)) - // A deleted Bot is not one anybody may be given, and answering about it at all would say it - // had existed. - .where(and(eq(agents.id, agentId), isNull(agentProfiles.deletedAt))) - .limit(1); - return row ? true : undefined; - }, - - /** - * Whether this Bot is one somebody could be handed work by, at all. - * - * The TARGET of a bot grant, unlike the grantee, may perfectly well run at its own endpoint — - * being handed work is not the same as being able to hand it on. What it may not be is absent: - * `ref` is bare text with no foreign key, so a typo stored happily, `message_bot` was offered, - * and every hop refused as not-granted. That is the same row-that-cannot-work this check exists - * to stop, arriving from the other side. - */ - async agentIsRegistered(agentId: string): Promise { - const [row] = await database - .select({ id: agents.id }) - .from(agents) - .innerJoin(agentProfiles, eq(agentProfiles.agentId, agents.id)) - .where(and(eq(agents.id, agentId), isNull(agentProfiles.deletedAt))) - .limit(1); - return row !== undefined; - }, - - /** - * Whether this deployment has an app by this id. - * - * The narrowest question a caller can ask about a server, and deliberately not `listServers`, - * which materialises every tool and every grant in the deployment to answer. One row, one - * column, one limit — this runs on the grant path, which is a person waiting on a switch. - * - * Existence only. Whether the app currently ADVERTISES a given tool is a different question and - * is not asked here: a grant naming a tool a server has stopped offering is a supported state - * ({@link GrantOnWithdrawnTool}), held and not offered, because what a vendor advertises today - * is not what somebody decided yesterday. A grant naming no app at all is not that state. - */ - async serverExists(serverId: string): Promise { - if (!serverId) return false; - const [row] = await database - .select({ id: mcpServers.id }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - return row !== undefined; - }, - - async agentOwner(agentId: string): Promise { - const [row] = await database - .select({ ownerUserId: agentProfiles.ownerUserId }) - .from(agentProfiles) - .where(eq(agentProfiles.agentId, agentId)) - .limit(1); - return row ? row.ownerUserId : undefined; - }, - - async installSkill(input: { - slug: string; - title: string; - summary: string; - instructions: string; - origin?: string; - /** Whose it is. Null writes a skill for the whole deployment, which is an admin's to make. */ - ownerUserId: string | null; - /** - * The tools this skill needs, as `/` refs. Absent leaves whatever was - * declared before; an empty array clears it, which is how a skill stops asking for anything. - */ - tools?: string[]; - by: string; - }): Promise { - /* - * Checked before anything is written, so a save is all-or-nothing from the caller's side: a - * skill is never left saved with half its declarations because the fourth ref was a typo. - */ - const declared = - input.tools === undefined - ? undefined - : [...new Set(input.tools.map((ref) => ref.trim()).filter(Boolean))]; - if (declared !== undefined && declared.length > 0) { - const known = await knownToolRefs(declared); - const unknown = declared.filter((ref) => !known.has(ref)); - if (unknown.length > 0) { - throw new PluginRefusedError( - `No tool by that name has been seen here: ${unknown.join(", ")}. A skill names tools as serverId/toolName, and the server has to have been refreshed at least once.`, - // No policy rule refused this; the name simply matches nothing. `rule` is what an audit - // reader is shown as the reason, and inventing one here would put a rule in the trail - // that nobody wrote. - null, - ); - } - } - - await database - .insert(skills) - .values({ - id: input.slug, - slug: input.slug, - ownerUserId: input.ownerUserId, - title: input.title, - summary: input.summary, - instructions: input.instructions, - origin: input.origin ?? "yours", - installedBy: input.by, - }) - // Editing keeps the owner it already had. Whose a skill is, is not something a re-save - // should quietly change, and the route has already checked this person may edit it. - .onConflictDoUpdate({ - target: skills.slug, - set: { - title: input.title, - summary: input.summary, - instructions: input.instructions, - updatedAt: new Date(), - }, - }); - - /* - * Replaced wholesale rather than merged. What a skill needs is a set the author is editing, so - * a save says what it is now; merging would make removing one a thing with no gesture for it. - */ - if (declared !== undefined) { - await database - .delete(skillTools) - .where(eq(skillTools.skillId, input.slug)); - if (declared.length > 0) { - await database.insert(skillTools).values( - declared.map((ref) => ({ - skillId: input.slug, - ref, - declaredBy: input.by, - })), - ); - } - } - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "skill", - targetId: input.slug, - payload: { - actor: input.by, - change: "skill_installed", - skill: input.slug, - // Recorded because it is what the skill will pull into a model's context once selection is - // built. It changes nothing about what may be called; the grant still decides that. - ...(declared === undefined ? {} : { declares: declared }), - }, - }); - }, - - async uninstallSkill(slug: string, by: string): Promise { - await database.transaction(async (transaction) => { - await transaction - .delete(pluginGrants) - .where( - and(eq(pluginGrants.kind, "skill"), eq(pluginGrants.ref, slug)), - ); - await transaction.delete(skills).where(eq(skills.slug, slug)); - }); - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: "skill", - targetId: slug, - payload: { actor: by, change: "skill_uninstalled", skill: slug }, - }); - }, - - /** - * The Bots one Bot has been granted, read fresh. - * - * NEVER CACHED. Whether one Bot may address another is a decision an administrator can change, - * and a grant revoked a minute ago has to apply to the next hop rather than after a restart. It - * is a single indexed read, which is the right price for that. - */ - /** - * The Bots this one may hand work to. - * - * Whatever the grantee's type. A Bot at its own endpoint is offered `message_bot` as a tool it - * calls back for, and the call reaches the same handoff desk, grant check and caps as a built-in - * Bot's (see `createCoordinationTools`). This used to keep only built-in grantees, from before - * remote Bots could call back for coordination, which left every remote Bot's grant reading as - * configured while no hop ever happened. - * - * The join keeps a grant held by a Bot that no longer exists from counting. - */ - async botsReachableFrom(agentId: string): Promise { - const rows = await database - .select({ ref: pluginGrants.ref }) - .from(pluginGrants) - .innerJoin(agents, eq(agents.id, pluginGrants.agentId)) - .where( - and(eq(pluginGrants.kind, "bot"), eq(pluginGrants.agentId, agentId)), - ); - return rows.map((row) => row.ref); - }, - - async grant( - kind: PluginKind, - ref: string, - agentId: string, - by: string, - ): Promise { - await database - .insert(pluginGrants) - .values({ kind, ref, agentId, grantedBy: by }) - .onConflictDoUpdate({ - target: [pluginGrants.kind, pluginGrants.ref, pluginGrants.agentId], - set: { grantedBy: by, updatedAt: new Date() }, - }); - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: grantTargetType(kind), - targetId: ref, - payload: { - actor: by, - change: "plugin_granted", - kind, - ref, - bot: agentId, - }, - }); - }, - - async revoke( - kind: PluginKind, - ref: string, - agentId: string, - by: string, - ): Promise { - await database - .delete(pluginGrants) - .where( - and( - eq(pluginGrants.kind, kind), - eq(pluginGrants.ref, ref), - eq(pluginGrants.agentId, agentId), - ), - ); - - await recordAuditEvent(auditStore, { - eventType: "configuration.changed", - targetType: grantTargetType(kind), - targetId: ref, - payload: { - actor: by, - change: "plugin_revoked", - kind, - ref, - bot: agentId, - }, - }); - }, - - /** Everything one Bot may use. The runtime asks this and offers exactly what comes back. */ - async listForAgent(agentId: string): Promise { - const held = await database - .select() - .from(pluginGrants) - .where(eq(pluginGrants.agentId, agentId)); - if (held.length === 0) return { tools: [], skills: [] }; - - const toolRefs = held - .filter((row) => row.kind === "mcp") - .map((row) => row.ref); - const skillSlugs = held - .filter((row) => row.kind === "skill") - .map((row) => row.ref); - - /* - * Narrowed in the query to the servers this Bot is actually granted something from, the same way - * `knownToolRefs` does it and for the same reason: a deployment aiming at a thousand tools should - * not read all of them to offer a handful. This is the run-time path, so it ran on every run of - * every Bot, selected every row in `mcp_tools`, and then discarded almost all of them here — and - * it sits underneath tool selection, so its cost is paid before the narrowing that was added to - * make large catalogues work. - * - * The exact ref is still matched below rather than in the query. Narrowing by server is a - * predicate the composite primary key can use; naming every (server, tool) pair would be exact - * and is not worth a clause per grant, because a server's own tool list is the bound on what - * comes back. - */ - const grantedServers = [ - ...new Set(toolRefs.map((ref) => ref.split("/")[0] ?? "")), - ]; - const toolRows = - grantedServers.length === 0 - ? [] - : await database - .select() - .from(mcpTools) - .where(inArray(mcpTools.serverId, grantedServers)) - .orderBy(asc(mcpTools.name)); - // A set, so this is a lookup per row rather than a walk of the grants per row. - const granted = new Set(toolRefs); - const grantedTools = toolRows - .filter((row) => granted.has(`${row.serverId}/${row.name}`)) - .map((row) => { - const ref = `${row.serverId}/${row.name}`; - return { - ref, - toolName: toolNameFor(ref), - description: row.description, - inputSchema: row.inputSchema as Record, - }; - }); - - const skillRows = - skillSlugs.length === 0 - ? [] - : await database - .select() - .from(skills) - .where(inArray(skills.slug, skillSlugs)); - - /* - * What each skill says it needs, carried alongside rather than folded into `tools`. - * - * `tools` above is what this Bot may call, and nothing here may widen it. Selection, when it is - * built, intersects the two; handing the runtime a union instead would make writing a skill a - * way to grant a tool, which is the one thing this must never be. - */ - const declared = await toolsDeclaredBy(skillRows.map((row) => row.id)); - - return { - tools: grantedTools, - skills: skillRows.map((row) => ({ - slug: row.slug, - title: row.title, - summary: row.summary, - instructions: row.instructions, - tools: declared.get(row.id) ?? [], - })), - }; - }, - - /** - * Register the deployment's OAuth client for a `user-oauth` server. - * - * An administrator pasting in what they created at the vendor. The work itself is - * {@link persistOAuthClient}, which self-registration goes through too — one path, so a client - * this deployment issued itself is stored, revoked and recorded exactly like a pasted one. - */ - registerOAuthClient: persistOAuthClient, - - /** - * The client to send somebody to the vendor with, registering one first if that is this - * vendor's way of getting one. - * - * A dynamically registered client is not paperwork anybody did: there is no console entry to - * paste, so "none yet" is the ordinary state of a server nobody has connected — and the answer - * is for the deployment to introduce itself, which is what it would have to do eventually - * anyway. Where an administrator registers by hand instead, and where the deployment has no - * public URL to be sent back to, the answer stays null: inventing a client at a vendor that - * never offered to issue one, or registering a redirect URI that resolves to nothing, would - * both leave behind a client that can never complete a consent flow. - * - * ONE CLIENT PER DEPLOYMENT EVEN WHEN TWO PEOPLE ASK AT ONCE. This is `requireUser`'s handler, so - * two first connects racing is the ordinary first hour of a connector. Registering twice is not - * merely wasteful: the loser's consent screen names a client the vault no longer holds, so that - * person consents and their callback then redeems the code against the client that replaced it — - * a connect that fails after the vendor already said yes. So the registration happens under - * {@link withOAuthClientLock}, with the "do we hold one" question asked AGAIN inside it, and the - * second caller finds the first one's client and is handed the same one. - * - * The lock is held across the registration request to the vendor, deliberately. It is one round - * trip with its own timeout, and a lock released before it would serialise nothing. - */ - async ensureOAuthClient( - serverId: string, - by: string, - ): Promise { - const stored = await storedOAuthClient(serverId); - if (stored) return stored; - - const { entry } = await requireServer(serverId); - if ( - entry?.auth.kind !== "user-oauth" || - entry.auth.clientRegistration !== "dynamic" || - !entry.auth.registrationUrl || - !options.redirectUri - ) { - return null; - } - // Held before the lock, because narrowing does not survive into the closure below. - const { registrationUrl } = entry.auth; - const { redirectUri } = options; - - const outcome = await withOAuthClientLock( - serverId, - async (transaction) => { - /* - * Asked again, under the lock. The read above was a fast path taken without one, and by now - * the caller we were racing has committed a client of its own — which is the one this - * deployment holds, so it is the one to consent against. - */ - const held = await heldOAuthClient(transaction, serverId); - if (held) return { client: held, registered: false, replaced: false }; - - const registered = await registerClient({ - registrationUrl, - redirectUri, - }); - if (!registered) return null; - - const { replaced } = await writeOAuthClient( - { serverId, client: registered }, - transaction, - ); - return { client: registered, registered: true, replaced }; - }, - ); - - if (!outcome) return null; - // Only what this call actually did. A trail row for handing back somebody else's client would - // claim a registration that never happened. - if (outcome.registered) { - await recordClientRegistered( - { serverId, client: outcome.client, by }, - outcome.replaced, - ); - } - return outcome.client; - }, - - /** - * Record that one person connected their own account to one server. - * - * The credential swap is {@link swapUserCredential}, and this is its only caller: a person - * connecting or reconnecting is exactly when there is an older grant to revoke. Rotation writes - * the same connection in place instead ({@link rotateConnectionToken}). What is only here is the - * audit row: this one IS somebody's act, and the trail should say so. - */ - async recordConnection(input: { - serverId: string; - userId: string; - refreshToken: string; - scope: string; - }): Promise { - const { replaced } = await swapUserCredential(input); - - await recordAuditEvent(auditStore, { - eventType: "mcp.account_connected", - targetType: "mcp_server", - targetId: input.serverId, - payload: { - actor: input.userId, - server: input.serverId, - // What the vendor granted, so a later refusal for want of a scope can be explained. - scope: input.scope, - reconnected: replaced, - }, - }); - }, - - /** - * The deployment's OAuth client for a server, or null if none is registered. - * - * Reads only. {@link ensureOAuthClient} is the one that will go and get one. - */ - oauthClientFor: storedOAuthClient, - - /** Which `user-oauth` servers this person has connected, for their own settings page. */ - async connectionsFor( - userId: string, - ): Promise<{ serverId: string; scope: string; connectedAt: string }[]> { - const rows = await database - .select({ - serverId: mcpUserCredentials.serverId, - scope: mcpUserCredentials.scope, - connectedAt: mcpUserCredentials.connectedAt, - }) - .from(mcpUserCredentials) - .where(eq(mcpUserCredentials.userId, userId)) - .orderBy(asc(mcpUserCredentials.serverId)); - - return rows.map((row) => ({ - serverId: row.serverId, - scope: row.scope, - connectedAt: iso(row.connectedAt) ?? "", - })); - }, - - /** - * 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 fields this shares with - * {@link connectionsFor} — `serverId`, `scope`, `connectedAt` — line up and one screen can draw - * both kinds of row. What comes back here is a SUPERSET of that shape rather than the same one: - * `verified` and `verifiedAt` ride along too, and only on a brokered row, because only a - * brokered row is a thing this deployment can re-check. - * - * `verified` AND `verifiedAt` COME ALONG BECAUSE "connected" IS NOT A PRESENT TENSE HERE. - * Composio never re-checks a key somebody typed in: it answers ACTIVE for as long as the row - * exists, whatever the vendor on the other side now thinks of that credential. So a page drawn - * off `connectedAt` alone would assert something this deployment has not known since the day it - * was written. These two fields are what lets it say when the claim was last earned instead — - * "connected with a key you provided, last checked 13 Sep". What the pair separates is a - * CHECKED key connection from an unchecked one, and nothing more: which KIND of connection a - * row is comes from the app's recorded {@link ServerRecord.authScheme}, which the page branches - * on first, and not from anything answered here. - * - * `probe` IS THE ACTION THE LAST CHECK SPENT, READ OUT OF THE ROW. The pair above says whether a - * key connection was ever checked; it cannot say WHY one was not, and three different - * situations share the one word `false`. Until this field, only the answer to a connect or a - * re-check could tell them apart — so a page reload lost the distinction, and the worst of the - * three degraded into the mildest: a row saying the key was accepted without being checked, over - * an account whose key the vendor had actually REFUSED. - * - * IT IS STORED BECAUSE IT IS A FACT ABOUT A MOMENT, NOT ABOUT TODAY'S METADATA. This field was - * once derived here, by asking {@link probeActionFor} which action this deployment WOULD check - * the app with; the argument for that was that the chooser holds every condition the probe - * itself runs on, so the two could not disagree. They cannot disagree AT AN INSTANT, and that is - * all it establishes. `verified` records a check made against the app's action listing as it - * stood THEN, and the chooser answers from the listing as it stands NOW — and `POST - * /servers/:id/refresh` is a generic administrator's route keyed on a server id, of which - * `composio-` is one, so an ordinary press of Refresh moves the second without touching - * the first. It is the very press the Composio transport tells an operator to make when an - * action appears or gains the version that makes it callable. So: somebody connects a key to an - * app that publishes nothing safe to try it on, and the row honestly says the key was accepted - * unchecked. An administrator presses Refresh. From that page load on, the derivation named an - * action, and the row drew the sentence written for a REFUSED key — your key was checked against - * this app and rejected, the account it was checked in still stands, so disconnect it. Every - * clause of that is false for somebody whose key was never tried, it tells them to take down a - * connection that works, and it persists: it is what every page load says until they press - * Re-check. - * - * SO THE WRITER RECORDS WHAT IT SPENT AND THIS READS IT BACK. {@link recordBrokeredConnection} - * is the single writer, every path into it knows the action it spent or that it spent none, and - * {@link composioConnections.probeAction} is where that goes. Read together with `verified`, the - * column tells four states apart, and no inference is made in any of them: - * - * no probe, not verified — nothing was tried: at the time of the check the app published - * nothing safe to spend a key on. A fact about the app, not the - * key. A key row written before the column existed reads this way - * too, for the reason that column gives. - * no probe, verified — a CONSENT connection. The vendor's own yes at its own screen is - * the evidence, no call was ever made against the account, and so - * there is no action to name. - * a probe, verified — it ran in this person's account and the vendor took the key. - * a probe, NOT verified — it ran and the vendor refused the key, and the account it ran in - * is still standing. A live account with a bad key behind it. - * - * AND THE LAST LINE IS A RECORD RATHER THAN AN INFERENCE, which is the whole of what changed. - * The caveat that stood here used to argue the state into existence: a key connection is ALWAYS - * probed at connect time, a probe that fails withdraws the account it just made, so an - * unverified row under an app that HAS a probe must be a refusal whose withdrawal failed — or - * else a re-check the vendor refused, which leaves the person's own older account alone. That - * reasoning was sound about the rows it described and said nothing about the row a refresh had - * quietly moved underneath it. What the row now warrants, it warrants by having been written: - * the check ran, it spent this action, and the vendor refused. - * - * WHICH PATH WROTE IT IS STILL NOT RECOVERABLE, and a reader must not invent one. The column - * records what was spent, not who spent it, and the two writers of that pair end differently: a - * connect withdraws the account it had just made and only leaves the row where Composio refused - * to take it back, while a re-check never withdraws anything, because the account predates the - * press and is the person's own. So a page may say the key was refused and the account stands; - * a page that goes on to blame a failed withdrawal is right on the connect path and FALSE on the - * re-check, where nothing ever tried to remove anything. - * - * `checkable` IS THE OTHER QUESTION, AND IT TRAVELS SEPARATELY BECAUSE COLLAPSING THE TWO IS - * WHAT DEADLOCKED THE SETTINGS SCREEN. "What did the check SPEND" is a fact about the past, and - * `probe` answers it. "Does this app have anything to check with TODAY" is a fact about the - * present, and this answers that — out of {@link probeActionFor}, asked of the APP rather than - * of the connection. The two agreed for as long as `probe` was derived: one field, one moment, - * two questions nobody ever had to tell apart. They part company the instant it became a - * record, which is the same instant it started being right about the past. - * - * THE DEADLOCK IN FULL, BECAUSE IT DOES NOT SELF-HEAL. The page gates its Re-check button on - * whether there is anything to check with, and it read `probe` for that. Somebody connects a - * key to an app that publishes nothing safe to spend it on: the check spends nothing, the row - * records null, and both of those are correct and permanent. An administrator presses Refresh, - * the app gains a safe versioned read, and the button is STILL withheld — because the record - * still says, truthfully, that nothing was spent. And pressing that button is the only thing in - * this product that can ever put an action into the record. The state is stable, wrong, and - * unreachable from inside itself: the single act that would end it is the act being withheld. - * - * SO A CALLER TAKES THE PAST FROM ONE AND THE PRESENT FROM THE OTHER, and must take neither out - * of the other one. A screen drawing its SENTENCE off `checkable` would accuse a key nobody - * tried, which is the defect the recorded column was made for; a screen gating its BUTTON on - * `probe` is the deadlock above. Neither field is a weaker spelling of the other, and the - * moment one is asked to answer both questions the two failures simply trade places. - * - * WHICH COSTS ONE QUERY PER CONNECTED APP, AND THAT IS THE RIGHT PRICE. Making `probe` a stored - * column took the per-row call out and left a listing that was one query; this puts it back — - * on top of the two the rows themselves now take, which the body below says why. - * The alternative is to fold the chooser's rule into the join — vendor-labelled read, not - * destructive, no required inputs, a recorded version — and that rule has no honest spelling in - * SQL: `required` is the vendor's own JSON Schema stored unchanged, and deciding whether it is - * a non-empty list of names is a thing JavaScript does and a `json` operator does badly. So - * folding it in means writing the rule a SECOND time, in a second language, over the same rows, - * and this file already records what a rule in two places costs: while the version condition - * sat in one of them, an app whose chosen action had no version connected honestly as "nothing - * was tried" and reloaded as "your key was checked and rejected". {@link probeActionFor} is the - * one authority on what can be checked, and a listing that asks it N times cannot disagree with - * the probe that asks it once. N is the apps ONE person has connected — a handful of indexed - * reads by server id, made in parallel — behind a settings page and not on any hot path. - * - * `verifiedAt` STAYS NULL WHERE IT IS NULL, unlike `connectedAt`, which collapses to `""` - * because a row cannot exist without one and the fallback is unreachable. Null here is - * reachable and it means something: never checked. Folding it into `""` would hand the page a - * row that was checked at a time nobody recorded, which is a different fact and not one this - * table ever holds. Note also what {@link composioConnections.verified} sets out about the rows - * migration 0038 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; - verified: boolean; - verifiedAt: string | null; - probe: string | null; - checkable: boolean; - }[] - > { - /* - * THIS PERSON'S OWN ACCOUNTS AND NOTHING ELSE. `holder = 'person'` is said out loud rather - * than left to the user id, because a settings page listing "your connections" must never - * draw the deployment's shared account as one of them — a deployment row has no `user_id`, - * and the filter keeps that a fact this query states rather than one it happens to rely on. - */ - const connections = await database - .select({ - toolkit: brokeredConnections.app, - connectedAt: brokeredConnections.connectedAt, - verified: brokeredConnections.verified, - verifiedAt: brokeredConnections.verifiedAt, - probeAction: brokeredConnections.probeAction, - }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.holder, "person"), - eq(brokeredConnections.userId, userId), - ), - ); - - /* - * THE APP IS RESOLVED PER CONNECTION, NOT JOINED TO IT, BECAUSE THE URL IS NOT A KEY. - * - * CRITERION. One connection is one row out of here, whatever `mcp_servers` holds. - * - * REASON. This read used to be an inner join on `mcp_servers.url = 'composio://' || toolkit`, - * and nothing in the schema makes that column unique — the one `uniqueIndex` in - * `db/schema/plugins.ts` is `skills_slug_key` on `skills.slug`. A join answers one row per - * PAIR, so a second row at an app's url listed the same account twice: two rows on the - * settings page saying the same app under two different server ids, each offering to - * disconnect the single connection standing behind both. And it is not an exotic state — it - * is what any database looks like the moment a row at `gmail` sits beside the - * `composio-gmail` an administrator really added, which is how a development database - * routinely looks. - * - * A UNIQUE INDEX WOULD BE THE OTHER FIX AND IS THE WRONG ONE. Two rows at one address is a - * state this product means to allow — a deployment holding two accounts at one vendor adds - * the same URL twice under two ids with two credentials, and `addCustomServer` refuses only a - * re-add that MOVES an existing id's address. Constraining the column would forbid that for - * everybody to tidy a display defect, and the migration that added it would fail outright on - * any deployment already holding a duplicate, taking the whole boot with it. - * - * SO THE LOWER ID ANSWERS FOR THE APP, and it is a rule rather than whatever the scan met - * first: a page that redrew under a different `serverId` each load would be offering buttons - * keyed on a value moving underneath it. The same rule decides the scheme reads — see - * {@link brokeredAppScheme} — so nothing in this file can name one row for an app while - * something else names another. - * - * AND "LOWER" IS THE DATABASE'S OWN WORD FOR IT, WHICH IS WHY THE ORDER IS IN THE QUERY. This - * read took its rows unordered and picked the smallest with a JavaScript `<`, which compares - * UTF-16 code units and nothing else, while {@link brokeredAppRow} asks for `order by id` - * under whatever collation the deployment's database runs. Those two agree for ASCII on a `C` - * database and are free to disagree everywhere else — a linguistic collation reorders case and - * punctuation, and byte order and code-unit order part company above the BMP. One rule spelled - * in two languages is two rules, and where they parted the page drew an app under one server - * id while the Re-check button beside it, the probe behind that button and the scheme that - * decides whether the button appears at all were about the other row: the same defect the - * single read was written to end, reached through the collation instead of through the query. - * So the resolution is {@link brokeredAppRowsAt} and not a rule spelled again here: it is the - * same function {@link brokeredAppRow} answers one app out of, which is what makes the row - * this page draws an app under the row every read behind its buttons is about. - * - * A CONNECTION WITH NO ROW AT ITS URL IS STILL LISTED BY NOTHING, which is what the inner - * join answered and what the missing `serverId` below drops. A person can hold an account at - * an app this deployment has since removed, and the settings page has no row to draw for it. - */ - const named = await brokeredAppRowsAt( - connections.map((row) => `composio://${row.toolkit}`), - ); - - const rows = connections - .flatMap((row) => { - const serverId = named.get(`composio://${row.toolkit}`)?.id; - return serverId === undefined ? [] : [{ ...row, serverId }]; - }) - // By server id, as the join's own `order by` was, so this read and `connectionsFor` hand the - // route two lists ordered alike. Compared as plain strings rather than through a collation, - // for the reason the route gives where it merges them: the order only has to be the same - // one every time. - .sort((left, right) => { - if (left.serverId < right.serverId) return -1; - return left.serverId > right.serverId ? 1 : 0; - }); - - return await Promise.all( - rows.map(async (row) => ({ - serverId: row.serverId, - scope: "", - connectedAt: iso(row.connectedAt) ?? "", - verified: row.verified, - verifiedAt: iso(row.verifiedAt), - // The name alone, because that is what the four states are told apart by; the version the - // check was made at is the caller-of-the-call's business, and this read makes none. - probe: row.probeAction, - /* - * WHETHER, NOT WHICH. The chooser names an action and this keeps only the yes or no, - * because the yes or no is the whole of the question being asked: is there anything to - * spend a key on. Carrying the name would put a second action name on a row that already - * has one, inches from the field that records what was actually spent — and the first - * reader to draw a sentence off the wrong one re-opens the defect that made `probe` a - * record. A boolean cannot be mistaken for a record of anything. - */ - checkable: (await this.probeActionFor(row.serverId)) !== null, - })), - ); - }, - - /** - * The deployment's one account at a brokered app, as the admin page draws it; null where none - * is connected, and null for a server that is not brokered at all. - * - * `connectedBy` IS THE ADMINISTRATOR'S EMAIL, because an id is not something a page can show - * anybody. It falls back to the stored id when that person has since been removed, so the row - * still says somebody connected it rather than claiming nobody did. - * - * `displayName` IS ASKED OF THE BROKER AND NEVER FAILS THE READ. It is the vendor's name for - * the account — the one thing that tells an administrator WHICH team account is behind the app - * — and a vendor that will not say is a page missing a label, not a page that cannot load. - */ - async deploymentConnectionFor(serverId: string): Promise<{ - connectedAt: string; - connectedBy: string | null; - verified: boolean; - verifiedAt: string | null; - probe: string | null; - displayName: string | null; - } | null> { - const [server] = await database - .select({ url: mcpServers.url }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - const toolkit = server ? toolkitOf(server.url) : null; - if (!toolkit) return null; - - const [row] = await database - .select({ - vendorUserId: brokeredConnections.vendorUserId, - connectedBy: brokeredConnections.connectedBy, - connectedByEmail: users.email, - connectedAt: brokeredConnections.connectedAt, - verified: brokeredConnections.verified, - verifiedAt: brokeredConnections.verifiedAt, - probeAction: brokeredConnections.probeAction, - }) - .from(brokeredConnections) - .leftJoin(users, eq(users.id, brokeredConnections.connectedBy)) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - eq(brokeredConnections.holder, "deployment"), - ), - ) - .limit(1); - if (!row) return null; - - const displayName = broker - ? await broker - .accountName({ - account: { holder: "deployment", vendorUserId: row.vendorUserId }, - toolkit, - }) - .catch(() => null) - : null; - - return { - connectedAt: iso(row.connectedAt) ?? "", - connectedBy: row.connectedByEmail ?? row.connectedBy, - verified: row.verified, - verifiedAt: iso(row.verifiedAt), - probe: row.probeAction, - displayName, - }; - }, - - /** - * Every Shared app, connected or not, as `GET /connections` lists it beside a person's own. - * - * A SHARED APP WITH NOTHING CONNECTED IS STILL LISTED, as `connected: false`. Its absence would - * read as "this app needs nothing", when it is the one state an administrator has to act on — - * the page offers its Connect action off exactly this entry. - * - * `checkable` IS TRUE ONLY WHEN AN ACCOUNT IS CONNECTED AND THE APP IS A KEY APP WITH A PROBE - * ACTION, because a re-check spends a key on a safe read and there is no key to spend without - * a connected account, nor on a consent app, which has no key at all. Where it is a connected - * key app the answer is {@link probeActionFor}'s, asked of the app, for the reason - * {@link brokeredConnectionsFor} gives for keeping `checkable` apart from `probe`. - */ - async sharedConnections(): Promise< - { - serverId: string; - scope: ""; - holder: "deployment"; - connected: boolean; - connectedAt: string | null; - connectedBy: string | null; - verified: boolean; - verifiedAt: string | null; - probe: string | null; - checkable: boolean; - displayName: string | null; - }[] - > { - /* - * ONE ENTRY PER SHARED APP, under the row that answers for it. A row marked `shared` that is - * a duplicate at some app's url is not the app — the app is Shared only if its answering row - * says so — and listing it would offer a Connect for an account no call would use. - */ - const marked = await database - .select({ id: mcpServers.id, url: mcpServers.url }) - .from(mcpServers) - .where(eq(mcpServers.accountMode, "shared")) - .orderBy(asc(mcpServers.id)); - const answering = await brokeredAppRowsAt( - marked - .filter((row) => toolkitOf(row.url) !== null) - .map((row) => row.url), - ); - const shared = marked.filter( - (row) => answering.get(row.url)?.id === row.id, - ); - - return await Promise.all( - shared.map(async (server) => { - const toolkit = toolkitOf(server.url); - const [connection, kind] = await Promise.all([ - this.deploymentConnectionFor(server.id), - toolkit ? brokeredAppKind(toolkit) : null, - ]); - return { - serverId: server.id, - scope: "" as const, - holder: "deployment" as const, - connected: connection !== null, - connectedAt: connection?.connectedAt ?? null, - connectedBy: connection?.connectedBy ?? null, - verified: connection?.verified ?? false, - verifiedAt: connection?.verifiedAt ?? null, - probe: connection?.probe ?? null, - checkable: - connection !== null && - kind === "key" && - (await this.probeActionFor(server.id)) !== null, - displayName: connection?.displayName ?? null, - }; - }), - ); - }, - - /** - * Whether this person has one brokered app connected, and since when. - * - * THE ROW IS A CACHE OF COMPOSIO'S ANSWER, not a record of a flow this deployment watched - * finish. Nothing here holds a secret for a brokered app: the vendor keeps the account, and - * what {@link brokeredConnections} 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 what the partial unique indexes enforce one row per: - * (provider, app) among deployment rows and (provider, app, user_id) among person rows — - * `holder` is each partial index's WHERE predicate, not a key column, so an ON CONFLICT - * target must name exactly those columns with the matching WHERE. 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; - account: AccountRef; - }): Promise<{ connectedAt: string } | null> { - const [row] = await database - .select({ connectedAt: brokeredConnections.connectedAt }) - .from(brokeredConnections) - .where(accountRow(input.toolkit, input.account)) - .limit(1); - - if (!row) return null; - return { connectedAt: iso(row.connectedAt) ?? "" }; - }, - - /** - * The action a key verification should call against this app, and the version to call it at. - * - * THIS CHOOSES THE ONE ACTION THAT WILL BE CALLED WITH SOMEBODY'S JUST-TYPED API KEY, which is - * what makes it the most dangerous line in the verification: whatever comes back from here runs - * against a stranger's account, once, purely to find out whether their key works. So the two - * safety conditions below are BOTH non-negotiable, and neither is a stricter spelling of the - * other. - * - * READ EFFECT, because a probe must not change anything. The label is the vendor's own and not - * a guess of ours: `effectOf` answers `read` only where Composio sent `readOnlyHint`, and - * everything unlabelled was already recorded as a write, so `read` here means Stripe or Linear - * or Notion said so. `destructive` is checked beside it rather than trusted to be implied — the - * two are separate columns precisely so a vendor can say both things, and a row that somehow - * says read AND destructive is a row this deployment has no business calling unasked. - * - * ZERO REQUIRED INPUTS, because there is nothing to invent an argument from. A probe happens - * before this deployment knows anything about the account beyond the key, so a required customer - * id or query has no honest value to carry, and a made-up one turns "is this key good" into - * "does this identifier exist" — which fails for a perfectly good key. - * - * BOTH, NEVER EITHER, and the live catalogue is why this sentence is here rather than a comment - * saying the checks are belt-and-braces. The first argument-less action on Stripe's own list is - * `STRIPE_CREATE_BILLING_METER_EVENT_SESSION`. A probe chosen on "takes no arguments" alone — - * the condition that looks sufficient, because it is the one that makes a call possible at all — - * would therefore write to somebody's account to find out whether their key works. The read - * effect is the whole of what stands between those two names. - * - * AND A RECORDED VERSION, WHICH IS PART OF "CAN THIS BE CALLED AT ALL" AND NOT A DETAIL OF THE - * CALLER. Composio refuses an execution without a specific version and rejects `latest`, so the - * transport refuses before dialling where none travels with the call — and Composio publishes - * some actions with no version at all. An action this deployment recorded without one is - * therefore an action nothing here can spend a key on, which is the same kind of fact as a - * required input: not unsafe, just not callable. The version is SELECTED AND RETURNED for the - * caller that has to send it, so the choice and the call cannot come apart. - * - * THE CONDITION LIVES IN THE FILTER RATHER THAN AFTER THE CHOICE, so there is ONE predicate for - * one question. While it sat downstream — read by the probe, unknown to everything else — there - * were two, and the weaker of them was what the connections listing derived its `probe` field - * from: an app whose chosen action had no version connected honestly as "nothing was tried", - * then reloaded as "your key was checked and rejected". That listing no longer derives that - * field from here — {@link brokeredConnectionsFor} reads what the check RECORDED, because no - * derivation from today's metadata can be right about yesterday's check. It still asks this - * function a question, but a present-tense one: whether the app has anything to check with now, - * kept as a yes or no beside the record. A split predicate would therefore no longer put a - * false sentence on a settings page; it would show up in two worse places — a probe that chose - * an action it then refused to send, and a button offered over an app nothing can be spent on. - * A filter also lets the search CONTINUE: a versionless - * candidate is passed over for the next safe read rather than short-circuiting the whole app to - * "nothing to try", so an app that can be checked is. - * - * NULL IS AN ANSWER AND NOT A FAILURE. Of fifteen key-based apps sampled, most publish some safe - * argument-less read and PostHog publishes none at all, so an app that cannot be probed is an - * ordinary app rather than a broken one. What a caller does about it — and running the probe at - * all — belongs to the verification path; this function only chooses. - */ - async probeActionFor( - serverId: string, - ): Promise<{ name: string; version: string } | null> { - // Ordered, because the fallback below is "the first candidate" and Postgres promises no order - // without one: an unordered read would make which action gets called with somebody's key a - // property of whichever plan the server happened to pick. - const actions = await database - .select({ - name: mcpTools.name, - inputSchema: mcpTools.inputSchema, - effect: mcpTools.effect, - destructive: mcpTools.destructive, - version: mcpTools.version, - }) - .from(mcpTools) - .where(eq(mcpTools.serverId, serverId)) - .orderBy(asc(mcpTools.name)); - - // One pass, and it yields the pair rather than the row: an action that survives every - // condition below has a version by definition, and building the answer here is what carries - // that fact into the type instead of leaving the caller to re-check it. - const safe = actions.flatMap((action) => { - if (action.effect !== "read" || action.destructive) return []; - // Null in the column and blank in the data are the same nothing, and neither is a version - // the transport can put on a call. - const version = action.version?.trim(); - if (!version) return []; - /* - * THE WHOLE SCHEMA IS ASKED, NOT ITS TOP-LEVEL `required`. This read `schema?.required` and - * passed over the action only for a non-empty list there — so an action whose required - * arguments are published under `allOf`/`anyOf`/`$ref`, which is how a generated toolkit - * ordinarily writes them, was selected as argument-less, called with `{}`, and answered with - * the vendor's validation error. That arrives at `probeConnection` as `answered: true, - * isError: true`, which is the `refused` verdict — a valid key declared bad, and on connect - * the account created seconds earlier deleted behind it. See {@link asksForArguments} for - * which keywords bear on an empty object and why the unreadable shapes answer yes. - */ - if (asksForArguments(action.inputSchema)) return []; - return [{ name: action.name, version }]; - }); - - const identity = safe.find((action) => IDENTITY_ACTION.test(action.name)); - return identity ?? safe[0] ?? null; - }, - - /** - * Write down that this person holds this brokered app, and how well that is known. - * - * ONE WRITER SO THE VERIFIED AND UNVERIFIED PATHS CANNOT DRIFT INTO TWO ROW SHAPES. What says - * how well a connection is known is a SET of fields and not a column: `verified` is meaningless - * without the moment it was earned, and `verified_at` without the flag is a date on a claim - * nobody made. Every path that records a connection therefore comes through here rather than - * spelling that set for itself — {@link confirmBrokeredConnection} with `true` today, and the - * verify path, which is the caller `false` exists for, when a probe against a key connection - * comes back unanswered. Two call sites each writing the set by hand is how one of them comes - * to set the flag and leave the timestamp null, or to move `connected_at` on a confirm that - * healed a row nothing changed; spelled once, a reader asking what shape a connection row takes - * has one answer and every path takes it. - * - * `verified` IS THE CALLER'S CLAIM AND `verifiedAt` FOLLOWS FROM IT, never the other way round. - * True means the caller has evidence as of now — the vendor's own yes at the end of a consent - * screen, or a call that went out and came back — so the timestamp is stamped here rather than - * passed in, and it is the moment of the write because that is the moment the evidence was in - * hand. False takes the timestamp back to null rather than leaving the old one standing: a row - * that has stopped being verified must not keep a date saying when it last was, because the one - * sentence the page builds out of the pair — "last checked 13 Sep" — would then be drawn for a - * connection this deployment is no longer claiming anything about. - * - * `connected_at` IS LEFT ALONE, which is the whole reason this is an upsert with an explicit - * `set` rather than a delete and an insert. The person connected when they connected; a write - * that moved it would make every page load look like a fresh connection on their own settings - * page, and would erase the one date the row holds that nothing else in this deployment knows. - * - * AND THE STAMP IT WROTE IS WHAT IT ANSWERS, for the same reason the caller does not pass one - * in. A caller that needs the moment — {@link recheckBrokeredConnection}, which hands it to the - * browser as the date the row's sentence is drawn from — would otherwise have to read the row - * back and hope it was reading its own write. The timestamp is still this writer's; what - * changed is that it is no longer thrown away. - * - * `only` IS WHETHER THIS WRITE MAY CREATE A CONNECTION, AND ONE CALLER MUST SAY NO. - * - * CRITERION. With `only: "a row that is still there"` this method never inserts. It updates a - * row that exists and answers `wrote: false` for one that does not, and the caller decides what - * to do about that. - * - * REASON. {@link recheckBrokeredConnection} reads the row first, deliberately, "because the - * writer below is an upsert, so a re-check that probed first would INSERT a connection for - * somebody who has none". That read and this write straddle a LIVE VENDOR CALL of several - * seconds, and nothing held the row still across it. A person pressing Re-check and then - * Disconnect in another tab — or a concurrent {@link confirmBrokeredConnection} getting `false` - * from Composio and deleting the row — had the revoke complete at Composio and the row deleted, - * and then this in-flight upsert PUT IT BACK. `brokered_connections` is the whole of the - * permission a brokered call is decided on, so what the re-insert restores is access to an - * account the person has just disconnected, drawn on every screen as connected. - * - * THE GUARD IS ON THE WRITE RATHER THAN A LOCK ACROSS THE CALL, because the call is somebody - * else's and may take as long as it likes. A connection this write did not find is one nothing - * happened to: the probe's answer is about an account the person no longer has here. - * - * `probeAction` IS PASSED IN, UNLIKE THE TIMESTAMP, BECAUSE ONLY THE CALLER KNOWS IT. It is the - * action this check SPENT — the probe's name, or null where none was spent — and it is required - * rather than optional so that a new path cannot record a connection while staying silent about - * what it tried. Every existing caller knows the answer without looking anything up: a consent - * confirm spent nothing and passes null, and both probing paths pass the name the probe returned - * them, which is null there too when the app published nothing safe to call. - * - * IT IS PART OF THE SAME SET AS THE FLAG AND THE STAMP, which is the reason it is written here - * and nowhere else. `verified` alone says a check did not pass and cannot say what it was; this - * column is what separates "the app published nothing to try" from "it ran and the vendor said - * no", and a row carrying one of the three without the others is a shape no reader downstream - * has reasoned about. It used to be derived on read instead, from the app's action listing as - * that listing stood at the moment of the read — see {@link brokeredConnectionsFor}, where the - * refresh that broke the derivation is written out. - */ - async recordBrokeredConnection(input: { - toolkit: string; - account: AccountRef; - connectedBy?: string; - verified: boolean; - probeAction: string | null; - /** See the paragraph on `only` above. Absent is the ordinary upsert. */ - only?: "a row that is still there"; - }): Promise<{ verifiedAt: Date | null; wrote: boolean }> { - const verifiedAt = input.verified ? new Date() : null; - // The one set, spelled once, so the conditional write below and the upsert cannot drift into - // two row shapes. `probeAction` is overwritten rather than left standing, for `verifiedAt`'s - // reason: the pair describes ONE check, and a row keeping the action an earlier check spent - // beside the verdict of a later one would name a call this row's own state did not come from. - const set = { - verified: input.verified, - verifiedAt, - probeAction: input.probeAction, - updatedAt: new Date(), - }; - - if (input.only === "a row that is still there") { - /* - * AN UPDATE AND NEVER AN INSERT, and `returning` is how "there was a row" is ANSWERED rather - * than assumed. An update that matches nothing is not a failure — it is the row having been - * deleted while the vendor was being asked — and the caller has a sentence for that. - */ - const written = await database - .update(brokeredConnections) - .set(set) - .where(accountRow(input.toolkit, input.account)) - .returning({ vendorUserId: brokeredConnections.vendorUserId }); - return { verifiedAt, wrote: written.length > 0 }; - } - - await database - .insert(brokeredConnections) - .values({ - provider: "composio", - app: input.toolkit, - holder: input.account.holder, - userId: - input.account.holder === "person" ? input.account.userId : null, - vendorUserId: input.account.vendorUserId, - connectedBy: - input.account.holder === "deployment" - ? (input.connectedBy ?? null) - : null, - verified: input.verified, - verifiedAt, - probeAction: input.probeAction, - }) - .onConflictDoUpdate( - input.account.holder === "person" - ? { - target: [ - brokeredConnections.provider, - brokeredConnections.app, - brokeredConnections.userId, - ], - targetWhere: sql`${brokeredConnections.holder} = 'person'`, - set, - } - : { - target: [brokeredConnections.provider, brokeredConnections.app], - targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, - // WHO CONNECTED IT MEANS THE LATEST CONNECTION. A deployment account is - // shared, so a second administrator reconnecting it supersedes the first — - // which `set` otherwise never said, leaving the row crediting whoever - // connected it the first time. Added only when this call names a - // `connectedBy` at all, so a re-check or a confirm that passes none does not - // blank out the administrator an earlier connect already recorded. - set: input.connectedBy - ? { ...set, connectedBy: input.connectedBy ?? null } - : set, - }, - ); - return { verifiedAt, wrote: true }; - }, - - /** - * 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. - * - * FOUR ANSWERS AND NOT A BOOLEAN, because `verified: false` means several different things - * about somebody's key and the flag separates none of them. They are enumerated on - * {@link BrokeredProbe}, which is where a reader should go; the one worth repeating at the call - * this method makes is the fourth. - * - * A VENDOR THAT WAS NOT REACHED IS NOT A VENDOR THAT SAID NO, and this is the one place that - * can tell. `callTool` answers with a result rather than throwing, and `isError` is true for all - * three of the failures it documents — so read as a verdict here, a Composio outage, a socket - * that closed, a `@composio/core` that cannot parse what came back and a run attributed to - * nobody were every one of them "the vendor refused this key". What that cost is at the callers - * and it was the worst thing this feature did: on connect the account somebody had just made - * was deleted at the vendor and they were told what they entered did not work; on re-check a - * live, working connection was stripped of its verification and marked as holding a bad key. - * {@link ActionAnswer} is what the transport now answers beside the result, and this method - * asks for it by calling `askAction` rather than `callTool`. - * - * 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; - account: AccountRef; - /** - * THE ACCOUNT TO SPEND IT IN, where the caller has one in mind. - * - * {@link connectBrokeredWithFields} has just made an account and is asking about THAT key; a - * person and an app do not name it, because one person may hold several accounts for one app - * and the vendor picks which a call runs in. Unpinned, a bad key was verified by the person's - * other, working account — and the same defect the other way round condemned a good key, and - * deleted the account it made, on the strength of some other account of theirs being broken. - * - * ABSENT FOR A RE-CHECK, AND THAT IS A GAP RATHER THAN A CHOICE. `brokered_connections` - * records no account id — a person's row is keyed on the person and the app, a deployment - * row on the app alone — so a press of Re-check has nothing to pin to and asks the vendor - * the same app-level question it always did. It is - * the milder half: a re-check writes a verdict but takes nothing away, and its refusal tells - * the person their key is wrong rather than removing anything they hold. - */ - accountId?: string; - }): Promise { - /* - * THE ONE ACTION THIS DEPLOYMENT WILL SPEND THE KEY ON, chosen from what the app published. - * - * Composio accepts a key without ever trying it, so "connected" at the vendor is not evidence - * that the credential works — and a row written on that acceptance is a gate every later - * brokered call passes for a key that cannot answer. {@link probeActionFor} is what keeps the - * call safe: the vendor must have labelled the action a read and it must take no arguments, - * and both matter because the first argument-less action on Stripe's own list creates a - * billing session. It answers the VERSION beside the name, because Composio refuses an - * execution without a specific one — so an action recorded without a version is one the - * chooser passes over rather than one this method discovers it cannot call. Null is an - * ordinary answer — see that method — and it is the FIRST of the three states above. - * - * NOTHING IS ASKED A SECOND TIME HERE, AND THAT IS THE POINT. Every condition on whether an - * action can be spent on a key lives in the chooser, so the action this method sends is the - * action the chooser said was sendable, whole. A version re-read here would be a second - * predicate for one question, and the weaker of two predicates is what once had a reloaded - * page tell somebody their untried key had been rejected — in the days when the connections - * listing answered by asking the chooser too. It no longer does: what a page says about a - * check is what the check recorded, and what it recorded is the name this method returns. - * - * AND THE APP IS RESOLVED BY ITS URL, THE WAY EVERY OTHER BROKERED LOOKUP HERE IS — through - * {@link brokeredAppRow}, which is the one read that decides it. The url is where a brokered - * row records which app it is; `mcp_servers.id` is a display name and nothing holds the two - * equal — which is exactly why {@link connectBrokeredWithFields}, {@link - * recheckBrokeredConnection} and {@link disconnectBrokered} all key on the url, and why {@link - * brokeredConnectionsFor} resolves on it rather than spelling `composio-${toolkit}` by hand. - * Composing it here made this method the one place that re-derived the id from a convention, - * and it put the two halves of the `checkable`/`probe` split back into disagreement on any - * row where they differ: the listing answered `checkable` off the app's real row while this - * answered off an id addressing nothing, so the button was offered and the press could never - * find anything to spend — the Re-check deadlock the split exists to prevent, reached from - * the other end. Worse where the composed id DID hit something: a row called `composio-gmail` - * at `composio://slack` would have this choose a stranger's probe off another app's listing - * and spend their key on it. A row this deployment has since removed drops out here as null, - * which is the same honest answer the listing gives for it: there is no app left to check. - * - * AND THE SHARED READ RATHER THAN A URL QUERY OF ITS OWN, because `mcp_servers.url` has no - * unique index and an unordered `limit(1)` over it is not an answer. A second row at the app's - * url — the ordinary state of any database where a fixture sits beside the `composio-` row an - * administrator really added — had this resolve to whichever row the scan met first while the - * listing named the app by the lower id, which is the SAME deadlock one step along: Re-check - * offered off the app's own row, and the press landing on a row that publishes no action and - * reporting there was nothing to try. One function decides which row answers for an app, so - * nothing here can name one row while something else names another. - */ - const app = await brokeredAppRow(input.toolkit); - - const candidate = app ? await this.probeActionFor(app.id) : null; - if (candidate === null) { - return { outcome: "nothing", 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 — 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 { result, answered } = await composioAskAction( - { - url: `composio://${input.toolkit}`, - actorId: input.account.vendorUserId, - // Spread rather than passed as `undefined`, so "any account of theirs" reaches the wire - // as a body with no such key. See {@link ComposioActions.execute}. - ...(input.accountId === undefined - ? {} - : { accountId: input.accountId }), - }, - candidate.name, - { [VERSION_ARG]: candidate.version }, - ); - - /* - * THE READING, AND IT IS THE WHOLE OF WHAT THIS METHOD DECIDES. `answered` is the transport's - * own statement that Composio ran the action in the account and said how it went; `isError` - * then says what it said. Anything else is an outage or an answer this deployment could not - * read, and neither is evidence about a key — so the action's NAME is withheld from those, - * because the name beside `verified: false` is the accusation and only a call that can be - * shown to have run may make it. - */ - if (!answered) { - return { - outcome: "unreachable", - probe: null, - attempted: candidate.name, - sentence: result.text, - }; - } - - /* - * AND `isError` SAYS THAT THE CALL WENT BADLY, NOT THAT THE KEY IS BAD. The envelope this is - * read out of is `{ data, error, successful }` and nothing more: a 429, a scope this one - * action needs and the key legitimately lacks, and a 404 for a resource the read action names - * all arrive here identically to a credential the vendor rejected. So the outcome is named - * for what was observed — the app complained — and the two readings that treated it as a - * verdict about the credential are gone from both callers. See {@link BrokeredProbe}. - */ - return result.isError - ? { - outcome: "complained", - probe: candidate.name, - sentence: result.text, - } - : { outcome: "answered", probe: candidate.name }; - }, - - /** - * 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. - * - * AND THE YES WRITES A VERDICT ONLY WHERE CONSENT IS THE CHECK, which is the one thing a reader - * of this method has to carry away. `verified: true` beside a null `probeAction` is not a - * neutral heal: it is a NAMED one of the four states {@link composioConnections.probeAction} - * enumerates — the consent state — and this method runs from an effect on mount, so writing it - * unconditionally meant every page load restated it over whatever a real check had recorded. - * For a key connection that erased the record, worst of all over "a named probe beside - * `verified: false`", the live account with a bad key behind it that is the one state somebody - * must act on; and it re-dated `verified_at` to the page load, so the row claimed a check on a - * day nothing was checked. The yes itself does not bear on a key: {@link - * ComposioBroker.isConnected} says an account is attached, Composio takes a key when it is - * typed and never tests it again, so for a key app that answer is what the row's existence - * already said. The branch is on the scheme recorded on the app's row, classified through {@link - * brokeredAppKind} as {@link connectBrokeredWithFields} and {@link recheckBrokeredConnection} - * classify theirs, and a key row already here is left untouched — the evidence about a key is a - * call, and those two are the only writers of this row's verdict. A key app the vendor holds an - * account for with no row here still gets one, written UNCHECKED, because the row is the gate - * every later brokered call passes through and the only thing Disconnect works off. - * - * AND THE FLAG IS WRITTEN FOR A CONSENT APP RATHER THAN FOR ANYTHING THAT IS NOT A KEY APP. A - * scheme this deployment cannot read — a null, or a literal nothing here writes — is neither - * kind, and it takes the key app's treatment: nothing recorded is overwritten, and a person the - * vendor holds an account for still gets the row that is their permission. See {@link - * SchemeKind} for why that third answer has to travel rather than be flattened into the second. - * - * `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; - account: AccountRef; - by: 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({ - account: input.account, - 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(brokeredConnections) - .where(accountRow(input.toolkit, input.account)); - 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. - * - * AND THE VERDICT IS READ BESIDE THE ROW'S EXISTENCE, which is the consent arm's business. - * `verified` here is the difference between "this deployment has already recorded the - * vendor's yes" and "it has not", and the write below is conditioned on it so that a mount - * cannot re-date a consent that was recorded days ago. The column is read directly rather - * than through {@link brokeredConnection}, which answers with the connection date alone. - */ - const [held] = await database - .select({ verified: brokeredConnections.verified }) - .from(brokeredConnections) - .where(accountRow(input.toolkit, input.account)) - .limit(1); - const existing = held !== undefined; - - /* - * THE SCHEME ON THE APP'S ROW, WHICH IS WHAT DECIDES WHETHER THE YES ABOVE IS A CHECK. - * - * Keyed on the url and on the one row that answers for it — see {@link brokeredAppRow} — which - * is the read {@link connectBrokeredWithFields} and {@link recheckBrokeredConnection} both - * make: `mcp_servers.id` is a display name and nothing holds the two equal, so a row called - * `gmail` at `composio://slack` would decide a Slack confirm on Gmail's scheme, and a second - * row at the app's own url would have this confirm branch on a scheme the re-check beside it - * disagrees with. Asked through {@link brokeredAppKind} rather than compared as a string, so - * the schemes this branches on cannot drift from the schemes that have a key behind them. - * - * AND THE QUESTION IS "IS THIS A CONSENT APP", NOT "IS THIS NOT A KEY APP", which are the same - * question only if the column can always be read. - * - * CRITERION. `verified: true` is written here for an app this deployment KNOWS connects by - * consent, and for no other. - * - * REASON. This branched on {@link isFieldScheme} alone, so every other answer — a consent - * scheme, a literal from a deployment that knew other names, a NULL — fell into the consent - * arm by elimination. A null is not a consent app: it is a column this deployment cannot read, - * which {@link mcpServers.authScheme} calls a row that is not brokered and which the row that - * ANSWERS for an app is perfectly free to carry — no unique index stands behind that url, so - * the row an enable wrote its scheme onto is not always the row found here. Confirm runs from - * an effect on mount, so the elimination wrote `verified: true` with a fresh `verified_at` and - * a null probe over that row on every page load: a verdict about evidence nobody has, dated to - * the day somebody opened a page, over whatever a real check had recorded. The other two - * readers of this column already fail closed on the null — {@link recheckBrokeredConnection} - * refuses the press, {@link disconnectBrokered} claims no revocation — so the one caller that - * could not survive being wrong was the only one failing open. - * - * SO AN UNREADABLE SCHEME IS TREATED AS A KEY APP IS, and that is the cautious half in both - * directions: nothing already recorded is overwritten, and the row that is the gate is still - * written where the vendor holds an account nothing here has a row for. - */ - const kind = await brokeredAppKind(input.toolkit); - - /** - * WHAT THIS CONFIRM ANSWERS FOR EACH KIND OF APP, WRITTEN DOWN BECAUSE THE CHAIN CANNOT BE. - * - * Type-only and erased; see {@link Decides}. The chain below tests ONE member and then tests - * something else — `kind === "none"`, `kind === "consent"`, then `!existing` — so there is no - * position where the compiler has this vocabulary narrowed away and nothing here would fail - * for a fifth member. - * This is the caller that WRITES, and it is the one that could not survive being wrong: it - * runs from an effect on mount, so whatever it decides for a member nobody named is decided - * again on every page load. - * - * EVERY CELL IS NOW ASSERTED RATHER THAN DECLARED. `consent` used to be written as what the - * code did rather than as what it should do — the re-stamp of `verified_at` on an - * already-consented row was a live finding — and `none` did not exist as a member at all, so - * a no-auth app was classified `consent` and got that same write on every mount. See the - * table in `tests/composio-connection-kinds.test.ts`, which drives each of these four against - * the running code. - */ - type _ConfirmDecides = Decides< - SchemeKind, - { - key: "leaves an existing row exactly as it is; records a new one as unchecked"; - consent: "records the vendor's yes as a verification the first time, and leaves an already-consented row alone"; - none: "writes nothing and files nothing — there is no account here for a row to be about"; - unreadable: "treated as a key app is — nothing already recorded is overwritten"; - } - >; - - /* - * AND THE CONSENT WRITE HAPPENS ONCE, WHICH IS THE OTHER HALF OF "A MOUNT DOES NOT RE-DECIDE". - * - * CRITERION. `verified: true` is written for a consent app only where this deployment has not - * already recorded it. A row already carrying that verdict is left exactly as it is, its - * `verified_at` included. - * - * REASON. The arm was unconditional, and this method runs from an effect on mount — so every - * page load re-stamped `verified_at` to the moment of the load. The flag never changed, so - * nothing looked wrong; what was destroyed was the DATE, which for a consent connection is - * the day somebody finished at the vendor's own screen and is a fact nothing else in this - * deployment records. The row's own sentence, "last checked 1 Sep", became "last checked - * today" on a day nothing was checked, and the real date could not be recovered from anywhere. - * {@link recheckBrokeredConnection} refuses to probe a consent app in order to protect exactly - * that date; a confirm that re-stamped it on every mount destroyed from the inside what that - * refusal protects from the outside. Two earlier fixes made this arm conditional for the key - * case and then for the unreadable case and left the consent case — the one the arm is - * actually FOR — writing on every load. - * - * AND A ROW NOT YET CARRYING THE VERDICT IS STILL HEALED, once. A consent app whose row was - * written by some other path — a key-era connect, an enable that changed the app's scheme - * afterwards — reads `false` with a null date, which is the pair "nobody has checked" and is - * not true of a consented account. The vendor's yes above is the check for this kind of app, - * so it is recorded, with the stamp of the moment it was first recorded here, and the next - * mount finds the verdict already present and writes nothing. - */ - /* - * AN APP THERE IS NOTHING TO CONNECT TO GETS NO ROW, WHICH IS THE OTHER TABLE'S RULE APPLIED - * HERE FOR THE FIRST TIME. - * - * CRITERION. Nothing is written and nothing is filed for a `none` app, whatever the vendor - * answered about it. - * - * REASON. `NO_AUTH` used to be a member of the CONSENT list, so the classifier called it - * consent and the arm below wrote `verified: true` with a fresh `verified_at` for it — on - * every page load, because this runs from an effect on mount. That is precisely the row the - * connect route refuses to create for these apps and the row the per-person gate is written to - * do without: `composio_connections` is the whole of the permission for a brokered call, and - * every row in it means one thing, that this person granted this deployment access to their - * account at this app. There is no account — Composio refuses even to hold an authorization - * config for a no-auth toolkit — and there is no grant, so a year on, offboarding, the trail - * and the Disconnect button could not tell those rows from ones somebody really made. - * - * THE NEGATIVE HEAL ABOVE STILL RAN, and deliberately: a row left behind by the version that - * wrote them is removed by the first mount that finds the vendor holding no account, which is - * every mount for an app like this. - */ - if (kind === "none") return { connected: true }; - - if (kind === "consent" && !held?.verified) { - // 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, - account: input.account, - connectedBy: input.by, - verified: true, - // NOTHING WAS SPENT TO EARN THAT FLAG, and that is what the null records rather than an - // absence of information. A consent connection is verified by the vendor's own yes at its - // own screen; no action of the app's is ever called against it, here or later, so there is - // no name to write and there never will be. The derived field could not say so — it - // answered with whatever the app happened to publish — and a consent row was listed as - // having been checked with an action nothing had called. - probeAction: null, - }); - } else if (kind !== "consent" && !existing) { - /* - * A KEY APP THE VENDOR HOLDS AN ACCOUNT FOR AND NOTHING HERE HAS A ROW FOR: recorded as - * UNCHECKED, which is the honest one of the four states for it. The account was made - * somewhere this deployment did not watch — in Composio's own dashboard, or by a connect - * whose row was lost — so no key of theirs has ever been tried from here, and null beside - * `false` is exactly "nothing was spent". The row still has to exist: it is the gate every - * later brokered call passes through, and the only thing Disconnect works off. - * - * AND AN APP WHOSE SCHEME CANNOT BE READ IS WRITTEN THE SAME WAY, for the same sentence - * one word weaker: nothing here has ever checked this account, and nothing here knows what - * checking it would even mean. `false` beside a null claims neither a check nor a refusal, - * which is the only pair that is true of it — and the row is still the permission, so a - * person whose account Composio holds does not lose their access to a column nobody wrote. - */ - await this.recordBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - connectedBy: input.by, - verified: false, - probeAction: null, - }); - } - /* - * AND AN EXISTING KEY ROW IS LEFT EXACTLY AS IT IS, which is the whole of what this branch - * does and the reason there is a branch at all. - * - * THIS RUNS FROM AN EFFECT ON MOUNT. Both brokered account screens confirm on every page - * load, so whatever is written here is written again every time somebody opens the page — - * and `verified: true` beside a null `probeAction` is not a neutral heal. It is one of the - * four states {@link composioConnections.probeAction} enumerates, and specifically the - * CONSENT one: "the vendor's own yes is the evidence and no call was ever made against the - * account". Written over a key row it erased the record of the last check and replaced it - * with that sentence, including over the worst state this feature has — a named probe beside - * `verified: false`, "it ran, the vendor refused the key, and the account is still standing" - * — which is the one state an operator has to act on. It also moved `verified_at` to the - * moment of the page load, so the row's own sentence, "last checked 13 Sep", named a day on - * which nothing was checked. - * - * BECAUSE THE YES IS NOT EVIDENCE ABOUT A KEY. {@link ComposioBroker.isConnected} answers - * that an account is attached, which for a key app is what the row's existence already said: - * Composio takes a key when it is typed and never tests it again — the whole reason the probe - * exists — so nothing in that answer bears on whether the key still works. Consent is the one - * scheme where the vendor's yes IS the check, and it earns the flag above for that reason - * alone. The evidence about a key is a call, and the two places that make one — {@link - * connectBrokeredWithFields} and {@link recheckBrokeredConnection} — are the only writers of - * this row's verdict. This one records what it learned by not writing. - * - * THE NEGATIVE HEAL IS UNTOUCHED, for all three kinds. A vendor answering NO still deletes the - * row above, which is what a confirm on a key app is still worth running for. - * - * AND AN EXISTING ROW UNDER AN UNREADABLE SCHEME IS LEFT ALONE FOR A STRICTLY WIDER REASON. - * For a key app the yes is not evidence; for an app whose scheme nothing here can read, it is - * not known WHAT the yes is evidence of. Both answers are the same act — write nothing — and - * it is the only act available that cannot claim more than was learned. - */ - - 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.by, - server: input.toolkit, - scope: "", - reconnected: false, - holder: input.account.holder, - }, - }); - } - - return { connected: true }; - }, - - /** - * Connect this person with the secret they typed, and write down everything except the secret. - * - * THE VALUES TRAVEL IN ONE DIRECTION AND THE WHOLE METHOD IS BUILT AROUND THAT. They arrive on - * the request, they are handed to {@link ComposioBroker.connectWithFields}, and they reach - * Composio. Nothing else here is given them: not the row, not the audit payload, not a log - * line, not a thrown error — the broker's own doc comment is where that promise is kept on the - * far side, and it is the one call in this tree that rethrows with no `cause` precisely because - * the vendor's error object holds the key. Every other participant in this method is a - * long-lived, widely-readable record, so a credential landing in one is not a leak somebody can - * clean up afterwards; it is a leak with a retention schedule. - * - * THE SCHEME IS THE ONE RECORDED ON THE APP'S ROW, never a fresh read of the catalogue and - * never a value a caller passed. It is what this deployment's authorization config was created - * AS, and a connection is attached to that config: a second derivation is a second answer — a - * key sent as `BASIC` against a config made for `API_KEY` — which is the reasoning {@link - * ComposioBroker.connectionFields} gives for taking the scheme rather than resolving it, one - * step earlier in the same flow. - * - * AND AN APP WHOSE SCHEME IS NOT A FIELD SCHEME IS REFUSED BEFORE THE KEY TRAVELS. A consent - * app, a `NO_AUTH` app and an app this deployment could not resolve at all have no form and - * nothing to attach typed values to, so sending them on would spend somebody's credential on a - * config that cannot hold it — and would do it having already taken the secret out of the - * request. {@link isFieldScheme} is asked rather than the string compared, for the reason it - * exists: one list, read by the guard and by the type, so the schemes this admits cannot come - * apart from the schemes the broker's signature takes. - * - * `connected` IS THE LITERAL `true` BECAUSE THERE IS NO OTHER WAY OUT OF HERE. Unlike {@link - * confirmBrokeredConnection}, which asks a question the vendor may answer no to, this performs - * an act: it either made the connection or it threw. A `boolean` would invite a caller to - * branch on a `false` this method cannot produce. - * - * `probe` IS RETURNED BESIDE `verified` BECAUSE THE FLAG ALONE NOW MEANS THREE DIFFERENT THINGS, - * AND THE BROWSER READS THIS FIELD TO CHOOSE ITS SENTENCE. While the row was written `false` - * unconditionally the flag had one meaning — nobody has checked — and a screen could say so from - * the flag alone. With a probe that can fail there are three states, and two of them share the - * flag: - * - * null probe, `verified: false` — nothing was tried, so nothing is known about the key. - * Either this app publishes nothing safe to call, or the - * check could not be made — Composio unreachable, or an - * answer this deployment could not read. "It was accepted - * without being checked" is true of both, which is why - * they share the state; `checkable` on the listing is what - * tells the person whether pressing Re-check can ever help, - * and the trail carries the outage under `unreachable`. - * 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; - account: AccountRef; - by: string; - values: Record; - }): Promise<{ connected: true; verified: boolean; probe: string | null }> { - // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to - // connect anybody at, and the refusal must happen before the values are touched at all. - if (!broker) throw new BrokerUnconfiguredError(); - - // Keyed on the url and on the one row that answers for it — see `brokeredAppScheme`. 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. - /** - * WHAT THIS CONNECT ANSWERS FOR EACH KIND OF APP, AND FOR EACH OUTCOME OF ITS OWN CHECK. - * - * Type-only and erased; see {@link Decides}. Two vocabularies meet in this one method — the - * scheme decides whether it will run at all, and {@link BrokeredProbe} decides what the run - * leaves behind — and each is read here by an equality test that a new member would sail - * past. `isFieldScheme` is asked rather than {@link schemeKind}, so the two answers that are - * not `key` reach one refusal by elimination; the roster is what says so out loud. - */ - type _ConnectWithFieldsDecides = Decides< - SchemeKind, - { - key: "connects, then checks the key it was just handed"; - consent: "refuses — not an app this deployment connects with values somebody types"; - none: "the same refusal — an app that needs no credential has nowhere to put one"; - unreadable: "the same refusal, reached by elimination rather than by decision"; - } - >; - type _ConnectWithFieldsRecords = Decides< - BrokeredProbe["outcome"], - { - nothing: "the account stands, recorded unchecked with a null probe"; - answered: "the account stands, recorded verified under the probe's name"; - complained: "the account stands, recorded unchecked under the probe's name, and the press reports what the app said"; - unreachable: "the account stands, recorded unchecked, with the attempt on the trail"; - } - >; - const authScheme = await brokeredAppScheme(input.toolkit); - if (!isFieldScheme(authScheme)) { - throw new BrokerRefusalError( - `${input.toolkit} is not an app this deployment connects with values somebody types, so nothing was sent. Open the app on the Plugins page and connect it the way it asks for; if it is not listed there at all, an administrator has to enable it first.`, - ); - } - - const { accountId } = await broker.connectWithFields({ - account: input.account, - toolkit: input.toolkit, - authScheme, - values: input.values, - }); - - /* - * THE CHECK, WHICH IS THE SAME ONE A RE-CHECK MAKES AND IS SPELLED ONCE FOR THAT REASON. - * - * {@link probeBrokeredConnection} chooses the action out of what the app published — with the - * version the listing recorded for it, which is part of what makes it choosable — calls it - * with no arguments and reads what came back. - * What belongs to THIS path and to no other is what happens next: an account this call has - * just made, which a key the vendor rejects must not be allowed to leave standing. A re-check - * runs the identical probe against an account that already existed and leaves it alone, and - * those two undo behaviours are exactly why the shared part stops where it does. - * - * `probe: null` IS THE FIRST OF THE THREE STATES THIS METHOD REPORTS — the app published - * nothing safe to call, or nothing at a version this deployment recorded, so the key was - * never tried. It is an ordinary answer and not a failure; see that method. - */ - const probed = await this.probeBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - /* - * THE ACCOUNT THIS CALL JUST MADE, which is the only account this check is about. A person - * and an app do not name one: Composio takes an account per key, somebody may hold several - * for one app, and the vendor picks which a call runs in. So an unpinned probe verified a - * key that does not work against the person's OTHER account — writing `verified` on the - * strength of a call the new key never touched — and, the same defect pointing the other - * way, condemned a perfectly good key and deleted the account it had just made because - * some older account of theirs was broken. The undo below has always been keyed on this id; - * this is what makes the check about the same account as the withdrawal. - */ - accountId, - }); - - // 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, - account: input.account, - }); - - /* - * THE CONNECT IS ON THE TRAIL BEFORE THE OUTCOME IS READ, AND THAT IS WHAT MOVING IT BUYS. - * - * The account exists by this line — `connectWithFields` above either made it or threw — and - * from here the method has four ways out, one of which is the throw in the branch below. - * Filed after that branch, this row was written on three of the four: a `complained` check - * left a live account at Composio, a `composio_connections` row, and an - * `mcp.connection_verified` row saying a check did not come back clean, with NOTHING saying - * the account had ever been connected. `complained` is the ordinary outcome of a rate limit - * or of one refused scope rather than an exotic one, so the trail lost the connect on the - * path most likely to be walked — and the `mcp.account_disconnected` row filed when that - * person later presses Disconnect had no counterpart to pair against. - * - * ABOVE `recordBrokeredConnection` RATHER THAN BELOW IT, because what this row asserts is - * that the ACCOUNT was made, which the broker call above has already established, and not - * that this deployment finished writing its own row about it. The two arms below disagree - * about what that row says and agree about this. - */ - 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.by, - server: input.toolkit, - /* - * EMPTY, AND PRESENT, which is the whole of what this field does on a brokered row. - * - * All three writers of `mcp.account_connected` now agree on the key. {@link - * recordConnection} carries what a vendor granted a `user-oauth` grant, and both brokered - * writers carry `""` — a brokered connection has no scopes at all, because Composio holds - * the grant and never tells this deployment what it covers. - * - * WRITING NOTHING IS NOT THE SAME AS WRITING THAT. This one wrote no key, so the same app - * connected by two people came back as `''` from {@link confirmBrokeredConnection} and as - * NULL from here, in a table whose entire purpose is being queried — and with nothing in - * either row saying which writer made it, a reader cannot tell an absent scope from an - * empty one, or either from a row written before the field existed. - */ - scope: "", - reconnected: existing !== null, - holder: input.account.holder, - /* - * 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(), - }, - }); - - if (probed.outcome === "complained") { - /* - * THE ACCOUNT THIS CALL MADE IS LEFT STANDING, AND THAT REVERSES WHAT THIS BRANCH USED TO DO. - * - * It read the outcome as "the vendor refused the key" and acted on it: `revokeAccount` on - * the id just created, then a refusal telling the person what they entered did not work. - * The premise was that nothing should be left behind on a key that does not work — which is - * a good rule for the fact it names and was being applied to a fact nobody had established. - * A complaint is Composio saying the CALL failed, out of an envelope with no status and no - * error code in it (see {@link BrokeredProbe}), so a 429 on the identity read, a scope this - * one action wants, or a 404 for a resource it names all arrived here as "your key is wrong" - * and destroyed a connection that had just been made correctly. - * - * THE TWO MISTAKES DO NOT COST THE SAME, WHICH IS WHAT SETTLES IT. Keeping the account of a - * genuinely bad key leaves a live row at Composio that can do nothing, that every screen - * here draws as unverified with the app's own words beside it, and that Disconnect ends in - * one press. Deleting the account of a good key costs the person the key itself: it is gone - * from Composio, it was never stored here, and getting back is fetching it from the vendor - * and typing it again. One is a press, the other is an errand — so the recoverable mistake - * is the one this branch is willing to make. - * - * SO THE STATE THIS WRITES IS THE ONE THE `probe` FIELD WAS ADDED FOR, reached now by the - * ordinary path rather than only by a failed undo: the row exists, it is unverified, it - * names the action the check was spent on, and the sentence carries what the app said. What - * it no longer carries is a verdict about the credential, here or on any screen that draws - * it. - */ - await this.recordBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - connectedBy: input.by, - verified: false, - // THE ACTION THAT WAS TRIED, which is the half of this state the flag cannot hold. It is - // the whole of what separates this row on a later page load from a key nobody ever tried, - // and it is the same name the audit row below carries under `action`, for a reader of the - // trail rather than of a screen. - probeAction: probed.probe, - }); - - /* - * AND THE TRAIL SAYS SO TOO, WHICH IS THE HALF THE SENTENCE BELOW CANNOT REACH. The refusal - * is told to one person in one moment; what outlives it is an unverified row and a live - * account at the vendor, and the person who most needs to know both exist is an operator - * reading this trail a week later. Filed BEFORE the throw for the only reason that matters - * here: every way out of this branch is that throw, so a row written after it is a row - * never written. - * - * `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 and the app answered with a failure". - */ - await recordAuditEvent(auditStore, { - eventType: "mcp.connection_verified", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.by, - holder: input.account.holder, - action: probed.probe, - verified: false, - }, - }); - - /* - * AND THE SENTENCE REPORTS RATHER THAN ACCUSES. It said "what you entered did not work", - * which is the one thing this branch cannot know. What it can say is all true: a check was - * spent, here is what the app answered, the account is still there, and here are the two - * presses that end it either way. - */ - throw new PluginRefusedError( - `${input.toolkit} was connected, and the check this deployment then ran against it did not come back clean: ${probed.sentence} That may be the key, and it may be the app — what came back does not say which — so the account is left standing and recorded as unchecked. Re-check it on the Plugins page once, and if it still will not answer, disconnect it and connect it again with a fresh key.`, - null, - ); - } - - /* - * ONE OUTCOME EARNS THE FLAG AND THE OTHER TWO REACHING HERE DO NOT, which is the whole of - * what is decided below. 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}. An app that published nothing safe to call is the honest - * unchecked state {@link composioConnections.verified} documents. And a vendor that could - * not be reached is the SAME unchecked state, arrived at a different way — which is the one - * thing a reader of this branch has to take away, so it is written out rather than left to - * the union. - * - * AN OUTAGE IS AN ABSENCE OF EVIDENCE AND THE ROW HAS A STATE FOR THAT. It is not a fourth - * thing to store: `probe_action` records the action a check SPENT, and a call that reached - * nobody spent none, so null beside `verified: false` says exactly what is true — this key - * has not been checked. What it must never become is the other `verified: false`, the one - * with a name beside it, because that pair is the accusation "it ran and the vendor refused - * your key" and the settings page draws it as such. {@link BrokeredProbe} withholds the name - * from this outcome so the writer below cannot record it even by accident. - * - * AND THE ACCOUNT STAYS, which is the half the person would not get back. The undo above is - * for a key the vendor REFUSED; a key nobody could ask about is very probably fine, and - * deleting somebody's account because Composio was down destroys the thing they just made to - * tell them something that was never established. The Re-check button is what settles it - * afterwards — the app publishes an action, so the settings page offers one — and this is why - * the connect answers rather than refusing: nothing went wrong with what they typed. - * - * 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 probe = probed.probe; - const verified = probed.outcome === "answered"; - - await this.recordBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - connectedBy: input.by, - verified, - // What was spent, which is the name on a probe that ran and the null that IS the first of - // the three states: this app published nothing safe to try the key on. `verified` is - // derived from this same value a few lines above, so the row cannot claim a check with an - // action beside a flag that says nothing checked it, or the other way about. - probeAction: probe, - }); - - /* - * THE CHECK ITSELF, ON THE TRAIL, WHETHER OR NOT ONE HAPPENED. - * - * Filed on every connection rather than only where a probe ran, because "this app published - * nothing safe to call, so the key was never tried" is the fact a reader most needs and the - * one nothing else records. `action` is the null the response field is: it separates an - * unchecked connection from a checked one, which `verified: false` alone cannot. - * - * THE FAILED UNDO ABOVE FILES THE SAME ROW BEFORE IT THROWS, so the worst state this feature - * has — a live account, an unverified row, and a withdrawal the vendor refused — is no longer - * named only in a sentence one person read once. The clean undo files nothing, and the reason - * is written out at that branch: this trail records state that persists, and a row there - * would cost the failed-undo row the one meaning that makes it worth reading. - * - * UNDER THE APP SLUG, which is the id `mcp.account_connected` above, {@link - * confirmBrokeredConnection}, {@link disconnectBrokered} and {@link retireConnectionsFor} all - * file under — so the two rows this method can write about one person's access to one app - * come back in ONE query, however that access began and however it ended. This row was filed - * under the SERVER row's id instead, on the reasoning that it is about an ACTION of a server - * rather than about access; that is true and it cost the reader the only question anybody - * asks this trail, which was answered half by one id and half by the other. Nothing goes with - * the change: the server id is `composio-` and the slug, and the action is in the payload. - * - * AND THE OUTAGE IS NAMED HERE BECAUSE THE ROW CANNOT NAME IT. Two different things leave - * this method with a null `action` — an app that publishes nothing safe to call, and a check - * that could not be made — and on `composio_connections` they are one state, correctly: both - * mean the key is unchecked, and neither says anything about the key. A reader of the TRAIL - * is asking a different question, "was a check attempted and what became of it", and - * `unreachable` is the whole of the difference, in Composio's own words. Present only on the - * outage, so its absence is as informative as its value. - * - * AND CAPPED WHERE EVERY OTHER QUOTED FOREIGN STRING IN THIS FILE IS CAPPED, which this one - * alone was not. `passableSentence` decides whether a candidate is worth repeating and says - * nothing whatever about length; `askAction` then caps at `MAX_RESULT_CHARS`, which is 20_000 - * and is a bound written for a model's context window rather than for a row. So the sentence - * arriving here can be fifty times what `refreshTools`' two `lastError` writes, the failed - * undo's own reason a hundred lines up, and `callTool`'s two `failure` fields each allow - * themselves — and `@composio/client` builds an `APIError` message out of an entire response - * body, so a multi-kilobyte one is the ordinary arrival and not a contrived one. - * - * WHY THIS ROW AND NOT ANOTHER. `audit_events` is append-only by trigger, it is exported, and - * it is kept for the deployment's whole retention window: a `lastError` written too long is - * overwritten by the next refresh, and this is not. Whatever lands here cannot be cleaned up - * afterwards, which makes the one uncapped write in the file the one that could least afford - * to be. - */ - await recordAuditEvent(auditStore, { - eventType: "mcp.connection_verified", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.by, - holder: input.account.holder, - action: probe, - verified, - ...(probed.outcome === "unreachable" - ? { unreachable: probed.sentence.slice(0, 400) } - : {}), - }, - }); - - return { connected: true, verified, probe }; - }, - - /** - * Try a key this deployment already holds, because somebody pressed the button that asks. - * - * A BUTTON, AND NEVER A PAGE-LOAD EFFECT. Composio never re-checks a key: it accepts one when it - * is typed and says nothing about it again, so a row that was verified in March goes on saying - * so after the key behind it was rotated, revoked or let expire. Nothing but this can correct - * that — which is exactly the argument somebody will use for calling it from an effect when the - * settings page mounts, and it is the wrong conclusion. The call this makes is spent against the - * PERSON'S OWN rate limit at the vendor, on their account, so verifying on every render would - * burn somebody's quota at Linear to redraw one word on a page they were only passing through. - * {@link confirmBrokeredConnection} is the one that runs on mount, and it asks Composio a - * question about its own records; this one goes out to the app. - * - * A RE-CHECK IS NOT A CONNECT, AND THE DIFFERENCE IS THE WHOLE METHOD. It runs against an - * account that already exists: it must not create one, it must not withdraw one when the probe - * fails — the person's account stays, it is their KEY that is wrong — and it must change - * nothing here but the verification and its timestamp. {@link connectBrokeredWithFields} does - * undo its account on a bad key, and it is right to: the account is a thing it had just made, - * seconds earlier, for a key that turned out not to work. Here the account predates the press by - * days, the person asked to have it CHECKED, and taking it away to tell them their key is wrong - * would destroy the thing they are trying to repair. The shared probe stops short of both - * behaviours for that reason. - * - * A PROBE THAT RAN AND FAILED RAISES, AND DOES NOT COME BACK AS `verified: false`. Those two - * answers are not different spellings of one outcome. `false` is also what an app that publishes - * nothing safe to call produces, and a row handed the flag alone cannot tell "the vendor - * rejected your key" from "there was nothing here to try" — so it would draw the unchecked - * sentence, and drop the Re-check button, for the one person who most needs it: somebody who has - * just fixed their key and pressed it. The refusal carries Composio's own sentence, which is the - * whole of what they can act on. The ONLY legitimate `verified: false` from here is the one that - * arrives with `probe: null` saying there was nothing to check with. - * - * NOTHING TO PROBE WRITES NOTHING AT ALL, and answers with the row as it stands. A check that - * could try nothing has learned nothing, and writing `false` on that would take the date off a - * connection verified at a consent screen — a fact nothing else in this deployment records, - * erased by a button that claims to check one. So the answer is what the row says after the - * press, and `probe` is what says whether the press was able to try anything. - * - * THE ROW IS READ BEFORE THE VENDOR IS CALLED, and its absence is a refusal. The writer below is - * an upsert, so a re-check that probed first and recorded the answer would INSERT a connection - * for somebody who has none — the row that is the whole of the gate every later brokered call - * passes through, created by a button that only asks a question. The probe itself would be spent - * on an account the vendor does not hold, and would come back "no connected account found": this - * deployment's own state, shown to somebody as though their key had been rejected. - * - * NO BROKER IS REFUSED BEFORE ANY OF IT, though nothing here calls the broker. The broker and the - * transport are built from the same key, so a deployment without one has neither — and the probe - * would come back as the transport's "Composio is not configured for this deployment", which - * this method would otherwise report as the vendor rejecting a perfectly good key, and would - * write the row unverified on the strength of it. - * - * AND A CONNECTION WITH NO KEY BEHIND IT IS REFUSED HERE RATHER THAN IN THE BROWSER. There is - * nothing to re-check on a consent connection: the person authenticated at the vendor's own - * screen, this deployment holds no credential of theirs, and the row's `verified_at` is the - * date that screen earned — a fact nothing else here records. A probe spent on it would be a - * call against their account that this method then reads as evidence about a key that does not - * exist, and the likely failure would write `verified: false` with a null timestamp: a button - * that claims to CHECK a connection, destroying the only record that one was ever checked. That - * is precisely what the nothing-to-probe branch above is written to protect, and an app that - * happens to publish a safe action walks straight past it. The screen does not offer the button - * for a consent app, but a route takes POSTs and not only button presses, so the refusal - * belongs where {@link connectBrokeredWithFields} puts its own: on the SCHEME RECORDED ON THE - * APP'S ROW, asked through {@link isFieldScheme} so the schemes this admits cannot drift from - * the schemes that have a key to admit. - */ - async recheckBrokeredConnection(input: { - toolkit: string; - account: AccountRef; - by: string; - }): Promise<{ - verified: boolean; - verifiedAt: string | null; - probe: string | null; - }> { - if (!broker) throw new BrokerUnconfiguredError(); - - // Keyed on the url and on the one row that answers for it — see `brokeredAppKind`. It is the - // read `connectBrokeredWithFields`, `confirmBrokeredConnection` and `disconnectBrokered` all - // make, for the same stake: a row called `gmail` at `composio://slack` would decide a Slack - // re-check on Gmail's scheme. Anything but a key refuses, a scheme nothing here can read - // included — there is no key recorded to re-check, and the sentence below is the same one - // either way. - /** - * WHAT THIS RE-CHECK ANSWERS FOR EACH KIND OF APP, AND FOR EACH OUTCOME OF THE CHECK. - * - * Type-only and erased; see {@link Decides}. `!== "key"` is a boolean read of a three-member - * vocabulary: it happens to fail closed for both of the other two today, which is why nothing - * has gone wrong here yet and also why a fourth member would inherit that answer without - * anybody choosing it. The probe roster below is the vocabulary this method reads FOUR ways, - * and it is the one that has already been added to once. - */ - type _RecheckDecides = Decides< - SchemeKind, - { - key: "spends a call against the key this deployment holds"; - consent: "refuses — there is no key here to re-check"; - none: "the same refusal — there is no account, let alone a key, to check"; - unreadable: "the same refusal, which is the closed direction"; - } - >; - type _RecheckRecords = Decides< - BrokeredProbe["outcome"], - { - nothing: "writes nothing and files nothing; answers the held row beside a null probe"; - answered: "records verified under the probe's name, and files the check"; - complained: "records unchecked under the probe's name, files the check, then reports what the app said"; - unreachable: "writes nothing and files nothing; refuses with Composio's own sentence"; - } - >; - if ((await brokeredAppKind(input.toolkit)) !== "key") { - throw new PluginRefusedError( - `${input.toolkit} is not an app this deployment holds a key for, so there is nothing here to re-check. It was connected at ${input.toolkit}'s own sign-in screen, and if it has stopped working, disconnecting it on the Plugins page and connecting it again is what fixes it.`, - null, - ); - } - - const [held] = await database - .select({ - verified: brokeredConnections.verified, - verifiedAt: brokeredConnections.verifiedAt, - }) - .from(brokeredConnections) - .where(accountRow(input.toolkit, input.account)) - .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 probed = await this.probeBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - }); - - /* - * THE VENDOR WAS NOT REACHED, SO THE RECORD OF THE LAST CHECK IS LEFT EXACTLY WHERE IT IS. - * - * This is the branch the whole four-state reading exists for on this path. A failed probe - * used to mean one thing here — "the vendor rejected the key it is holding" — so an outage, - * a socket that closed, or a `@composio/core` that could not parse an answer cleared - * `verified`, dropped `verified_at` (the only record anywhere that this connection was ever - * checked, and the date the page prints) and wrote the named probe beside the `false`, which - * is the accusation. Every person who pressed the button while Composio was down was told - * their key had been refused, over a row that had been verified minutes earlier. - * - * NOTHING IS WRITTEN, WHICH IS STRONGER THAN WRITING SOMETHING HONEST. A press that learned - * nothing may not move a record: there is no state for "we could not ask" on the row, and - * there should not be — the row says what is known about the key, and an outage changes - * nothing about that. It is the same restraint as the nothing-to-probe branch below, and - * nothing is filed on the trail for the same reason it gives. - * - * AND IT RAISES RATHER THAN ANSWERING, which is where it parts from that branch. Somebody - * pressed a button and is owed the truth about what happened to their press: the check did - * not happen, and here is Composio's own sentence about why. Answering with the held row - * instead would report the state as though the press had confirmed it. - */ - if (probed.outcome === "unreachable") { - throw new PluginRefusedError( - `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when Composio is answering is the whole of the retry.`, - null, - ); - } - - /* - * 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 (probed.outcome === "nothing") { - return { - verified: held.verified, - verifiedAt: iso(held.verifiedAt), - probe: null, - }; - } - - // What the press spent, which from here on is a name: both outcomes left are a call that ran - // and a vendor that answered about it. - const probe = probed.probe; - - /* - * 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 = probed.outcome === "answered"; - const { verifiedAt, wrote } = await this.recordBrokeredConnection({ - toolkit: input.toolkit, - account: input.account, - verified, - // The action this press spent. Never null on this path: the nothing-to-probe branch above - // returns before reaching the writer, precisely so that a check which could try nothing - // writes nothing at all. - probeAction: probe, - /* - * AND IT MAY NOT CREATE THE CONNECTION IT IS RECORDING A CHECK OF. The read at the top of - * this method refuses a person with no row, deliberately, because this writer is an upsert — - * but that read and this write straddle the vendor call above, which is live and takes - * seconds. A Disconnect landing in that window revoked the grant at Composio and deleted the - * row, and this upsert then put the row back: `composio_connections` is the whole of the - * permission a brokered call is decided on, so the re-insert restored access to an account - * the person had just ended, and drew the app as connected again. - */ - only: "a row that is still there", - }); - - /* - * THE ROW WENT WHILE COMPOSIO WAS BEING ASKED, SO NOTHING IS RECORDED AND NOTHING IS FILED. - * - * The two ways here are a Disconnect in another tab and a concurrent confirm that Composio - * answered `false` for and which deleted the row. Both are somebody or something ENDING this - * connection, and a verification row filed afterwards would be the trail recording a check of - * an account that no longer exists — beside `mcp.account_disconnected` for the same app, in - * whichever order the two happened to land. - * - * AND IT SAYS SO RATHER THAN ANSWERING QUIETLY, for the reason the outage branch above does: - * somebody pressed a button and is owed the truth about their press. The answer is not the - * probe's verdict, because the thing it was a verdict about is gone. - */ - if (!wrote) { - throw new PluginRefusedError( - `Your connection to ${input.toolkit} was disconnected while this check was still running, so nothing was recorded about it: what the check found is about an account you no longer have here. Connect ${input.toolkit} again on the Plugins page if that was not what you meant.`, - null, - ); - } - - /* - * 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.by, - holder: input.account.holder, - action: probe, - verified, - }, - }); - - if (probed.outcome === "complained") { - /* - * THE APP'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 this - * button to press again once whatever the sentence names has passed. - * - * AND IT REPORTS WHAT CAME BACK RATHER THAN RULING ON THE KEY. This said "would not answer - * with the key it is holding", which reads as the vendor having rejected the credential — - * and the envelope the outcome is decided from carries no status and no error code, so a - * rate limit and a bad key are the same answer here. See {@link BrokeredProbe}. Moving the - * row to unchecked is still right and is still the thing this button exists to do: whatever - * the failure was, the check did not come back clean, so a standing `verified: true` is no - * longer supported and must not keep standing. - */ - throw new PluginRefusedError( - `The check this deployment ran against ${input.toolkit} did not come back clean: ${probed.sentence} That may be the key and it may be the app — what came back does not say which — so your connection here is recorded as unchecked until a check does come back clean. Nothing was disconnected: press Re-check again in a few minutes, and fix the key at ${input.toolkit} if it keeps answering the same way.`, - null, - ); - } - - return { verified: true, verifiedAt: iso(verifiedAt), probe }; - }, - - /** - * End this person's brokered account at the vendor, and then forget where it was. - * - * REVOKE BEFORE DELETE, AND THAT ORDER IS THE WHOLE METHOD. The row is the only thing in this - * deployment that says which app this person connected: the app is read off - * `composio_connections`, and a revoke needs it. Delete first and a revoke that then fails - * leaves a live grant on somebody's mailbox that no operation here can reach, because the one - * value it would have to be revoked under is gone. The other order costs nothing by - * comparison — a revoke that throws leaves the row standing, the person presses disconnect - * again, and the second attempt has everything the first one had. - * - * WHICH ALSO MEANS THE FAILURE IS LOUD. Nothing is caught here: a broker that will not answer - * ends this call, and no row and no trail entry claims an account was disconnected when the - * account is still live. - * - * `vendorRevocationRequested` IS WHAT WAS ASKED FOR, NOT THAT A CALL WAS MADE — {@link - * ComposioBroker.revoke}'s own answer, passed through. True where an account was found and its - * withdrawal asked for, false where there was none to withdraw, and the value of the field is - * exactly that a reader can tell an account this deployment acted on from one that outlives it - * somewhere else. - * - * IT SAYS "REQUESTED" BECAUSE THE VENDOR'S ANSWER SUPPORTS NOTHING STRONGER, and the field was - * renamed from `vendorRevoked` when that turned out to be false in the plainest way: the - * adapter behind it was soft-deleting the account and asking for no upstream revocation at all, - * so every row saying a grant had been withdrawn described one still live at Google. The ask is - * now made; what a broker can promise synchronously is that the account is gone at Composio and - * that the provider has been asked, because the withdrawal itself runs as a background job with - * no supported way to poll it. - * - * EXCEPT WHERE THE APP IS ONE SOMEBODY TYPED A KEY INTO, AND THERE IT IS FALSE BY CONSTRUCTION - * RATHER THAN BY WHAT THE VENDOR FOUND. `revoke_on_delete` asks the PROVIDER to end a grant, - * which is a real request for a consent account — Google or Slack acts on it — and a - * meaningless one for an API key. There is no grant behind a key to withdraw: the value is - * still valid at the app and still works for anyone holding it, so the account ends at Composio - * and nothing was asked of anybody else. Saying otherwise would be the one row in this trail - * nobody could rely on, which is the failure the paragraph above describes arriving a second - * time by a different road — a withdrawal recorded for something that was never granted. So the - * scheme recorded on the app's row decides this field for a field connection, and the broker's - * own answer decides it for every other. - * - * THE SCHEME IS THE ONE ON THE APP'S ROW, for {@link connectBrokeredWithFields}' reason: it is - * what this deployment's authorization config was created AS and what the account was attached - * to, and a fresh read of the catalogue is a second answer — a key connection described as - * consent because the vendor has since started publishing managed OAuth for the app. The - * person's half of this fact is already written on the disconnect row they are shown: their key - * still works at the app, and rotating it there is what ends it. This is the trail's half of - * the same sentence. - * - * THE VENDOR IS ASKED WHETHER OR NOT A ROW IS HERE. The row is a cache of Composio's answer - * and never the account itself (see {@link brokeredConnection}), so its absence is not - * evidence that the grant is gone: the confirm above deletes it on any `false` from the - * vendor, and a person whose row was cleared that way can still be holding a live account at - * Composio with nothing left here pointing at it. Asking anyway is the only operation in this - * deployment that can end such a grant, and it costs nothing where there is genuinely nothing - * to withdraw — the broker answers `false` and says so. Skipping the revoke for want of a - * local row would make the safe half of disconnect unreachable for exactly the person who - * needs it, on the strength of a cache we already know drifts. - * - * BUT THE TRAIL RECORDS ONLY A DISCONNECT THAT HAPPENED. The event is filed where something - * actually ended — a row deleted here, or a grant withdrawn at the vendor — and not otherwise. - * A call that found no row and withdrew no grant disconnected nothing, and an - * `mcp.account_disconnected` row for it tells whoever reads the trail that somebody's account - * ended at a moment when nobody's did. It is the criterion - * {@link confirmBrokeredConnection} files its own event under, one act the other way round: - * the trail records acts, and a call that changed nothing performed none. - * - * WHICH IS NOT THE SAME QUESTION AS `vendorRevocationRequested`. A row here with no grant at - * the vendor is a disconnect — the gate this deployment decides every brokered call on was - * open, and this call closed it — so the event is filed, saying - * `vendorRevocationRequested: false`. A grant at the vendor - * with no row here is a disconnect too, and the weightier of the two, because somebody's live - * account was ended; the event is filed for that as well. Only where both are absent is there - * no act to record, and the two cases stay legible in the trail because the field still says - * which of them happened. - * - * SO THE FILING IS DECIDED ON THE BROKER'S OWN ANSWER AND NOT ON THE FIELD, because for a field - * connection the two part company on purpose. A key account the vendor found and ended with no - * row here is an act — somebody's live connection stopped existing — and gating the event on a - * value that is false by construction would leave exactly that act unrecorded. The field - * answers what was asked of the provider; `ended` answers whether anything was there. - */ - async disconnectBrokered(input: { - toolkit: string; - /** Whose account ends — a person's own, or the one the deployment holds for a Shared app. */ - account: AccountRef; - by: string; - /** - * Why the account ended, which is a closed set and not free text. A brokered account ends - * in exactly four ways — the person disconnecting their own; the person being removed from - * the People screen, which is the word {@link retireConnectionsFor} already files its own - * rows under; an administrator disconnecting the deployment's account for a Shared app; and - * the app being switched between Personal and Shared, which retires the accounts the old - * mode held. A reader asking the trail which of them happened can be answered only if it is - * the same word every time, so the type is the set rather than whatever sentence a caller - * happened to spell. - */ - reason: "self" | "person_removed" | "admin" | "mode_switched"; - }): Promise<{ vendorRevocationRequested: boolean }> { - if (!broker) throw new BrokerUnconfiguredError(); - - /* - * Keyed on the url and on the one row that answers for it — see {@link brokeredAppKind}. - * It is the read {@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, AND NEITHER IS ONE WHOSE SCHEME CANNOT BE READ. - * 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 both of those fall through - * to: the field below is a claim that this deployment asked the vendor to withdraw something, - * and an app it cannot say holds a key is one whose withdrawal it has to report as asked. - */ - /** - * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR EACH KIND OF APP. - * - * Type-only and erased; see {@link Decides}. `=== "key"` collapses three answers into two, - * and the collapse is deliberate here rather than accidental: the field below is a claim that - * this deployment asked the VENDOR to withdraw something, and an app it cannot say holds a key - * is one whose withdrawal it has to report as asked. That is a decision about `unreadable`, - * and this roster is where it is written down as one. - */ - type _DisconnectDecides = Decides< - SchemeKind, - { - key: "claims no vendor revocation — nothing at the vendor holds this key"; - consent: "reports the vendor's own withdrawal as asked for"; - none: "reports whatever the vendor says it ended, which for an app with no account is nothing"; - unreadable: "reports it as asked for too, which is the claim that cannot be too weak"; - } - >; - const fieldScheme = (await brokeredAppKind(input.toolkit)) === "key"; - - // 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({ - account: input.account, - toolkit: input.toolkit, - }); - - const vendorRevocationRequested = fieldScheme ? false : ended; - - // `returning` because whether a row was here is half of what decides if anybody was - // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. - const [deleted] = await database - .delete(brokeredConnections) - .where(accountRow(input.toolkit, input.account)) - .returning({ app: brokeredConnections.app }); - - if (deleted || ended) { - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.by, - server: input.toolkit, - // Whose account this was, which is not always who ended it: an administrator - // offboarding somebody and a person disconnecting themselves write the same shape of - // row, and only these two fields tell them apart. The deployment's own account has no - // person behind it, so it is named as the deployment rather than as whoever pressed. - owner: - input.account.holder === "person" - ? input.account.userId - : "deployment", - reason: input.reason, - vendorRevocationRequested, - }, - }); - } - - return { vendorRevocationRequested }; - }, - - /** - * Retire every connector credential belonging to one person. - * - * WHAT THIS IS FOR. "We removed their access" has to be true of the thing that matters, which is - * the refresh token sitting at the vendor. Removing somebody from the People screen used to end - * their sessions and add them to the deny list, and leave their Google grant entirely intact in - * this deployment's vault. They could not exercise it — the actor comes from a session they no - * longer get — but the deployment still held a usable secret for a person who had been removed, - * which is not what an administrator was told they did, and is the first thing a customer asks - * about a per-person connector. - * - * LOOKED UP IN THE VAULT, NOT THROUGH THE JOIN TABLE. `mcp_user_credentials.user_id` cascades on - * a user row being deleted, so by the time somebody is gone the join row can be gone too and the - * credential is orphaned: unrevoked, referenced by nothing, reachable from no screen and by no - * code path. `credentials.key_id` holds the user id for an `mcp_user_token`, so the vault can - * still be asked directly — which makes this work for the person who was removed and for the one - * whose row was deleted underneath it. - * - * The join rows go too, so the account pages stop claiming a connection this deployment can no - * longer use. - * - * AND THE BROKERED CONNECTIONS, which are neither a credential nor a join row. Composio holds - * the account, so there is no secret in the vault to find and the person's `brokered_connections` - * row is itself the permission — the only thing deciding whether a call may go out as this person. - * Sweeping the vault alone therefore left that gate passing for somebody who had been removed. - * - * NOT VENDOR-SIDE REVOCATION FOR THE VAULT HALF. That needs the OAuth client and the vendor's - * revoke endpoint, and it belongs with disconnect. Those rows are the half that stops us - * holding the secret; the grant at Google outlives it until somebody revokes it there. Said - * plainly rather than implied, because the difference matters to whoever has to answer for it. - * - * THE BROKERED HALF DOES END IT AT THE VENDOR, because there is no secret of ours to stop - * holding: clearing the row alone would shut the gate this deployment owns and leave the - * mailbox attached at Composio, which is "we removed their access" being untrue of the only - * thing that matters, for the person it matters most about. So every app this person connected - * is revoked through the broker, exactly as {@link disconnectBrokered} revokes for one and - * {@link removeServer} for a whole app. - * - * REVOKE BEFORE DELETE, ALWAYS. The row is the only thing that names which apps this person - * had, and it outlives the `users` row precisely so offboarding can still find them — which - * was the table's whole justification and until now was theoretical. A delete that ran first - * would leave a failed revoke with nothing to revoke under: a live grant on a departed - * person's mailbox that no operation in this deployment can reach. The other order costs a - * repeat of an act nobody minds repeating. Nothing is caught around the revokes either, so a - * broker that will not answer ends this method with the rows still standing rather than - * letting it report an ending that did not happen. - */ - async retireConnectionsFor( - userId: string, - by: string, - ): Promise<{ retired: number }> { - if (!userId) return { retired: 0 }; - - const owned = await database - .select({ - id: credentialRows.id, - provider: credentialRows.provider, - revokedAt: credentialRows.revokedAt, - }) - .from(credentialRows) - .where( - and( - eq(credentialRows.kind, "mcp_user_token"), - eq(credentialRows.keyId, userId), - ), - ); - - let retired = 0; - for (const credential of owned) { - // Already revoked is not a failure. Retiring twice is something an administrator can - // legitimately do, and the second time should be quiet rather than an error. - if (credential.revokedAt) continue; - await credentials.revoke(credential.id); - retired += 1; - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: credential.provider, - payload: { - actor: by, - server: credential.provider, - owner: userId, - /* - * Why, because the two reasons are not the same event to a reader. Somebody disconnecting - * their own account is a person changing their mind; an administrator removing somebody - * is an offboarding, and an auditor asking "what happened to their access" wants to see - * which one this was. - */ - reason: "person_removed", - vendorRevocationRequested: false, - }, - }); - } - - await database - .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 the person's - * `brokered_connections` row is the entire gate. Reading only the vault therefore retired nothing for somebody whose only - * connector was brokered, reported that as a retirement, and left the `(toolkit, user_id)` gate - * passing for a person who no longer exists — their access outliving them, which is the first - * thing anybody asks about a per-person connector. The table's own docblock justifies its shape - * by this path, so the shape was carrying a promise nothing kept. - * - * FOUND HERE AND NOWHERE ELSE, which is what the missing foreign key buys. The row survives the - * `users` row precisely so this can still name what the person had after they are gone — the - * same argument the vault lookup above makes, from the side that has no vault row. It is also - * why the guard at the top of this method is load-bearing rather than defensive: `not null` - * admits the empty string, so a row at `(toolkit, "")` is legal, and retiring "nobody" must not - * be what deletes it. - * - * COUNTED, because the number is what "we removed their access" claims. Retiring twice stays - * quiet on its own: the rows are gone, so the second call finds none. - * - * READ BEFORE ANYTHING IS DELETED, because the revokes below need the apps and the rows are - * where the apps are — the reason the docblock gives for revoking first. Sorted, so two - * retirements of the same person revoke in the same order and write their rows in the same - * order. - */ - const brokered = await database - .select({ toolkit: brokeredConnections.app }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.holder, "person"), - eq(brokeredConnections.userId, userId), - ), - ) - .orderBy(asc(brokeredConnections.app)); - - /* - * What the broker was actually asked for each app, kept so the trail below records the answer - * rather than the call. False where there is no broker at all: a deployment whose key has - * since been unset can still offboard somebody, and it could not have been calling Composio - * either way — but nothing was asked there and the row must not claim otherwise. - * - * ONE APP'S REFUSAL IS ONE APP'S REFUSAL, and until now it was everybody's. A throw out of - * `revoke` left this loop before the delete and before the trail, so three accounts already - * withdrawn at Composio kept their rows and got no row on the trail. That was survivable while - * repeating the act did nothing — and #574 made repeating it the documented recovery, so the - * second pass asks again for those three, Composio answers `false` because the accounts are - * gone, and each writes `vendorRevocationRequested: false` about a withdrawal this deployment - * asked for and got. That field exists to tell an account we acted on from one that outlives - * us somewhere else; those three rows say the wrong one. - * - * So the answer is kept per app and the refusal is held rather than thrown. Every app is still - * asked — a later one is not punished for an earlier one — and the first refusal is rethrown - * below, so the act still fails loudly and the administrator still gets a 500. - */ - const withdrawn: { toolkit: string; requested: boolean }[] = []; - const refusals: unknown[] = []; - for (const connection of brokered) { - try { - withdrawn.push({ - toolkit: connection.toolkit, - requested: broker - ? await broker.revoke({ - /* - * THE PERSON'S OWN ACCOUNT, NAMED AS ONE. Offboarding somebody ends what they - * held and never the deployment's shared account, whoever connected it — so the - * holder is fixed here rather than read off anything, and the vendor id is the - * person's own, which is what a personal account is filed under at Composio. - */ - account: { holder: "person", userId, vendorUserId: userId }, - toolkit: connection.toolkit, - }) - : false, - }); - } catch (error) { - /* - * Held, and the row deliberately left standing. - * - * "An offboarding the vendor refuses leaves the connection standing" is the existing - * criterion and it is unchanged: the row is the only thing naming which app this person - * connected, repeating the act is the recovery, and repeating it is only possible while - * the row is there. What changes is that the rule now applies to the app it is about - * rather than to every app in the same act. - */ - refusals.push(error); - } - } - - /* - * Only the apps that answered, which is the other half of the same correction. - * - * Deleting by user id would take the rows of apps that were refused or never reached, and - * those are exactly the rows the recovery needs. Deleting none — what a throw used to do — - * leaves a row and an open `(toolkit, user_id)` gate for an account that is already gone at - * Composio, so the table claims a connection this person does not have. - */ - if (withdrawn.length > 0) { - await database.delete(brokeredConnections).where( - and( - eq(brokeredConnections.holder, "person"), - eq(brokeredConnections.userId, userId), - inArray( - brokeredConnections.app, - withdrawn.map((entry) => entry.toolkit), - ), - ), - ); - } - - for (const connection of withdrawn) { - retired += 1; - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - // The app, which for a brokered connection is all the row records. The `mcp_servers` row - // it belongs to may have been removed already, and the connection outlives that too. - targetId: connection.toolkit, - payload: { - actor: by, - server: connection.toolkit, - owner: userId, - reason: "person_removed", - /* - * What was asked of the vendor, not that a call was made — {@link - * ComposioBroker.revoke}'s own answer, passed through, and the one place this half - * differs from the vault loop above. There the grant at Google outlives our copy of - * the secret and nothing was asked of anybody, so the field can only say false; here - * there was no secret of ours and the account itself was deleted at Composio with its - * withdrawal asked for, or there was nothing to ask about, or there was no broker to - * ask. The value of the field is exactly that a reader can tell those apart, so a - * constant here would be worse than none. - */ - vendorRevocationRequested: connection.requested, - }, - }); - } - - /* - * Loud, after every app has been asked and every answer recorded. - * - * The first, because the route turns this into a 500 and one sentence is what reaches the - * administrator; the rest are the same act failing more than once, and the trail above already - * says which apps did not end. Thrown last rather than first so a refusal on one app cannot - * cost the record of another — which is the whole of this change. - */ - if (refusals.length > 0) throw refusals[0]; - - return { retired }; - }, - - /** - * May this Bot use this plugin? - * - * The single question every caller asks, so there is one place the answer is decided and one - * place to audit it. A missing row is a refusal, not an oversight. - */ - async decide( - kind: PluginKind, - ref: string, - agentId: string, - ): Promise { - const [row] = await database - .select() - .from(pluginGrants) - .where( - and( - eq(pluginGrants.kind, kind), - eq(pluginGrants.ref, ref), - eq(pluginGrants.agentId, agentId), - ), - ) - .limit(1); - - if (!row) { - return { - allowed: false, - reason: - kind === "mcp" - ? `This Bot has not been given the tool ${ref}.` - : `This Bot has not been given the skill ${ref}.`, - }; - } - return { allowed: true }; - }, - - /** - * Call a tool on somebody else's server, on a Bot's behalf. - * - * Decide, record, then act, which is the order the computer gateway uses and for the same - * reason: a call that was permitted and then failed is exactly what an investigation needs to - * see, and a trail written only on success cannot show it. The grant is checked first because a - * tool this Bot was never given should not reach the policy engine, the vault or the network. - */ - async callTool(input: { - ref: string; - args: Record; - botId: string; - actorId: string; - initiator?: AuditInitiator; - /** - * Whose connected account the call goes out on, when that is not the asker's: a Team Bot - * reaching its owner's account (team-bots/team-bots.ts). Every gate is still the asker's. - */ - credentialActorId?: string; - }): Promise<{ text: string; isError: boolean }> { - const [serverId, ...rest] = input.ref.split("/"); - const toolName = rest.join("/"); - if (!serverId || !toolName) { - 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, { - eventType: "mcp.call_rejected", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - payload: { - actor: auditActor, - bot: input.botId, - server: serverId, - tool: toolName, - refusal: "not_granted", - reason: decision.reason, - }, - }); - throw new PluginRefusedError(decision.reason, null); - } - - const { row, entry, access, app } = await requireServer(serverId); - - const advertised = await database - .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, - advertised[0]?.effect, - ); - - const args = withoutEmptyOptionals( - input.args, - advertised[0]?.inputSchema as Record | undefined, - ); - - /* - * The version this action was listed at, handed to the transport that needs one. - * - * Under a reserved key rather than as a parameter on the shared signature, because that - * signature is MCP's and three other transports implement it. The Composio transport strips - * the key before anything reaches the vendor, and asserts that it did. - * - * A `__version` in the model's own arguments is not an argument: it is this key, and no - * vendor publishes it. So it is stripped unconditionally, whatever its value, and that strip - * is the whole protection. The recorded version is then merged into arguments that provably - * cannot carry the key, which makes both spread orders identical: the merge order has no - * reachable failure mode. Do not read the strip as belt-and-braces on top of an ordering - * guarantee — the ordering is the redundant half, and removing the strip is what would let a - * model choose which revision of an action runs. - * - * Absent when the app has not been refreshed since the column existed, and because the key - * was stripped there is then no version at all for the transport to read, which is what makes - * its refusal hold rather than guessing — a guessed version is a call against an action's - * other behaviour. - */ - const { [VERSION_ARG]: _dropped, ...modelArgs } = args; - const vendorArgs = advertised[0]?.version - ? { ...modelArgs, [VERSION_ARG]: advertised[0].version } - : modelArgs; - - /** - * The same policy the computer actions are judged by, asked about a tool call. - * - * Every field is present, including the ones a tool call has no use for, and that is load - * bearing rather than tidy. This engine treats an expression it cannot evaluate as a match, - * which is correct for a browser action on an element the server could not resolve. Applied to - * a tool call it is a disaster: the boundary this product ships in `.env.example` denies - * `contains(element.name, "submit") || key == "Enter"`, and with `element` and `key` absent - * that rule is unevaluable, so it would match, so every deployment using the shipped preset - * would refuse every MCP call for a reason mentioning a submit button. - * - * Neutral values instead. Empty strings match no substring, no key and no extension, so a rule - * written about the browser evaluates to false against a tool call, which is the honest answer: - * a tool call did not click anything. A rule meant to catch tool calls says so, with `mcp` or - * with `intent`. - */ - const context: PolicyContext = { - tool: { name: toolNameFor(input.ref) }, - bot: { id: input.botId }, - actor: { id: input.actorId }, - page: { url: "", host: "" }, - element: { ref: "", role: "", name: "", type: "" }, - key: "", - file: { path: "", name: "", extension: "" }, - // Empty, like the browser fields above: an MCP call runs no shell command, but a - // `deny: contains(command, "rm -rf")` names `command`, and an unbound identifier throws and - // fails closed — which would refuse every MCP call once a deployment wrote a rule about its - // shell. An empty command matches no such rule. - command: "", - intent: effect === "write" ? "write_tool" : "read_tool", - mcp: { server: serverId, tool: toolName, effect }, - /* - * The real one, and this is the path where it is not neutral. A routine's turn reaches its - * tools through here, carrying the initiator its run assertion was signed with, so this is - * where `initiator.kind == "routine"` becomes a rule a deployment can actually write. A - * chat turn arrives with none and reads as a person. - */ - initiator: policyInitiator(input.initiator), - }; - - const verdict = evaluateActionPolicy(options.policy(), context); - - /* - * The parts of the row that are known before the attempt, held rather than written. - * - * Everything here is a fact about the decision, and the decision is final at this point. What - * is NOT yet known is whether the call worked, which is why this is a variable and not a write: - * the row goes down once, after the outcome exists. - */ - const decided = { - actor: auditActor, - bot: input.botId, - server: serverId, - tool: toolName, - effect, - /* - * Whose credential this call goes out with. - * - * Without it the trail cannot answer "who did this run reach as", which is the whole question - * 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( - access, - input.credentialActorId ?? input.actorId, - ), - decision: { - allowed: verdict.allowed, - mode: verdict.mode, - rule: verdict.matched, - source: verdict.source, - carriedOut: verdict.forward, - }, - }; - - /* - * A refusal is written on the POLICY's answer, not on whether the call was then let through. - * - * This deployment declining is the whole event, and it is recorded before the throw so that a - * refusal cannot be lost by the caller's error handling. - * - * In `dry-run` the policy still refuses and the mode forwards anyway, which is the whole point - * of the mode: `evaluateActionPolicy` returns `allowed: false` with `forward: true` so a rule - * can be tried against live traffic before it starts refusing anybody. Writing this row on - * `forward` therefore recorded nothing at all on this surface for exactly the traffic an - * operator switched dry-run on to measure — the browser gateway keys its row on - * `decision.allowed` and does record it — so `Blocked` on the audit page, and every - * `eventType=mcp.call_rejected` query behind it, answered "this rule would refuse none of your - * tool calls" about calls it would refuse. The rule then looked inert, and enforcing it - * started refusing Bots with no warning in the trail. - * - * `decision.carriedOut` is what tells the two rows apart: false is a call this deployment - * stopped, true is one dry-run recorded and let past. The outcome row below is unchanged, so a - * forwarded call still says separately whether the vendor answered. - */ - if (!verdict.allowed) { - await recordAuditEvent(auditStore, { - eventType: "mcp.call_rejected", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - payload: decided, - }); - } - if (!verdict.forward) { - throw new PluginRefusedError(verdict.reason, verdict.matched); - } - - /** - * Structural policy answers whether this Bot may call this tool. Content inspection answers - * whether the arguments would carry a credential out of the deployment. It runs after policy - * and before credentials are read or a vendor is contacted, and its result contains paths and - * categories only: never the values it refused. - * - * IT IS ASKED ABOUT WHAT WOULD BE SENT, WHICH IS `vendorArgs` AND NOT `args`. - * - * CRITERION. The object this inspection judges is the object handed to the transport below, - * identically — not a version of it taken before the reserved key was stripped and the - * recorded one merged in. - * - * REASON. It was asked about `args`, so the gate and the call were about two different - * objects, and they came apart in both directions. Whatever is merged in below the strip left - * this deployment WITHOUT HAVING BEEN LOOKED AT, which is not a boundary at all — it is a - * boundary around a neighbouring value. And the reserved key, which is stripped - * unconditionally and provably reaches no vendor, was still judged: a model that put anything - * credential-shaped under `__version` had its granted call refused and its person told the - * arguments carry credential material, over material that was never going anywhere and a - * refusal no rule of this deployment's asked for. - */ - const contentDecision = inspectToolArguments(vendorArgs); - if (!contentDecision.safe) { - await recordAuditEvent(auditStore, { - eventType: "mcp.call_rejected", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - payload: { - ...decided, - decision: { ...decided.decision, carriedOut: false }, - refusal: "sensitive_tool_arguments", - contentInspection: { - reason: contentDecision.reason, - findings: contentDecision.findings, - }, - }, - }); - throw new PluginRefusedError( - contentDecision.reason === "sensitive_content" - ? "The tool call was refused because its arguments contain credential material." - : "The tool call was refused because its arguments could not be inspected safely.", - null, - ); - } - - /* - * A write that sends content to other people asks the owner first, whatever their general - * approvals switch says. Pending throws the check's own wait, which a headless turn and the - * open chat both turn into a paused tool call; denied is a refusal like any other. - */ - const share = - effect === "write" && options.privateShareCheck - ? shareTargetOf(serverId, toolName, vendorArgs) - : null; - if (share && options.privateShareCheck) { - const shareVerdict = await options.privateShareCheck({ - ownerUserId: input.actorId, - botId: input.botId, - audience: share.audience, - content: share.content, - origin: { kind: "unknown" }, - }); - if (shareVerdict.status !== "allowed") { - await recordAuditEvent(auditStore, { - eventType: "mcp.call_rejected", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - payload: { - ...decided, - decision: { ...decided.decision, carriedOut: false }, - refusal: - shareVerdict.status === "pending" - ? "private_share_pending" - : "private_share_denied", - }, - }); - // A pending share outside any run that can wait says so and sends nothing. - if (shareVerdict.status === "pending" && currentApprovalContext()) - throw shareVerdict.suspension; - throw new PluginRefusedError(shareVerdict.message, null); - } - } - - /* - * A SHARED ACCOUNT ANSWERS TO WHOEVER CAN STEER THE BOT, and that is checked on every call. - * - * The grant says this Bot may use the app; it says nothing about who may reach the Bot. A - * Shared app acts as one team account for everyone, so a Bot opened to more people than an - * administrator approved would hand that account to all of them. The gate decides that here, - * at call time, so an audience widened a moment ago is caught on its next call. With no gate - * wired there is nothing that can answer the question, and the call is refused rather than - * assumed safe. - */ - /* - * DECIDED ON, AND KEYED BY, THE APP'S ANSWERING ROW — never the dialled one. A duplicate row - * at a Shared app's url saying `personal` must not take the call round this gate, and an - * approval is a fact about the app, so it is asked for under the app's id whichever row the - * grant happens to name. `gatedMode` then travels to {@link connectionTokenFor}, which - * refuses if the account it resolves is not the one this gate was decided about. - */ - const gatedMode: AccountMode | null = - access.credential !== "brokered" - ? null - : app?.accountMode === "shared" - ? "shared" - : "personal"; - if (gatedMode === "shared" && app) { - const verdict = options.sharedUse - ? await options.sharedUse({ - botId: input.botId, - serverId: app.id, - title: row.title, - actorId: input.actorId, - ...(input.initiator ? { initiator: input.initiator } : {}), - }) - : { - allowed: false as const, - message: `${row.title} is shared, and this deployment cannot check who may use it, so it was not called.`, - }; - if (!verdict.allowed) { - await recordAuditEvent(auditStore, { - eventType: "mcp.call_rejected", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - payload: { - ...decided, - decision: { ...decided.decision, carriedOut: false }, - refusal: verdict.refusal ?? "shared_audience", - }, - }); - throw new PluginRefusedError(verdict.message, null); - } - } - - /* - * THE APPROVAL GATE IS ASKED UNDER THE APP'S ID TOO, for a brokered row whose app has an - * answering row. Rules are written against the app — the Shared default is `${appId}/*` - * scoped to the app's id — so a write granted through a duplicate row has to be judged as - * the app's, or it matches no rule and walks past the ask an administrator set. `target` - * still names the dialled row, which is what the call itself goes to; every other row is - * asked about exactly as before. - */ - const gateApp = access.credential === "brokered" ? app : null; - const approval = await options.approvalGate?.({ - actorId: input.actorId, - botId: input.botId, - toolRef: gateApp ? `${gateApp.id}/${toolName}` : input.ref, - effect, - scope: gateApp ? gateApp.id : serverId, - args: vendorArgs, - target: { serverId, toolName, url: row.url, effect }, - continuation: currentApprovalContext(), - }); - if (approval && "replay" in approval) - return approval.replay as { text: string; isError: boolean }; - - /* - * Attempt first, record second. - * - * The row now says what HAPPENED rather than what was permitted. It used to be written here, - * before the two lines below, which meant a call that died at the vendor left `call_succeeded` - * behind it — and a per-person connector fails at exactly these two lines: no connection for - * the asker, a refresh token the vendor no longer accepts, an API not enabled for the project. - * Every one of those was invisible, and worse than invisible, because the trail asserted the - * opposite. - * - * `isError` counts as a failure. A vendor that answers the protocol correctly to say the tool - * itself failed has not completed the call, and a reader counting successes should not be told - * it did. - */ - try { - const { token, vendorUserId } = await connectionTokenFor( - row, - entry, - input.credentialActorId ?? input.actorId, - access, - gatedMode, - ); - const vendor = - injectedVendor ?? transportFor(access.transport).callTool; - const result = await vendor( - { - url: effectiveUrl(row, entry), - token, - actorId: vendorUserId ?? input.credentialActorId ?? input.actorId, - botId: input.botId, - }, - toolName, - vendorArgs, - ); - await recordAuditEvent(auditStore, { - eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded", - targetType: "mcp_tool", - targetId: input.ref, - ...(input.initiator ? { initiator: input.initiator } : {}), - /* - * The vendor's own words, when it is reporting a failure. - * - * Only on the failure branch, and this is the whole point of the distinction. A successful - * result is somebody's data — a file listing, a document — and it has no business in an - * audit row that an administrator can read. An `isError` result is a message written for - * whoever operates this deployment, and it is the most useful sentence available: Google - * refuses the Drive MCP server with "The caller does not have permission", which named the - * problem after a generic message had already cost a round of probing. - * - * Capped, because the failure branch is not a promise about length. - */ - payload: result.isError - ? { - ...decided, - failure: - result.text.slice(0, 400) || "the tool reported an error", - } - : decided, - }); - const answer = { text: result.text, isError: result.isError }; - await approval?.complete(answer); - return answer; - } catch (error) { - /* - * Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is - * that the failure now exists in the trail, which is where somebody asking "is this connector - * working" looks. The vendor's own sentence is kept, since for a 403 that is the sentence - * naming which API is not enabled. - * - * Capped like the `isError` branch above, and for the same reason: parts of this sentence - * came from the vendor, and a failure is not a promise about length. - */ - await recordAuditEvent(auditStore, { - eventType: "mcp.call_failed", - targetType: "mcp_tool", - targetId: input.ref, - ...(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 - ? withoutStatement(error) - : String(error) - ).slice(0, 400), - }, - }); - throw error; - } - }, + ...serverMethods(context), + ...skillMethods(context), + ...grantMethods(context), + ...connectionMethods(context), + ...callMethods(context), }; } -/** - * Optional arguments the model filled in with an empty string, removed. - * - * A model handed a schema with many optional fields tends to fill them all, and where it has no - * value it writes "". Vendors reject that: an empty string is not a channel id, not a timestamp and - * not a cursor, so the call fails with a validation error that reads to the person as the tool being - * broken. - * - * Only optional fields, and only empty strings. A required field left empty is the model getting it - * wrong, and the vendor should say so rather than have us hide it. Anything other than "" is a value - * the model meant, including false and 0. - */ -function withoutEmptyOptionals( - args: Record, - schema: Record | undefined, -): Record { - const required = new Set( - Array.isArray(schema?.required) ? (schema.required as string[]) : [], - ); - return Object.fromEntries( - Object.entries(args).filter( - ([key, value]) => required.has(key) || value !== "", - ), - ); -} - export type PluginStore = ReturnType; -export type { CatalogueEntry }; diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts index 75bbdd8c5..be15ae675 100644 --- a/server/src/plugins/tools.ts +++ b/server/src/plugins/tools.ts @@ -1,7 +1,7 @@ -import { z } from "zod"; import { ApprovalRefusedError } from "../approvals/types"; import type { AuditInitiator } from "../audit"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { type GrantedTool, parametersFor } from "@openbot/toolkit/granted-tool"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { markUntrusted } from "../untrusted-content"; import type { SelectableSkill } from "./selection"; import { @@ -64,38 +64,6 @@ export function vendorAnswer(result: { text: string; isError: boolean }) { : markUntrusted(result.text, "connector result"); } -export type GrantedTool = { - name: string; - description: string; - parameters: z.ZodType; - execute: (args: unknown) => Promise; - initiator?: AuditInitiator; - /** - * `/`, carried alongside the name the model is offered. - * - * The two spellings exist because a model tool name may not contain a slash, and selection has to - * compare a tool against what a skill declared, which is written in the ref spelling because that - * is how a grant is written. Carried rather than derived at the comparison, so there is one place - * the two forms are converted (`toolNameFor`) and no second parser to drift from it. - */ - ref: string; -}; - -/** - * A vendor's JSON Schema as something the model can be handed. - * - * Anything that is not an object schema describes something other than a tool's arguments, and a - * schema we cannot read must not stop the tool being offered: an open object lets the model call it - * and lets the vendor be the one to reject a bad argument, which is where that error belongs. - */ -export function parametersFor(inputSchema: Record): z.ZodType { - try { - const converted = z.fromJSONSchema(inputSchema as never); - if (converted instanceof z.ZodObject) return converted; - } catch {} - return z.object({}).catchall(z.unknown()); -} - /** * What this Bot holds, said in its instructions rather than left to be inferred from a tool list. * diff --git a/server/src/proactive/engine.ts b/server/src/proactive/engine.ts index 2d852a28b..d87244f34 100644 --- a/server/src/proactive/engine.ts +++ b/server/src/proactive/engine.ts @@ -10,10 +10,10 @@ */ import { z } from "zod"; import type { AuditInitiator } from "../audit"; -import type { HeadlessTool } from "../computer/headless-tools"; import type { MemoryStore } from "../memory/store"; import { REFUSAL_MARKER } from "../plugins/tools"; -import type { TurnRunner } from "../routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; import type { WorkQueue } from "../work/queue"; import { proactiveInitiator, diff --git a/server/src/proactive/private-share.ts b/server/src/proactive/private-share.ts index 00d013c69..524a1e317 100644 --- a/server/src/proactive/private-share.ts +++ b/server/src/proactive/private-share.ts @@ -25,7 +25,7 @@ import { approvalPreview, currentApprovalContext, } from "../approvals/types"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; export const PRIVATE_SHARE_TOOL_REF = "openbot/share_private_information"; export const PRIVATE_SHARE_EFFECT = "share_private"; diff --git a/server/src/proactive/restriction.ts b/server/src/proactive/restriction.ts index 014784fdf..cef3bea84 100644 --- a/server/src/proactive/restriction.ts +++ b/server/src/proactive/restriction.ts @@ -16,7 +16,8 @@ * that could not be read, a tool with no recorded effect. */ import type { AuditInitiator } from "../audit"; -import { type GrantedTool, REFUSAL_MARKER } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { REFUSAL_MARKER } from "../plugins/tools"; export const PROACTIVE_INITIATOR_PREFIX = "proactive:"; diff --git a/server/src/provider-oauth.ts b/server/src/provider-oauth.ts index 75d48cd52..515d10bec 100644 --- a/server/src/provider-oauth.ts +++ b/server/src/provider-oauth.ts @@ -14,6 +14,7 @@ import { CredentialLockUnavailable, lockProviderCredentials, } from "./provider-oauth-lock"; +import { OAUTH_REQUEST_TIMEOUT_MS } from "./timeouts"; export type ModelOAuthRecord = { version: 1; @@ -167,7 +168,7 @@ export function createProviderOAuthProxy( { method: "POST", redirect: "error", - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(OAUTH_REQUEST_TIMEOUT_MS), headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", diff --git a/server/src/responsibilities/engine.ts b/server/src/responsibilities/engine.ts index aebacf7c6..a999195a2 100644 --- a/server/src/responsibilities/engine.ts +++ b/server/src/responsibilities/engine.ts @@ -1,5 +1,5 @@ import { BOT_PAUSED_REASON, isBotPaused } from "../agents/lifecycle"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import type { WorkQueue } from "../work/queue"; import type { ResponsibilityRunContext, ResponsibilityStore } from "./types"; diff --git a/server/src/responsibilities/tools.ts b/server/src/responsibilities/tools.ts index c4a3b5ee2..cd3a9e421 100644 --- a/server/src/responsibilities/tools.ts +++ b/server/src/responsibilities/tools.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; -import type { GrantedTool } from "../plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import type { ResponsibilityEngine } from "./engine"; import { ResponsibilityNotFoundError, diff --git a/server/src/responsibilities/types.ts b/server/src/responsibilities/types.ts index ec3362002..a2034e86b 100644 --- a/server/src/responsibilities/types.ts +++ b/server/src/responsibilities/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { HeadlessWaiting } from "../computer/headless-tools"; +import type { HeadlessWaiting } from "@openbot/toolkit/headless-tools"; export const responsibilitySources = [ "manual", diff --git a/server/src/responsibilities/wiring.ts b/server/src/responsibilities/wiring.ts new file mode 100644 index 000000000..21ad8d4f9 --- /dev/null +++ b/server/src/responsibilities/wiring.ts @@ -0,0 +1,189 @@ +import { buildResponsibilityTurn } from "@openbot/shared/responsibility-turn"; +import type { ComponentCatalog } from "@openbot/toolkit/ports"; +import { + createTurnRunner, + type IntelligenceLike, + type RunnerLike, +} from "@openbot/toolkit/run-turn"; +import { z } from "zod"; +import { canUseComputer } from "../agents/computer-access"; +import type { AgentProfileStore } from "../agents/profile-store"; +import type { AgentActor } from "../agents/profile-types"; +import { serverTurnAdmission } from "../agents/turn-admission"; +import { + parseApprovalContinuation, + parseApprovalResult, +} from "../approvals/types"; +import type { AuditInitiator, AuditStore } from "../audit"; +import type { ChannelStore } from "../channels/routes"; +import type { ComputerGateway } from "../computer/gateway"; +import { createHeadlessComputerTools } from "../computer/headless-tools"; +import type { mountCopilotRuntime } from "../copilot"; +import type { Database } from "../db/client"; +import type { DeliveryScope } from "../delivery/types"; +import { markUntrusted } from "../untrusted-content"; +import { createWorkQueue } from "../work/queue"; +import { + createResponsibilityEngine, + type ResponsibilityEngine, +} from "./engine"; +import { responsibilityTools } from "./tools"; +import type { ResponsibilityStore } from "./types"; + +/** + * The deployment's responsibility engine: a responsibility's turn, with the computer when the Bot + * may use it and the responsibility's own tools, and its reply posted to the channel it watches. + * + * Moved out of index.ts verbatim. `notify` is taken as a function because delivery is assigned after + * this is created, and a reply reads it when it is posted, never when this is built. + */ +export function createServerResponsibilityEngine(deps: { + database: Database; + responsibilityStore: ResponsibilityStore; + routineIntelligence: IntelligenceLike; + routineAgentRunner: RunnerLike; + componentCatalog: ComponentCatalog; + buildAgentFor: Parameters[0]["buildAgentFor"]; + actorFor: (ownerUserId: string) => Promise; + computerGateway: ComputerGateway | undefined; + agentProfileStore: AgentProfileStore; + bootAuditStore: AuditStore; + signInService: Parameters[0]["signIn"]; + copilotRuntime: Pick, "learning">; + channelStore: ChannelStore; + notify: ( + scope: DeliveryScope, + input: { id: string; kind: "reply"; text: string }, + ) => Promise; +}): ResponsibilityEngine { + const { + database, + responsibilityStore, + routineIntelligence, + routineAgentRunner, + componentCatalog, + buildAgentFor, + actorFor, + computerGateway, + agentProfileStore, + bootAuditStore, + signInService, + copilotRuntime, + channelStore, + } = deps; + const responsibilityQueue = createWorkQueue(database); + const responsibilityEngine: ResponsibilityEngine = createResponsibilityEngine( + { + store: responsibilityStore, + queue: responsibilityQueue, + runTurn: async (context) => { + const initiator: AuditInitiator = { + kind: "responsibility", + id: context.responsibilityId, + }; + const tools = responsibilityTools({ + store: responsibilityStore, + engine: responsibilityEngine, + ownerUserId: context.ownerUserId, + agentId: context.agentId, + channelId: context.channelId, + responsibilityRunId: context.runId, + }); + const runner = createTurnRunner({ + admission: serverTurnAdmission, + intelligence: routineIntelligence, + runner: routineAgentRunner, + components: componentCatalog, + buildAgentFor: (input) => buildAgentFor({ ...input, initiator }), + toolsForTurn: async () => { + const actor = await actorFor(context.ownerUserId); + // A Team Bot's teammate uses the Bot, not its owner's signed-in computer. + const computer = + computerGateway && + canUseComputer( + await agentProfileStore + .get(actor, context.agentId) + .catch(() => null), + actor, + ) + ? createHeadlessComputerTools({ + gateway: computerGateway, + botId: context.agentId, + actor: { id: actor.id, userId: actor.id, initiator }, + auditStore: bootAuditStore, + ...(signInService ? { signIn: signInService } : {}), + }) + : []; + return [ + ...computer, + ...tools.map((tool) => ({ + definition: { + name: tool.name, + description: tool.description, + parameters: z.toJSONSchema(tool.parameters), + }, + execute: (args: unknown) => tool.execute(args), + })), + ]; + }, + learningContainerForThread: (input) => + copilotRuntime.learning?.containerForThread(input) ?? + Promise.resolve(undefined), + }); + const saved = context.continuation; + const snapshot = saved + ? parseApprovalContinuation(saved.waiting.continuation) + : undefined; + const toolResult = saved + ? parseApprovalResult(saved.response) + : undefined; + return runner({ + ownerUserId: context.ownerUserId, + routineId: context.responsibilityId, + agentId: context.agentId, + threadId: context.threadId, + initiator, + signal: context.signal, + ...(snapshot && toolResult + ? { + continuation: { + snapshot, + result: toolResult, + messageId: `responsibility-result:${context.runId}:${snapshot.toolCallId}`, + }, + } + : {}), + instruction: buildResponsibilityTurn({ + instruction: context.instruction, + successCriteria: context.successCriteria, + progress: context.progress, + trigger: context.event, + eventData: markUntrusted( + JSON.stringify(context.event.payload), + `${context.event.source} event`, + ), + responsibilityId: context.responsibilityId, + }), + }); + }, + onReply: async (context, text) => { + await channelStore.recordActivity( + await actorFor(context.ownerUserId), + context.channelId, + { text, agentId: context.agentId, at: new Date() }, + { id: `responsibility:${context.runId}` }, + ); + await deps.notify( + { + ownerUserId: context.ownerUserId, + channelId: context.channelId, + agentId: context.agentId, + threadId: context.threadId, + }, + { id: `responsibility:${context.runId}`, kind: "reply", text }, + ); + }, + }, + ); + return responsibilityEngine; +} diff --git a/server/src/routines/runner.ts b/server/src/routines/runner.ts index c2393c996..9c8fd627c 100644 --- a/server/src/routines/runner.ts +++ b/server/src/routines/runner.ts @@ -22,41 +22,13 @@ * within a firing never sees the routine that fails cleanly, once, every single night. */ -import type { Message } from "@ag-ui/client"; import { BOT_PAUSED_REASON, isBotPaused } from "../agents/lifecycle"; import type { AgentActor } from "../agents/profile-types"; -import type { ApprovalContinuation } from "../approvals/types"; -import type { AuditInitiator } from "../audit"; import type { ChannelStore } from "../channels/routes"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; import type { RoutineStore } from "./store"; -/** Everything a headless turn needs, injectable so tests never dial a model. */ -export type TurnRunner = (input: { - ownerUserId: string; // the actor the run asserts — grants and connections resolve to them - routineId: string; // what the trail names as having started this turn, rather than the owner - agentId: string; - threadId: string; // the owner's thread for the routine's channel - instruction: string; // the user message of this turn - initiator?: AuditInitiator; - signal?: AbortSignal; - continuation?: { - snapshot: ApprovalContinuation; - result: { content: string; error?: string }; - messageId: string; - }; - userMessage?: Message; - /** Durable inbound delivery id; reused by lock, runner and emitted events. */ - runId?: string; - /** How deep in a Bot-to-Bot chain this turn is. Absent is a person's own turn, depth zero. */ - depth?: number; - /** The reply so far, each time more of it arrives, for a surface that shows a turn in progress. */ - onText?: (text: string) => void; -}) => Promise<{ replyText: string; components?: DrawnComponent[] }>; - -/** A display component the Bot drew in a turn and the server allowed, with the props it drew. */ -export type DrawnComponent = { name: string; args: Record }; - export type RoutineRunner = { run(routineRunId: string): Promise }; /** diff --git a/server/src/routines/store.ts b/server/src/routines/store.ts index b1fd0e939..19d6d9d31 100644 --- a/server/src/routines/store.ts +++ b/server/src/routines/store.ts @@ -33,7 +33,6 @@ import { ne, sql, } from "drizzle-orm"; -import type { HeadlessWaiting } from "../computer/headless-tools"; import type { Database } from "../db/client"; import { channelAgents, @@ -43,6 +42,7 @@ import { routineSweeps, routines, } from "../db/schema"; +import type { HeadlessWaiting } from "@openbot/toolkit/headless-tools"; import { describeCron, nextOccurrence, ScheduleRefusedError } from "./schedule"; const SWEEP_ID = "routines"; diff --git a/server/src/routines/sweep.ts b/server/src/routines/sweep.ts index 82e0adf70..01860d06a 100644 --- a/server/src/routines/sweep.ts +++ b/server/src/routines/sweep.ts @@ -21,6 +21,7 @@ * A second half-right copy grown next to it is the duplicated firing mechanism #235 exists to * prevent. */ +import { TURN_LIMITS } from "@openbot/toolkit/limits"; import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue"; import { RoutineRefusedError, type RoutineStore } from "./store"; @@ -61,13 +62,12 @@ export const DEFAULT_GRACE_MS = 10 * 60_000; * * A server that dies mid-turn strands its run row for ever: the queue item was finished on the 202, * so no retry comes back for it, and nothing else writes that row — the routines page reads - * "running now" for a run no process is running. Twice the server's own turn timeout - * (`DEFAULT_TURN_TIMEOUT_MS` in `./run-turn`, five minutes), so a slow-but-alive turn is never - * closed out from under the server still running it. A local constant rather than an import because - * the sweep runs as a CronJob and must not drag the runtime's import graph — the Intelligence - * client and everything behind it — into that process. + * "running now" for a run no process is running. Twice the turn timeout, so a slow-but-alive turn is + * never closed out from under the server still running it. Derived rather than copied, so raising the + * turn timeout cannot quietly make the sweep close turns that are still running. `limits` has no + * imports, so the CronJob still does not drag the runtime's import graph into its process. */ -const ABANDONED_RUN_MS = 10 * 60_000; +const ABANDONED_RUN_MS = 2 * TURN_LIMITS.turnTimeoutMs; export type RoutineSweepOptions = { routineStore: RoutineStore; diff --git a/server/src/timeouts.ts b/server/src/timeouts.ts new file mode 100644 index 000000000..958064608 --- /dev/null +++ b/server/src/timeouts.ts @@ -0,0 +1,20 @@ +/** + * How long the server waits on another service, for the calls that share a reason. + * + * Only values that several call sites share for the same reason live here. A timeout that belongs + * to one call stays next to it (the plugin store's token timeout, Google Drive's listing timeout), + * because its reason is about that call. + */ + +/** + * One request to an OAuth or identity provider: a token exchange or refresh, a client registration, + * the account lookup after sign-in. A single round trip that either answers or is not going to. + */ +export const OAUTH_REQUEST_TIMEOUT_MS = 15_000; + +/** + * One hand-off to a delivery transport: a push notification, an SMS, a message to OpenTag. Longer + * than an OAuth request because these carry a person's message and a slow answer is still worth + * waiting for, and short enough that a stuck transport does not hold the delivery queue. + */ +export const DELIVERY_REQUEST_TIMEOUT_MS = 30_000; diff --git a/server/src/user-preferences.ts b/server/src/user-preferences.ts index cf1837077..4fd186d5e 100644 --- a/server/src/user-preferences.ts +++ b/server/src/user-preferences.ts @@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm"; import { DEFAULT_USER_PREFERENCES, type UserPreferences, -} from "../../shared/user-preferences"; +} from "@openbot/shared/user-preferences"; import type { Database } from "./db/client"; import { users } from "./db/schema"; diff --git a/server/src/voice/session-routes.ts b/server/src/voice/session-routes.ts index da7115772..e1e3305a1 100644 --- a/server/src/voice/session-routes.ts +++ b/server/src/voice/session-routes.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; -import type { VoiceSessionRecord } from "../../../shared/voice-session"; +import type { VoiceSessionRecord } from "@openbot/shared/voice-session"; import type { AgentActor } from "../agents/profile-types"; import type { AppVariables } from "../auth/guards"; import type { ChannelStore } from "../channels/routes"; diff --git a/server/src/voice/sessions.ts b/server/src/voice/sessions.ts index c72f2f5c0..6d1d3b2c6 100644 --- a/server/src/voice/sessions.ts +++ b/server/src/voice/sessions.ts @@ -4,7 +4,7 @@ import type { VoiceSessionPage, VoiceSessionRecord, VoiceTranscriptEntry, -} from "../../../shared/voice-session"; +} from "@openbot/shared/voice-session"; import type { AgentActor } from "../agents/profile-types"; import type { ChannelStore } from "../channels/routes"; import type { Database } from "../db/client"; diff --git a/server/src/voice/summary.ts b/server/src/voice/summary.ts index 44e82a3fc..9be4b0f7b 100644 --- a/server/src/voice/summary.ts +++ b/server/src/voice/summary.ts @@ -1,4 +1,4 @@ -import type { VoiceTranscriptEntry } from "../../../shared/voice-session"; +import type { VoiceTranscriptEntry } from "@openbot/shared/voice-session"; import { PlanModel } from "../agents/plan-model"; import type { TitlerOptions } from "../channels/titler"; import type { RuntimeModel } from "../copilot"; diff --git a/server/tests/agent-handoff-delivery.test.ts b/server/tests/agent-handoff-delivery.test.ts index aec0be3f0..d8dee484d 100644 --- a/server/tests/agent-handoff-delivery.test.ts +++ b/server/tests/agent-handoff-delivery.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { AbstractAgent, BaseEvent, Message } from "@ag-ui/client"; +import { LockLapsedError } from "@openbot/toolkit/lease"; import { Observable } from "rxjs"; import { createHandoffDelivery } from "../src/agents/handoff-delivery"; import type { HandoffWork } from "../src/agents/handoff-runner"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; /** * Turning a hop into a turn. @@ -60,6 +62,7 @@ function delivery( released, builtFor, delivery: createHandoffDelivery({ + admission: serverTurnAdmission, ...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }), @@ -332,6 +335,7 @@ describe("a delivery that never finishes", () => { }); // A run that emits nothing and never completes, which is what a stalled Bot looks like. const stalled = createHandoffDelivery({ + admission: serverTurnAdmission, deadlineMs: 20, agentFor: async () => stubAgent(), history: async () => PRIOR, @@ -355,6 +359,128 @@ describe("a delivery that never finishes", () => { void deliver; }); + /* + * A renewal that did not arrive is not a lock that was lost. Stopped here, the hop would be retried + * from the start, and a Bot that had already acted would act again. + */ + test("a renewal that merely failed does not stop the hop", async () => { + let renewals = 0; + const hop = createHandoffDelivery({ + admission: serverTurnAdmission, + deadlineMs: 5_000, + renewEveryMs: 2, + agentFor: async () => stubAgent(), + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: () => { + renewals += 1; + return Promise.reject(new TypeError("fetch failed")); + }, + release: async () => {}, + }, + runner: { + run: () => + new Observable((subscriber) => { + const finish = setTimeout(() => { + for (const event of FINISHED) subscriber.next(event); + subscriber.complete(); + }, 30); + return () => clearTimeout(finish); + }), + }, + }); + + await hop.deliver({ work: WORK, message: "m", shown: "s", assertion: "s" }); + expect(renewals).toBeGreaterThan(0); + }); + + /* + * A renewal refused as a conflict means somebody else holds the conversation now — on a relay, + * most likely the person, typing in their own channel. The hop stops rather than write its answer + * into their run, gives the lock back, and fails into a retry with the platform's reason. + */ + /* + * Renewals that never answer at all. No error ever arrives to say the lock was lost, so the only + * thing that can stop the hop is the lock's lifetime running out. + */ + test("renewals that never settle stop the hop once the lock's lifetime has passed", async () => { + const stopped: string[] = []; + const released: string[] = []; + const hop = createHandoffDelivery({ + admission: serverTurnAdmission, + deadlineMs: 5_000, + renewEveryMs: 2, + lockLifetimeMs: 20, + agentFor: async () => stubAgent(), + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: () => new Promise(() => {}), + release: async (input) => { + released.push(input.threadId); + }, + }, + runner: { + // A run that would go on for ever, so only the lapsed lock can end it. + run: () => new Observable(() => {}), + stop: async (input) => { + stopped.push(input.runId); + return true; + }, + }, + }); + + await expect( + hop.deliver({ work: WORK, message: "m", shown: "s", assertion: "s" }), + ).rejects.toBeInstanceOf(LockLapsedError); + expect(stopped).toEqual(["platform-run"]); + expect(released).toEqual(["scratch-thread"]); + }); + + test("a renewal refused as a conflict stops the hop and gives the lock back", async () => { + const conflict = Object.assign( + new Error("Intelligence platform error 409: THREAD_LOCK_RENEWAL_FAILED"), + { name: "PlatformRequestError", status: 409 }, + ); + const stopped: string[] = []; + const released: string[] = []; + const hop = createHandoffDelivery({ + admission: serverTurnAdmission, + deadlineMs: 5_000, + renewEveryMs: 2, + agentFor: async () => stubAgent(), + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + lock: { + acquire: async () => ({ runId: "platform-run" }), + renew: () => Promise.reject(conflict), + release: async (input) => { + released.push(input.threadId); + }, + }, + runner: { + // A run that would go on for ever, so only the lost lock can end it. + run: () => new Observable(() => {}), + stop: async (input) => { + stopped.push(input.runId); + return true; + }, + }, + }); + + await expect( + hop.deliver({ work: WORK, message: "m", shown: "s", assertion: "s" }), + ).rejects.toBe(conflict); + expect(stopped).toEqual(["platform-run"]); + expect(released).toEqual(["scratch-thread"]); + }); + test("the lock is given back on the conversation it was taken on", async () => { const { delivery: deliver, released } = delivery(FINISHED); @@ -449,6 +575,7 @@ describe("the working indicator", () => { function withBusy() { const busy: Array<{ threadId: string; busy: boolean }> = []; const deliver = createHandoffDelivery({ + admission: serverTurnAdmission, agentFor: async () => stubAgent(), history: async () => PRIOR, newRunId: () => "run-2", @@ -535,6 +662,7 @@ describe("what comes back for the relay", () => { /** A runner that drives the agent the way the real one does, then completes. */ function throughTheAgent(agent: AbstractAgent) { return createHandoffDelivery({ + admission: serverTurnAdmission, agentFor: async () => agent, history: async () => PRIOR, newRunId: () => "run-2", @@ -610,6 +738,7 @@ describe("what comes back for the relay", () => { { type: "TEXT_MESSAGE_END" }, ]); const deliver = createHandoffDelivery({ + admission: serverTurnAdmission, agentFor: async () => agent, history: async () => PRIOR, newRunId: () => "run-2", @@ -752,6 +881,7 @@ describe("a history read that fails", () => { test("takes no lock, so nothing is leaked for the retry to collide with", async () => { const lockCalls: string[] = []; const deliver = createHandoffDelivery({ + admission: serverTurnAdmission, agentFor: async () => stubAgent(), history: async () => { throw new Error("the platform answered 500"); @@ -837,6 +967,7 @@ describe("a hop to a Bot that is paused while it works", () => { started = resolve; }); const hop = createHandoffDelivery({ + admission: serverTurnAdmission, deadlineMs: 5_000, agentFor: async () => stubAgent(), history: async () => PRIOR, @@ -877,4 +1008,58 @@ describe("a hop to a Bot that is paused while it works", () => { resetBotLifecycleForTests(); } }); + + /* + * Admitted before the lock, as every headless turn is. A hop used to take the conversation's lock + * first and only then find the Bot paused, so a paused Bot's relay held the person's own channel + * for a moment on every retry. + */ + test("a Bot paused before the hop never touches the lock and never runs", async () => { + const { BotPausedError, configureBotLifecycle, resetBotLifecycleForTests } = + await import("../src/agents/lifecycle"); + const query = { + from: () => query, + where: () => query, + limit: async () => [{ pausedAt: new Date() }], + }; + configureBotLifecycle({ + database: { select: () => query } as unknown as Parameters< + typeof configureBotLifecycle + >[0]["database"], + }); + const lockCalls: string[] = []; + let runs = 0; + const hop = createHandoffDelivery({ + admission: serverTurnAdmission, + agentFor: async () => stubAgent(), + history: async () => PRIOR, + newRunId: () => "run-2", + mintThreadId: () => "scratch-thread", + lock: { + acquire: async () => { + lockCalls.push("acquire"); + return { runId: "platform-run" }; + }, + renew: async () => {}, + release: async () => { + lockCalls.push("release"); + }, + }, + runner: { + run: () => { + runs += 1; + return new Observable(() => {}); + }, + }, + }); + try { + await expect( + hop.deliver({ work: WORK, message: "m", shown: "s", assertion: "s" }), + ).rejects.toBeInstanceOf(BotPausedError); + expect(lockCalls).toEqual([]); + expect(runs).toBe(0); + } finally { + resetBotLifecycleForTests(); + } + }); }); diff --git a/server/tests/approvals-native.test.ts b/server/tests/approvals-native.test.ts index 9d3c8b1df..9164a0f39 100644 --- a/server/tests/approvals-native.test.ts +++ b/server/tests/approvals-native.test.ts @@ -1,8 +1,8 @@ import { expect, spyOn, test } from "bun:test"; import { z } from "zod"; -import { buildAgents } from "../src/copilot"; import { currentApprovalContext } from "../src/approvals/types"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; +import { buildAgents } from "../src/copilot"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; test("a real built-in native tool carries provider call identity and suspends AG-UI without a fake tool result", async () => { const transport = spyOn(globalThis, "fetch").mockImplementation( diff --git a/server/tests/approvals-policy.integration.test.ts b/server/tests/approvals-policy.integration.test.ts index 664b1caff..d99aa6c38 100644 --- a/server/tests/approvals-policy.integration.test.ts +++ b/server/tests/approvals-policy.integration.test.ts @@ -7,7 +7,6 @@ import { } from "../src/approvals/service"; import { createApprovalStore } from "../src/approvals/store"; import type { ApprovalCandidate } from "../src/approvals/types"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { createDatabase } from "../src/db/client"; import { approvalTeamRules, @@ -16,6 +15,7 @@ import { } from "../src/db/schema"; import { users } from "../src/db/schema/core"; import { workItems } from "../src/db/schema/work"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; /** diff --git a/server/tests/approvals-policy.test.ts b/server/tests/approvals-policy.test.ts index 003b3263e..9d2027cab 100644 --- a/server/tests/approvals-policy.test.ts +++ b/server/tests/approvals-policy.test.ts @@ -27,9 +27,9 @@ import { type ApprovalTeamSettings, withApprovalContext, } from "../src/approvals/types"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { createHostAccessBroker } from "../src/host-access/broker"; import { createPrivateShareCheck } from "../src/proactive/private-share"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; const candidate = ( over: Partial = {}, diff --git a/server/tests/approvals-question-resume.integration.test.ts b/server/tests/approvals-question-resume.integration.test.ts index b39a90b72..939de4361 100644 --- a/server/tests/approvals-question-resume.integration.test.ts +++ b/server/tests/approvals-question-resume.integration.test.ts @@ -19,7 +19,7 @@ import type { ResponsibilityStore, } from "../src/responsibilities/types"; import { ResponsibilityNotFoundError } from "../src/responsibilities/types"; -import type { TurnRunner } from "../src/routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; /** diff --git a/server/tests/approvals.test.ts b/server/tests/approvals.test.ts index 7fb8fdf7f..045a62cb6 100644 --- a/server/tests/approvals.test.ts +++ b/server/tests/approvals.test.ts @@ -12,9 +12,9 @@ import { withApprovalContext, } from "../src/approvals/types"; import { createComputerGateway } from "../src/computer/gateway"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { createHostAccessBroker } from "../src/host-access/broker"; import { CatalogueEntryUnknownError } from "../src/plugins/store"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; const context = { runId: "run", diff --git a/server/tests/attachment-mime.test.ts b/server/tests/attachment-mime.test.ts index af221403f..9ade47897 100644 --- a/server/tests/attachment-mime.test.ts +++ b/server/tests/attachment-mime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { classifyAttachment, namesNoFormat } from "../../shared/attachments"; +import { classifyAttachment, namesNoFormat } from "@openbot/shared/attachments"; import { sniffMimeType } from "../src/channels/attachment-mime"; /** diff --git a/server/tests/attachment-parts.test.ts b/server/tests/attachment-parts.test.ts index 94370def7..2ba66c8f1 100644 --- a/server/tests/attachment-parts.test.ts +++ b/server/tests/attachment-parts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { MAX_EXTRACTED_CHARACTERS } from "../../shared/attachments"; +import { MAX_EXTRACTED_CHARACTERS } from "@openbot/shared/attachments"; import { newInlineBudget, resolveAttachmentParts, @@ -277,7 +277,7 @@ describe("resolving stored attachment references into model-readable content", ( /* * "note" is what an older message gets, because history is replayed on * every turn and a throw there would fail this channel for ever — the - * failure `agents/history-sanitize.ts` records finding in production + * failure `toolkit/history-sanitize.ts` records finding in production * twice. The part still SAYS the file is gone, so nothing answers as * though it were there. */ diff --git a/server/tests/attachment-routes.test.ts b/server/tests/attachment-routes.test.ts index ebd04892d..19a421fd0 100644 --- a/server/tests/attachment-routes.test.ts +++ b/server/tests/attachment-routes.test.ts @@ -8,7 +8,7 @@ import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_FILE_BYTES, MAX_IMAGE_BYTES, -} from "../../shared/attachments"; +} from "@openbot/shared/attachments"; import { createApp, UPLOAD_BODY_LIMIT_BYTES } from "../src/app"; import type { AppVariables } from "../src/auth/guards"; import { resolveAttachmentParts } from "../src/channels/attachment-parts"; diff --git a/server/tests/attachment-store.test.ts b/server/tests/attachment-store.test.ts index 48ee3df0e..3e8711dff 100644 --- a/server/tests/attachment-store.test.ts +++ b/server/tests/attachment-store.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; -import { MAX_IMAGE_BYTES } from "../../shared/attachments"; +import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import { createApp, UPLOAD_BODY_LIMIT_BYTES } from "../src/app"; import type { AppVariables } from "../src/auth/guards"; import { diff --git a/server/tests/builtin-routines.test.ts b/server/tests/builtin-routines.test.ts index 37862524f..33946c1d2 100644 --- a/server/tests/builtin-routines.test.ts +++ b/server/tests/builtin-routines.test.ts @@ -167,7 +167,7 @@ describe("the tool list", () => { // A live firing did nothing and reported success because its instruction was written in // schedule-speak ("Every run, append …"): the model read it as a question about scheduling, // checked that the routine existed, and answered "already configured". The firing turn now says - // it is a firing (`routines/run-turn.ts`), and this is the other half — the authoring side, so + // it is a firing (`toolkit/run-turn.ts`), and this is the other half — the authoring side, so // the instruction is written as work in the first place. const tools = await listTools(); const create = tools.find((tool) => tool.name === "create_routine"); diff --git a/server/tests/channel-summary.integration.test.ts b/server/tests/channel-summary.integration.test.ts index ab6ac951e..8fa7e54db 100644 --- a/server/tests/channel-summary.integration.test.ts +++ b/server/tests/channel-summary.integration.test.ts @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; -import { FIRING_FRAME, frameFiring } from "../../shared/routine-firing"; +import { FIRING_FRAME, frameFiring } from "@openbot/shared/routine-firing"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AgentActor } from "../src/agents/profile-types"; import { createChannelStore } from "../src/channels/routes"; diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index 184642e26..1d1dd9635 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -34,7 +34,7 @@ import { } from "../src/plugins/broker"; import type { ComposioActions } from "../src/plugins/composio"; import { useComposioClient } from "../src/plugins/composio"; -import { connectionOf } from "../src/plugins/composio-adapter"; +import { connectionOf } from "../src/plugins/composio-vendor"; import { createPluginRoutes } from "../src/plugins/routes"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { diff --git a/server/tests/computer-handoff-contract.test.ts b/server/tests/computer-handoff-contract.test.ts index 42d28cedc..add4132d6 100644 --- a/server/tests/computer-handoff-contract.test.ts +++ b/server/tests/computer-handoff-contract.test.ts @@ -3,7 +3,7 @@ import type { MiddlewareHandler } from "hono"; import type { BrowserChallenge, ComputerControlState, -} from "../../shared/computer-control"; +} from "@openbot/shared/computer-control"; import type { AppVariables } from "../src/auth/guards"; import { createComputerGateway } from "../src/computer/gateway"; import type { PolicyStore } from "../src/computer/policy-store"; diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 05f4bba71..c068e1aec 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -5,9 +5,10 @@ import type { AbstractAgent, RunAgentInput } from "@ag-ui/client"; import { HttpAgent } from "@ag-ui/client"; import { LLMock } from "@copilotkit/aimock"; import { BuiltInAgent } from "@copilotkit/runtime/v2"; +import { PROVENANCE_GUIDANCE } from "@openbot/shared/bot-prompt"; +import { defaultModelFor } from "@openbot/shared/model-providers"; import { EMPTY, lastValueFrom } from "rxjs"; import { z } from "zod"; -import { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt"; import { createActorAgentResolver } from "../src/agents/agent-resolver"; import { MAX_INLINED_BYTES_PER_RUN } from "../src/channels/attachment-parts"; import { loadConfig } from "../src/config"; @@ -217,6 +218,30 @@ describe("deployment model selection", () => { expect(request.model).toBe("gpt-5.6-terra"); }, ); + + /* + * Switching the provider away from the package's own, without naming a model. The two fallbacks are + * not the same source: OpenAI's is the built-in Bot's own (it speaks the Responses API), Anthropic's + * is the shared spec's. See BUILT_IN_OPENAI_FALLBACK_MODEL in copilot.ts. + */ + test("switched to OpenAI with no model named, the built-in Bot falls back to its own OpenAI model", () => { + const model = runtimeModelForEnvironment( + { provider: "anthropic", defaultModel: "claude-from-package" }, + { BOT_PROVIDER: "openai" }, + ); + expect(model.provider).toBe("openai"); + expect(model.defaultModel).toBe("gpt-5.6-terra"); + }); + + test("switched to Anthropic with no model named, the built-in Bot falls back to the shared spec's", () => { + const model = runtimeModelForEnvironment( + { provider: "openai", defaultModel: "gpt-from-package" }, + { BOT_PROVIDER: "anthropic" }, + ); + expect(model.provider).toBe("anthropic"); + expect(model.defaultModel).toBe(defaultModelFor("anthropic")); + expect(model.defaultModel).toBe("claude-sonnet-4-5"); + }); }); describe("registered Copilot agents", () => { @@ -1857,7 +1882,7 @@ describe("where an attachment reaches the model, and where it deliberately does ]; test("a built-in Bot's run fails naming the attachment it could not load", async () => { - // Protects `BuiltInAgentWithSaneHistory.run` (copilot.ts:~924). Drop the + // Protects `BuiltInAgentWithSaneHistory.run` (toolkit/src/copilotkit/agents.ts). Drop the // `inlineAttachments` call there and this run completes instead of erroring. const agents = await buildAgents( [assistant], @@ -1928,7 +1953,7 @@ describe("where an attachment reaches the model, and where it deliberately does test("the narrowed built-in path reads the attachment once, not twice", async () => { /* - * Protects the exclusion: `RunBuiltAgent.run` (copilot.ts:~989) deliberately does not call + * Protects the exclusion: `RunBuiltAgent.run` (toolkit/src/copilotkit/agents.ts) deliberately does not call * `resolveAttachmentParts` itself. It only delegates to the built-in agent its `build()` * produces, and that agent's own `run` is what inlines the attachment. Re-adding the call at * `RunBuiltAgent.run` would read the same attachment a second time, which is what turns this @@ -2054,7 +2079,7 @@ describe("where an attachment reaches the model, and where it deliberately does * The permanence half of the same argument. `inlineAttachments` maps over the WHOLE history and * history is replayed every turn, so a row deleted by the sweeper after an interrupted send * would otherwise fail this channel's every future turn for ever, exactly as the dangling call - * in `agents/history-sanitize.ts` did in production. The old part says the file is gone; the + * in `toolkit/history-sanitize.ts` did in production. The old part says the file is gone; the * one the person is actually asking about still arrives as bytes. */ const agent = await builtInWith(async (id) => diff --git a/server/tests/delivery-turn.test.ts b/server/tests/delivery-turn.test.ts index 75529b9c2..f4085fbad 100644 --- a/server/tests/delivery-turn.test.ts +++ b/server/tests/delivery-turn.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { AbstractAgent, type Message, type RunAgentInput } from "@ag-ui/client"; import { EMPTY } from "rxjs"; -import { createTurnRunner } from "../src/routines/run-turn"; +import { createTurnRunner } from "@openbot/toolkit/run-turn"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; class Agent extends AbstractAgent { run() { @@ -46,6 +47,7 @@ test("verified inbound person message keeps canonical run ID and persists exact stop: async () => true, }; const turn = createTurnRunner({ + admission: serverTurnAdmission, intelligence, runner, buildAgentFor: async (input) => { diff --git a/server/tests/dictation-routes.test.ts b/server/tests/dictation-routes.test.ts index 6a031cc11..f8654f6eb 100644 --- a/server/tests/dictation-routes.test.ts +++ b/server/tests/dictation-routes.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; -import { MAX_DICTATION_BYTES } from "../../shared/dictation"; +import { MAX_DICTATION_BYTES } from "@openbot/shared/dictation"; import type { AppVariables } from "../src/auth/guards"; import { createDictationRoutes } from "../src/dictation/routes"; diff --git a/server/tests/enterprise-controls.integration.test.ts b/server/tests/enterprise-controls.integration.test.ts index 4c06f5d21..c08c3501b 100644 --- a/server/tests/enterprise-controls.integration.test.ts +++ b/server/tests/enterprise-controls.integration.test.ts @@ -34,9 +34,10 @@ import { teamBotPublications, users, } from "../src/db/schema"; -import { createTurnRunner } from "../src/routines/run-turn"; +import { createTurnRunner } from "@openbot/toolkit/run-turn"; import { createTeamBots } from "../src/team-bots/team-bots"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; /** * The enterprise controls against a real PostgreSQL, through the same seams production uses: the @@ -442,6 +443,7 @@ function headlessTurn(agentId: string) { stop: async () => true, }; const runTurn = createTurnRunner({ + admission: serverTurnAdmission, // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. intelligence: intelligence as any, // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. @@ -501,6 +503,7 @@ describe("headless turns obey the same switches", () => { test("a Bot-to-Bot hand-off meets the same switches", async () => { let ran = 0; const hop = createHandoffDelivery({ + admission: serverTurnAdmission, deadlineMs: 5_000, agentFor: async () => ({ diff --git a/server/tests/group-conversations.integration.test.ts b/server/tests/group-conversations.integration.test.ts index 569865210..8eafd0e4b 100644 --- a/server/tests/group-conversations.integration.test.ts +++ b/server/tests/group-conversations.integration.test.ts @@ -15,7 +15,6 @@ import { } from "../src/channels/group"; import { createChannelStore } from "../src/channels/routes"; import { createThreadIdentity } from "../src/channels/thread-identity"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { createDatabase } from "../src/db/client"; import { agentProfiles, @@ -26,8 +25,9 @@ import { workItems, } from "../src/db/schema"; import { createPrivateShareCheck } from "../src/proactive/private-share"; -import type { TurnRunner } from "../src/routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; import { createTeamBots } from "../src/team-bots/team-bots"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import { createWorkQueue } from "../src/work/queue"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; diff --git a/server/tests/headless-components.integration.test.ts b/server/tests/headless-components.integration.test.ts index 8f3d9b5bf..47917e80c 100644 --- a/server/tests/headless-components.integration.test.ts +++ b/server/tests/headless-components.integration.test.ts @@ -10,16 +10,20 @@ import { import { inArray } from "drizzle-orm"; import { Observable } from "rxjs"; import { GALLERY } from "../../app/src/components/gallery/charts"; -import { NOT_SHOWN } from "../../shared/component-markers"; -import type { AuditEventInput } from "../src/audit"; -import { createHeadlessComponentTools } from "../src/components/headless"; +import { NOT_SHOWN } from "@openbot/shared/component-markers"; +import { type AuditEventInput, type AuditStore, auditSink } from "../src/audit"; +import { + createHeadlessComponentTools, + headlessComponentCatalog, +} from "../src/components/headless"; import { createComponentStore } from "../src/components/store"; import { createComputerGateway } from "../src/computer/gateway"; import { createHeadlessComputerTools } from "../src/computer/headless-tools"; import { createDatabase } from "../src/db/client"; import { agents, componentExclusions, components } from "../src/db/schema"; -import { createTurnRunner, type RunnerLike } from "../src/routines/run-turn"; +import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; /** * An unattended routine turn that browses a page and answers with a chart, against the real @@ -205,6 +209,7 @@ function harness(agent: AbstractAgent, botId: string) { stop: async () => true, }; const run = createTurnRunner({ + admission: serverTurnAdmission, intelligence: { getOrCreateThread: async () => undefined, getThreadMessages: async () => ({ messages: [] }), @@ -220,7 +225,10 @@ function harness(agent: AbstractAgent, botId: string) { botId, actor: { id: ownerUserId, userId: ownerUserId, initiator }, }), - components: { store, auditStore }, + components: headlessComponentCatalog({ + store, + audit: auditSink(auditStore as unknown as AuditStore), + }), }); return { persisted, diff --git a/server/tests/headless-components.test.ts b/server/tests/headless-components.test.ts index a267d5b7d..1d102516d 100644 --- a/server/tests/headless-components.test.ts +++ b/server/tests/headless-components.test.ts @@ -9,8 +9,8 @@ import { GALLERY as DECISIONS } from "../../app/src/components/gallery/decisions import { GALLERY as FORM } from "../../app/src/components/gallery/form"; import { GALLERY as QUOTE } from "../../app/src/components/gallery/quote"; import { GALLERY as TABLE } from "../../app/src/components/gallery/table"; -import { NOT_SHOWN } from "../../shared/component-markers"; -import type { AuditEventInput } from "../src/audit"; +import { NOT_SHOWN } from "@openbot/shared/component-markers"; +import { type AuditEventInput, type AuditStore, auditSink } from "../src/audit"; import { createHeadlessComponentTools, HEADLESS_COMPONENTS, @@ -193,7 +193,7 @@ describe("headless component tools", () => { botId: "bot", ownerUserId: "owner", initiator, - auditStore, + audit: auditSink(auditStore as unknown as AuditStore), }); const bar = refused.find((tool) => tool.definition.name === "showBarChart"); expect(bar?.hidden).toBeTrue(); diff --git a/server/tests/headless-tools.test.ts b/server/tests/headless-tools.test.ts index 3a22c3088..78ad27ec9 100644 --- a/server/tests/headless-tools.test.ts +++ b/server/tests/headless-tools.test.ts @@ -11,7 +11,8 @@ import { parseApprovalContinuation } from "../src/approvals/types"; import type { AuditEventInput } from "../src/audit"; import { createComputerGateway } from "../src/computer/gateway"; import * as headless from "../src/computer/headless-tools"; -import { createTurnRunner, type RunnerLike } from "../src/routines/run-turn"; +import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; const definition = { name: "computer_snapshot", @@ -109,6 +110,7 @@ function turnHarness( stop: async () => true, }; const run = createTurnRunner({ + admission: serverTurnAdmission, intelligence: { getOrCreateThread: async () => undefined, getThreadMessages: async () => ({ messages: [] }), diff --git a/server/tests/learning-runtime.test.ts b/server/tests/learning-runtime.test.ts index 0f796e0f9..452152f99 100644 --- a/server/tests/learning-runtime.test.ts +++ b/server/tests/learning-runtime.test.ts @@ -9,7 +9,7 @@ import { selectLearningTarget, } from "../src/learning/runtime"; import type { LearningSettingsStore } from "../src/learning/settings"; -import type { LearningSettings } from "../../shared/learning"; +import type { LearningSettings } from "@openbot/shared/learning"; import { skillSnapshot } from "./fixtures/learned-skills"; function fixture() { diff --git a/server/tests/parallel-research.test.ts b/server/tests/parallel-research.test.ts index 504e800ba..e416fb064 100644 --- a/server/tests/parallel-research.test.ts +++ b/server/tests/parallel-research.test.ts @@ -8,7 +8,8 @@ import { resolveServerUrl, serverCredentialKind, } from "../src/plugins/catalogue"; -import { type GrantedTool, grantedToolGuidance } from "../src/plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; +import { grantedToolGuidance } from "../src/plugins/tools"; const tool = (server: string, name: string): GrantedTool => ({ name: `mcp__${server}__${name}`, diff --git a/server/tests/passwords-sign-in.test.ts b/server/tests/passwords-sign-in.test.ts index f99a9fd37..13ebf2f85 100644 --- a/server/tests/passwords-sign-in.test.ts +++ b/server/tests/passwords-sign-in.test.ts @@ -12,10 +12,7 @@ import { parseApprovalContinuation } from "../src/approvals/types"; import type { AuditEventInput, AuditStore } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import type { ComputerGateway } from "../src/computer/gateway"; -import { - createHeadlessComputerTools, - HeadlessToolSuspension, -} from "../src/computer/headless-tools"; +import { createHeadlessComputerTools } from "../src/computer/headless-tools"; import type { SignInCodeInput, SignInCodeResult, @@ -32,7 +29,9 @@ import { } from "../src/passwords/service"; import { createMemoryPasswordStore } from "../src/passwords/store"; import { originOf, type SignInRequestRecord } from "../src/passwords/types"; -import { createTurnRunner, type RunnerLike } from "../src/routines/run-turn"; +import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; /** * The private sign-in request and the Passwords vault, as properties: @@ -789,6 +788,7 @@ describe("an unattended turn that needs a sign-in", () => { stop: async () => true, }; const run = createTurnRunner({ + admission: serverTurnAdmission, intelligence: { getOrCreateThread: async () => undefined, getThreadMessages: async () => ({ messages: [] }), diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1ba31ef34..12e46dd96 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 { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { MCPMock, type MCPToolDefinition } from "@copilotkit/aimock/mcp"; import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; @@ -6442,32 +6442,43 @@ describe("a query of this deployment's own that failed", () => { * above owns the behaviour of the reachable path; this owns the shape of every path. */ test("no recording site in the plugin store reads a caught message without asking", () => { - const source = readFileSync( - join(import.meta.dir, "..", "src", "plugins", "store.ts"), - "utf8", - ); + // Every file the store is made of: the assembly in `store.ts` and the modules it was split into. + const plugins = join(import.meta.dir, "..", "src", "plugins"); + const files = [ + "store.ts", + ...readdirSync(join(plugins, "plugin-store")) + .filter((name) => name.endsWith(".ts")) + .map((name) => `plugin-store/${name}`), + ]; /* - * Comments blanked rather than removed, because this file argues about `error.message` at + * Comments blanked rather than removed, because these files argue about `error.message` at * length — the prose naming the hazard must not be mistaken for the hazard, and the line - * numbers reported below have to stay the file's own so a failure names where to look. + * numbers reported below have to stay each file's own so a failure names where to look. */ - const code = source - .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")) - .replace(/^([^\n"'`]*?)\/\/.*$/gm, "$1"); + const code = files.map((file) => ({ + file, + code: readFileSync(join(plugins, file), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")) + .replace(/^([^\n"'`]*?)\/\/.*$/gm, "$1"), + })); // Listed rather than matched, so a failure prints the line to correct instead of the file. expect( - code - .split("\n") - .flatMap((line, index) => - /\berror\.message\b/.test(line) - ? [`${index + 1}: ${line.trim()}`] - : [], - ), + code.flatMap(({ file, code: text }) => + text + .split("\n") + .flatMap((line, index) => + /\berror\.message\b/.test(line) + ? [`${file}:${index + 1}: ${line.trim()}`] + : [], + ), + ), ).toEqual([]); // And the door is genuinely in use, so the assertion above cannot be satisfied by deleting the // read altogether and saying nothing. - expect(code).toMatch(/\b(withoutStatement|reasonWithoutStatement)\(/); + expect(code.map(({ code: text }) => text).join("\n")).toMatch( + /\b(withoutStatement|reasonWithoutStatement)\(/, + ); }); }); diff --git a/server/tests/proactive.test.ts b/server/tests/proactive.test.ts index ca1243097..851fdcdd8 100644 --- a/server/tests/proactive.test.ts +++ b/server/tests/proactive.test.ts @@ -8,7 +8,7 @@ import { import { Observable } from "rxjs"; import { z } from "zod"; import type { ApprovalRecord } from "../src/approvals/types"; -import type { GrantedTool } from "../src/plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { createProactiveEngine } from "../src/proactive/engine"; import { createPrivateShareCheck } from "../src/proactive/private-share"; import { @@ -20,8 +20,9 @@ import { } from "../src/proactive/restriction"; import type { ProactiveStore } from "../src/proactive/store"; import type { ProactiveSetting } from "../src/proactive/types"; -import { createTurnRunner, type RunnerLike } from "../src/routines/run-turn"; +import { createTurnRunner, type RunnerLike } from "@openbot/toolkit/run-turn"; import type { WorkQueue } from "../src/work/queue"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; const catalogue = (effects: Record) => ({ listServers: async () => [ @@ -299,6 +300,7 @@ describe("proactive run", () => { queue, owner: "worker", runTurn: createTurnRunner({ + admission: serverTurnAdmission, intelligence: { getOrCreateThread: async () => undefined, getThreadMessages: async () => ({ messages: [] }), diff --git a/server/tests/remote-approval-context.test.ts b/server/tests/remote-approval-context.test.ts index baab6b61f..b1c826e0a 100644 --- a/server/tests/remote-approval-context.test.ts +++ b/server/tests/remote-approval-context.test.ts @@ -3,8 +3,8 @@ import { HttpAgent, type RunAgentInput } from "@ag-ui/client"; import { of } from "rxjs"; import { z } from "zod"; import { currentApprovalContext } from "../src/approvals/types"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { buildAgents } from "../src/copilot"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; async function fixture(answered = false) { const requests: RunAgentInput[] = []; diff --git a/server/tests/responsibilities-engine.test.ts b/server/tests/responsibilities-engine.test.ts index c5606cadd..e4d52d455 100644 --- a/server/tests/responsibilities-engine.test.ts +++ b/server/tests/responsibilities-engine.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "bun:test"; -import { HeadlessToolSuspension } from "../src/computer/headless-tools"; import { createResponsibilityEngine } from "../src/responsibilities/engine"; import { - parseResponsibilityInput, parseResponsibilityEvent, + parseResponsibilityInput, type ResponsibilityRunContext, type ResponsibilityRunOutcome, } from "../src/responsibilities/types"; +import { HeadlessToolSuspension } from "@openbot/toolkit/headless-tools"; import type { WorkItem } from "../src/work/queue"; function runContext(): ResponsibilityRunContext { diff --git a/server/tests/routine-run-turn.test.ts b/server/tests/routine-run-turn.test.ts index 9c4a709c1..1628847f1 100644 --- a/server/tests/routine-run-turn.test.ts +++ b/server/tests/routine-run-turn.test.ts @@ -1,13 +1,20 @@ import { describe, expect, test } from "bun:test"; import type { Message } from "@ag-ui/client"; import { AbstractAgent, EventType } from "@ag-ui/client"; -import { EMPTY } from "rxjs"; +import { + HeadlessToolSuspension, + OPENBOT_WAITING_METADATA, +} from "@openbot/toolkit/headless-tools"; +import type { TurnAdmission } from "@openbot/toolkit/ports"; import { createTurnRunner, drawnComponents, frameFiring, sanitizeSeededHistory, -} from "../src/routines/run-turn"; +} from "@openbot/toolkit/run-turn"; +import { EMPTY } from "rxjs"; +import { serverTurnAdmission } from "../src/agents/turn-admission"; +import { HEADLESS_COMPONENTS } from "../src/components/headless"; /** * A headless turn, asserted without a gateway, without a database and without a model. @@ -46,6 +53,16 @@ type HistoryRow = { toolCallId?: string; }; +/** The platform refusing a renewal for a lock this run no longer owns, as the client raises it. */ +function lockConflict(): Error { + const error = new Error( + "Intelligence platform error 409: THREAD_LOCK_RENEWAL_FAILED", + ); + error.name = "PlatformRequestError"; + (error as Error & { status?: number }).status = 409; + return error; +} + /** A `PlatformRequestError` as `isMissingThread` matches it: the name and the status, nothing else. */ function threadNotFound(): Error { const error = new Error("THREAD_NOT_FOUND"); @@ -109,6 +126,8 @@ function harness(options: { heartbeatMs?: number; lockTtlSeconds?: number; learningContainerId?: string; + /** Defaults to the server's own, which admits everything while nothing is configured. */ + admission?: TurnAdmission; }) { const order: string[] = []; const calls = { @@ -200,6 +219,7 @@ function harness(options: { const builtFor: { initiator: { kind: string; id?: string } }[] = []; const runTurn = createTurnRunner({ + admission: options.admission ?? serverTurnAdmission, // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. intelligence: intelligence as any, // biome-ignore lint/suspicious/noExplicitAny: narrow structural fakes, on purpose. @@ -659,18 +679,18 @@ describe("the lock is released on every exit path", () => { ]); }); - test("when a heartbeat renew rejects", async () => { + test("when a heartbeat renew is refused as a conflict", async () => { const { run, calls } = harness({ drive: ({ agent, observer }) => { agent.onAbort = () => observer.complete(); }, heartbeatMs: 2, renew: () => { - throw new Error("somebody else holds this lock"); + throw lockConflict(); }, }); - await expect(run()).rejects.toThrow("somebody else holds this lock"); + await expect(run()).rejects.toThrow("THREAD_LOCK_RENEWAL_FAILED"); expect(calls.cleaned).toEqual([ { threadId: THREAD_ID, runId: calls.acquired[0]?.runId ?? "" }, @@ -739,7 +759,12 @@ describe("one run id, everywhere", () => { expect(typeof runId).toBe("string"); expect(calls.renewed.length).toBeGreaterThan(1); for (const renew of calls.renewed) { - expect(renew).toEqual({ threadId: THREAD_ID, runId, ttlSeconds: 20 }); + expect(renew).toEqual({ + threadId: THREAD_ID, + runId, + // The literal, not the constant, so moving the lifetime off the platform's 120 fails here. + ttlSeconds: 120, + }); } expect(calls.cleaned).toEqual([{ threadId: THREAD_ID, runId }]); expect(calls.runs[0]?.input.runId).toBe(runId); @@ -770,7 +795,7 @@ describe("one run id, everywhere", () => { turnTimeoutMs: 20, abortGraceMs: 50, renew: () => { - throw new Error("somebody else holds this lock"); + throw lockConflict(); }, }); @@ -780,7 +805,7 @@ describe("one run id, everywhere", () => { }); }); -describe("a failed heartbeat stops the turn", () => { +describe("a heartbeat refused as a conflict stops the turn", () => { test("aborts the agent, stops the run, and rethrows", async () => { const { run, calls, agent } = harness({ drive: ({ agent: driven, observer }) => { @@ -788,11 +813,11 @@ describe("a failed heartbeat stops the turn", () => { }, heartbeatMs: 2, renew: () => { - throw new Error("lock lost"); + throw lockConflict(); }, }); - await expect(run()).rejects.toThrow("lock lost"); + await expect(run()).rejects.toThrow("THREAD_LOCK_RENEWAL_FAILED"); expect(agent.aborts).toBe(1); expect(calls.stops).toEqual([ @@ -809,14 +834,50 @@ describe("a failed heartbeat stops the turn", () => { }, heartbeatMs: 2, renew: () => { - throw new Error("lock lost"); + throw lockConflict(); }, }); - await expect(run()).rejects.toThrow("lock lost"); + await expect(run()).rejects.toThrow("THREAD_LOCK_RENEWAL_FAILED"); }); }); +/* + * A renewal that did not arrive is not a lock that was lost. Stopped here, the turn would be retried + * from the start, and a Bot that had already acted would act again. + */ +describe("a heartbeat that merely failed does not stop the turn", () => { + for (const [name, failure] of [ + ["a network failure", () => new TypeError("fetch failed")], + [ + "a platform outage", + () => { + const error = new Error("Intelligence platform error 503"); + error.name = "PlatformRequestError"; + (error as Error & { status?: number }).status = 503; + return error; + }, + ], + ] as const) { + test(`${name} is skipped and the turn answers`, async () => { + const { run, calls, agent } = harness({ + drive: (context) => { + setTimeout(() => answers(context), 30); + }, + heartbeatMs: 2, + renew: () => { + throw failure(); + }, + }); + + expect(await run()).toEqual({ replyText: "Three things happened." }); + expect(calls.renewed.length).toBeGreaterThan(1); + expect(agent.aborts).toBe(0); + expect(calls.stops).toEqual([]); + }); + } +}); + describe("recovering what was said", () => { test("falls back to the streamed chunks when no message was added", async () => { const { run } = harness({ @@ -889,6 +950,163 @@ describe("recovering what was said", () => { }); }); +/** + * Admission is the first thing a turn does: a refused one has touched nothing, and the signal it + * hands back is the one a pause uses to stop a turn already running. + */ +describe("a turn's admission", () => { + test("is asked with the owner and the Bot, before the thread is touched", async () => { + const asked: { ownerUserId: string; agentId: string }[] = []; + const { run, order } = harness({ + admission: async ({ ownerUserId, agentId, signal }) => { + asked.push({ ownerUserId, agentId }); + order.push("admission"); + return signal ?? new AbortController().signal; + }, + }); + + await run(); + + expect(asked).toEqual([{ ownerUserId: OWNER, agentId: AGENT_ID }]); + expect(order[0]).toBe("admission"); + }); + + test("a refusal ends the turn before any thread, lock or run", async () => { + const { run, order, builtFor } = harness({ + admission: async () => { + const error = new Error("Use Bots is off for this person."); + error.name = "CapabilityRefusedError"; + throw error; + }, + }); + + await expect(run()).rejects.toThrow("Use Bots is off for this person."); + expect(order).toEqual([]); + expect(builtFor).toEqual([]); + }); + + test("aborting the signal it handed back stops a running turn and gives the lock back", async () => { + const paused = new AbortController(); + let started: () => void = () => {}; + const running = new Promise((resolve) => { + started = resolve; + }); + const { run, calls } = harness({ + admission: async () => paused.signal, + // A run that never finishes on its own, so only the abort can end it. + drive: () => started(), + }); + + const turn = run(); + await running; + paused.abort(); + + await expect(turn).rejects.toThrow("The headless turn was cancelled."); + expect(calls.stops).toHaveLength(1); + expect(calls.cleaned).toHaveLength(1); + }); +}); + +/** + * The waiting details travel two ways: as an `openbot.headless.waiting` CUSTOM event, and on the + * interrupt's metadata for when that event did not survive the trip. Both must end the turn as a + * suspension that names the open request, never as the generic "nobody to ask" failure, or the + * request the person still has to answer is lost. + */ +describe("a turn waiting on a person", () => { + const WAITING = { + kind: "approval", + requestId: "request_1", + message: "Waiting for Ada to approve the transfer.", + }; + + const interruptCarrying = (metadata: Record) => + // biome-ignore lint/suspicious/noExplicitAny: only the metadata is the subject. + ({ id: "interrupt_1", metadata }) as any; + + test("a waiting carried on the interrupt suspends the turn with its details", async () => { + const { run, calls } = harness({ + drive: ({ agent, observer }) => { + agent.pendingInterrupts = [ + interruptCarrying({ [OPENBOT_WAITING_METADATA]: WAITING }), + ]; + observer.complete(); + }, + }); + + const error = await run().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HeadlessToolSuspension); + expect((error as HeadlessToolSuspension).message).toBe(WAITING.message); + expect((error as HeadlessToolSuspension).waiting).toEqual(WAITING); + expect(calls.cleaned).toHaveLength(1); + }); + + test("a carried waiting without its own message gets the default sentence", async () => { + const { run } = harness({ + drive: ({ agent, observer }) => { + agent.pendingInterrupts = [ + interruptCarrying({ + [OPENBOT_WAITING_METADATA]: { kind: "question" }, + }), + ]; + observer.complete(); + }, + }); + + await expect(run()).rejects.toThrow("The turn is waiting for human input."); + }); + + test("a carried waiting wins over whatever the turn said before it stopped", async () => { + const { run } = harness({ + drive: (context) => { + context.agent.pendingInterrupts = [ + interruptCarrying({ [OPENBOT_WAITING_METADATA]: WAITING }), + ]; + answers(context); + }, + }); + + await expect(run()).rejects.toBeInstanceOf(HeadlessToolSuspension); + }); + + test("metadata without a kind is not a waiting, and reads as an interrupt", async () => { + const { run } = harness({ + drive: ({ agent, observer }) => { + agent.pendingInterrupts = [ + interruptCarrying({ + [OPENBOT_WAITING_METADATA]: { requestId: "request_1" }, + }), + ]; + observer.complete(); + }, + }); + + await expect(run()).rejects.toThrow("nobody to ask"); + }); + + test("the CUSTOM event wins over the copy carried on the interrupt", async () => { + const fromEvent = { kind: "approval", requestId: "request_event" }; + const { run } = harness({ + drive: ({ agent, observer }) => { + observer.next({ + type: EventType.CUSTOM, + name: "openbot.headless.waiting", + value: fromEvent, + // biome-ignore lint/suspicious/noExplicitAny: the harness observer types only RUN_ERROR. + } as any); + agent.pendingInterrupts = [ + interruptCarrying({ [OPENBOT_WAITING_METADATA]: WAITING }), + ]; + observer.complete(); + }, + }); + + const error = await run().catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HeadlessToolSuspension); + expect((error as HeadlessToolSuspension).waiting).toEqual(fromEvent); + }); +}); + describe("a RUN_ERROR through next", () => { test("rejects rather than hanging, and does not read as an empty answer", async () => { const { run, calls } = harness({ @@ -967,7 +1185,10 @@ test("drawnComponents returns the charts that were drawn, never a refused or unk { id: "t3", role: "tool", toolCallId: "c3", content: "ok" }, { id: "t4", role: "tool", toolCallId: "c4", content: "saved" }, ]; - expect(drawnComponents(messages)).toEqual([ + const names = new Set(HEADLESS_COMPONENTS.map(({ name }) => name)); + expect(drawnComponents(messages, names)).toEqual([ { name: "showBarChart", args: { title: "Top", points: [] } }, ]); + // A turn with no catalog drew nothing, whatever its tool calls were named. + expect(drawnComponents(messages, new Set())).toEqual([]); }); diff --git a/server/tests/routine-runner.test.ts b/server/tests/routine-runner.test.ts index 731bf54ff..bb12a3140 100644 --- a/server/tests/routine-runner.test.ts +++ b/server/tests/routine-runner.test.ts @@ -5,7 +5,8 @@ import type { ChannelActivity, ChannelStore, } from "../src/channels/routes"; -import { createRoutineRunner, type TurnRunner } from "../src/routines/runner"; +import type { TurnRunner } from "@openbot/toolkit/run-turn"; +import { createRoutineRunner } from "../src/routines/runner"; import type { RoutineRunContext, RoutineRunOutcome, diff --git a/server/tests/run-built-agent-cancellation.test.ts b/server/tests/run-built-agent-cancellation.test.ts index 444bbad82..5dec863ba 100644 --- a/server/tests/run-built-agent-cancellation.test.ts +++ b/server/tests/run-built-agent-cancellation.test.ts @@ -10,7 +10,7 @@ import { type RuntimeModel, type ToolSelection, } from "../src/copilot"; -import type { GrantedTool } from "../src/plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { createModelCompleter } from "../src/routing/model"; const model: RuntimeModel = { provider: "openai", defaultModel: "gpt-5.5" }; diff --git a/server/tests/tool-selection.integration.test.ts b/server/tests/tool-selection.integration.test.ts index 6dc359a30..99137c5a6 100644 --- a/server/tests/tool-selection.integration.test.ts +++ b/server/tests/tool-selection.integration.test.ts @@ -20,7 +20,7 @@ import { type RuntimeModel, } from "../src/copilot"; import type { Selection } from "../src/plugins/selection"; -import type { GrantedTool } from "../src/plugins/tools"; +import type { GrantedTool } from "@openbot/toolkit/granted-tool"; import { createModelCompleter } from "../src/routing/model"; declare global { diff --git a/server/tests/user-preferences-routes.test.ts b/server/tests/user-preferences-routes.test.ts index b8affac22..3ddd3803f 100644 --- a/server/tests/user-preferences-routes.test.ts +++ b/server/tests/user-preferences-routes.test.ts @@ -3,7 +3,7 @@ import { Hono } from "hono"; import { DEFAULT_USER_PREFERENCES, type UserPreferences, -} from "../../shared/user-preferences"; +} from "@openbot/shared/user-preferences"; import type { UserPreferencesStore } from "../src/user-preferences"; import { userPreferencesRoutes } from "../src/user-preferences-routes"; diff --git a/server/tests/voice-session-preview.integration.test.ts b/server/tests/voice-session-preview.integration.test.ts index 25fe45d9e..ca4d6eaf9 100644 --- a/server/tests/voice-session-preview.integration.test.ts +++ b/server/tests/voice-session-preview.integration.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq, inArray, sql } from "drizzle-orm"; import { Hono } from "hono"; -import type { SaveVoiceSessionInput } from "../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { createAgentProfileStore } from "../src/agents/profile-store"; import type { AppVariables } from "../src/auth/guards"; import { diff --git a/server/tests/voice-session-routes.test.ts b/server/tests/voice-session-routes.test.ts index e0d5fc701..231ba8132 100644 --- a/server/tests/voice-session-routes.test.ts +++ b/server/tests/voice-session-routes.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; -import type { VoiceSessionRecord } from "../../shared/voice-session"; +import type { VoiceSessionRecord } from "@openbot/shared/voice-session"; import type { AppVariables } from "../src/auth/guards"; import { createVoiceRoutes } from "../src/voice/routes"; import { diff --git a/server/tests/voice-session-store.integration.test.ts b/server/tests/voice-session-store.integration.test.ts index 6033f3eaf..60452d977 100644 --- a/server/tests/voice-session-store.integration.test.ts +++ b/server/tests/voice-session-store.integration.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq, inArray } from "drizzle-orm"; -import type { SaveVoiceSessionInput } from "../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { createAgentProfileStore } from "../src/agents/profile-store"; import { createChannelStore } from "../src/channels/routes"; import { createThreadIdentity } from "../src/channels/thread-identity"; diff --git a/server/tests/voice-sessions.test.ts b/server/tests/voice-sessions.test.ts index 25618c629..ef75d2dac 100644 --- a/server/tests/voice-sessions.test.ts +++ b/server/tests/voice-sessions.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import type { SaveVoiceSessionInput } from "../../shared/voice-session"; +import type { SaveVoiceSessionInput } from "@openbot/shared/voice-session"; import { parseVoiceSessionInput } from "../src/voice/sessions"; import { createVoiceSummarizer } from "../src/voice/summary"; diff --git a/server/tsconfig.json b/server/tsconfig.json index 9d8810dd6..cbd77e392 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -22,8 +22,7 @@ // `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. + // not with a sibling. Named individually for that reason. // // `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 diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 000000000..d9862e8f5 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@openbot/shared", + "license": "MIT", + "private": true, + "type": "module", + "exports": { + "./model-providers.json": "./model-providers.json", + "./*": "./*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 000000000..f7984378c --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "resolveJsonModule": true + }, + "include": ["*.ts"] +} diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 17538c2fe..7f9df2d60 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -28,14 +28,21 @@ function packagesStartedBy(script: string, workspaces: string[]): string[] { } describe("OpenBot workspace", () => { - test("defines the app, server, and worker packages", () => { + test("defines the app, server, worker, shared and toolkit packages", () => { const manifest = rootManifest(); + const names: Record = { + app: "app", + server: "server", + worker: "worker", + shared: "@openbot/shared", + toolkit: "@openbot/toolkit", + }; - expect(manifest.workspaces).toEqual(["app", "server", "worker"]); + expect(manifest.workspaces).toEqual(Object.keys(names)); - for (const packageName of manifest.workspaces) { - expect(existsSync(join(repositoryRoot, packageName))).toBe(true); - expect(packageManifest(packageName).name).toBe(packageName); + for (const workspace of manifest.workspaces) { + expect(existsSync(join(repositoryRoot, workspace))).toBe(true); + expect(packageManifest(workspace).name).toBe(names[workspace] ?? ""); } }); diff --git a/toolkit/package.json b/toolkit/package.json new file mode 100644 index 000000000..e5e36c2a5 --- /dev/null +++ b/toolkit/package.json @@ -0,0 +1,22 @@ +{ + "name": "@openbot/toolkit", + "license": "MIT", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ag-ui/client": "1.0.1", + "@copilotkit/runtime": "1.77.0", + "@openbot/shared": "workspace:*", + "rxjs": "7.8.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/toolkit/src/continuation.ts b/toolkit/src/continuation.ts new file mode 100644 index 000000000..205dfa534 --- /dev/null +++ b/toolkit/src/continuation.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Message, RunAgentInput } from "@ag-ui/client"; +import type { AuditInitiator } from "./initiator"; + +/** + * Everything needed to pick a turn up again at the tool call it stopped on, once a person has + * answered. Re-exported from approvals/types.ts, which parses and stores it. + */ +export type ApprovalContinuation = { + runId: string; + threadId: string; + toolCallId: string; + toolName: string; + args: unknown; + messages: Message[]; + state: unknown; + context: RunAgentInput["context"]; + forwardedProps: unknown; + initiator?: AuditInitiator; +}; + +const actionContext = new AsyncLocalStorage(); + +/** Run a tool call with its continuation in scope, so a refusal inside it can be resumed later. */ +export const withApprovalContext = ( + context: ApprovalContinuation, + run: () => Promise, +): Promise => actionContext.run(context, run); + +export const currentApprovalContext = () => actionContext.getStore(); diff --git a/toolkit/src/copilotkit/agents.ts b/toolkit/src/copilotkit/agents.ts new file mode 100644 index 000000000..1486e5d7d --- /dev/null +++ b/toolkit/src/copilotkit/agents.ts @@ -0,0 +1,352 @@ +import { + AbstractAgent, + type BaseEvent, + type Message, + type RunAgentInput, +} from "@ag-ui/client"; +import { + BuiltInAgent, + type BuiltInAgentConfiguration, + CopilotKitIntelligence, +} from "@copilotkit/runtime/v2"; +import { + defer, + finalize, + from, + fromEvent, + type Observable, + switchMap, + takeUntil, +} from "rxjs"; +import { sanitizeSeededHistory } from "../history-sanitize"; +import { nativeApprovalRun } from "../native-approval"; +import { historyOrEmpty } from "../thread-history"; + +/** + * The CopilotKit agents OpenBot runs its Bots as: a built-in Bot that will not send a broken + * conversation, one whose tools are chosen when its run starts, a remote Bot that survives being + * cloned, a Bot that is no longer there, and the platform client with one answer corrected. + * + * Moved out of the server's copilot.ts. What the server does around a run — inlining attachments, + * watching the model connection — comes in as {@link BuiltInAgentHooks}, so nothing here imports it. + */ + +/** + * The reason a finished run's build is aborted with, so a pending model call can tell a run that + * ended normally from one somebody stopped. Read by the server's plan model. + */ +export const PLAN_RUN_COMPLETED = Symbol("plan-run-completed"); + +type AgentHeaders = Record; +type HeaderBearingAgent = AbstractAgent & { headers?: AgentHeaders }; + +/** What the host does around a built-in Bot's run. */ +export type BuiltInAgentHooks = { + /** + * Replace a person's attached files with their bytes, given the run's conversation. Absent, the + * conversation goes up as it is, without deferring the run. + */ + inline?: (messages: Message[], threadId: string) => Promise; + /** Watch the model connection, for a host that reports when it fails. */ + observe: (run: Observable) => Observable; +}; + +export class CloningRemoteAgent extends AbstractAgent { + headers?: AgentHeaders; + private readonly remote: HeaderBearingAgent; + + constructor( + remote: AbstractAgent, + private readonly attachOpenBotMiddleware: (target: AbstractAgent) => void, + ) { + super({ + agentId: remote.agentId, + description: remote.description, + threadId: remote.threadId, + initialMessages: remote.messages, + initialState: remote.state, + debug: remote.debug, + }); + this.remote = remote as HeaderBearingAgent; + if (this.remote.headers) { + this.headers = { ...this.remote.headers }; + } + this.attachOpenBotMiddleware(this); + } + + run(input: RunAgentInput): Observable { + if (this.headers) { + this.remote.headers = { ...this.headers }; + } + return this.remote.run(input); + } + + async getCapabilities() { + return this.remote.getCapabilities?.() ?? {}; + } + + abortRun(): void { + this.remote.abortRun(); + super.abortRun(); + } + + clone() { + const clonedRemote = this.remote.clone() as AbstractAgent; + const clone = new CloningRemoteAgent( + clonedRemote, + this.attachOpenBotMiddleware, + ); + if (this.headers) { + clone.headers = { ...this.headers }; + } + return clone; + } +} + +/** + * A built-in Bot that will not hand the model provider a conversation it is going to refuse. + * + * FOUND LIVE, ON CHAT. One person's next three messages each failed with + * `AI_MissingToolResultsError: Tool result is missing for tool call chatcmpl-tool-8dd56dc7497c5ea9`, + * thrown out of the AI SDK's `convertToLanguageModelPrompt`. A frontend tool handler had been torn + * down while its call was open, so the agent's live messages in the browser carried an assistant + * message whose tool call never got a result. The durable store did not have it, nothing was going + * to answer it, and every retry sent it straight back up as `input.messages`. The conversation was + * finished until the person worked out for themselves to start another one. + * + * The guard has to be on this side of `run`. `BuiltInAgent.run` converts `input.messages` itself, + * with no seam in between, so wrapping the agent is the only place left to stand. The reasoning for + * why a dangling call is DROPPED rather than repaired, and why ids are never changed, is in + * `toolkit/history-sanitize.ts`, where the routines path found the same failure first. + * + * A RESUMED CALL IS NOT A DANGLE. `run` appends a tool result for each `input.resume` entry by + * `interruptId` AFTER converting the messages, so a call that a resume is about to answer must + * survive this pass or the appended result lands on nothing. + */ +export class BuiltInAgentWithSaneHistory extends BuiltInAgent { + /** + * The configuration, held a second time because the base class keeps its own copy private and + * {@link clone} has to build another one of THIS class rather than of the base. + */ + private readonly configuration: BuiltInAgentConfiguration; + /** + * What the host does around a run: how it inlines a person's attached files, and how it watches + * the model connection. Held for the same reason {@link configuration} is: {@link clone} builds + * another one of THIS class, and a seam lost in a clone is a seam that never runs. + */ + private readonly hooks: BuiltInAgentHooks; + + constructor( + configuration: BuiltInAgentConfiguration, + hooks: BuiltInAgentHooks, + ) { + super(configuration); + this.configuration = configuration; + this.hooks = hooks; + } + + run(input: RunAgentInput): Observable { + const answeredByResume = new Set( + (input.resume ?? []).map((entry) => entry.interruptId), + ); + const history = sanitizeSeededHistory(input.messages, answeredByResume); + const { inline, observe } = this.hooks; + /* + * ALONGSIDE THE GUARD ABOVE, NOT INSTEAD OF IT. One drops a conversation the model provider is + * going to refuse; this replaces the stored reference a person's attachment arrives as with the + * bytes themselves, because `BuiltInAgent.run` converts `input.messages` with no seam in + * between and a `/api/attachments/` URL is not something a model provider will go and fetch. + * + * Nothing to load means nothing to inline, and the run goes up exactly as it did before any of + * this existed. + */ + if (!inline) + return nativeApprovalRun({ ...input, messages: history }, () => + observe(super.run({ ...input, messages: history })), + ); + /* + * Deferred, because `run` has to answer with a stream straight away and reading the bytes is a + * database round trip. `defer` puts that read on the subscription, which is where the run + * actually begins, so nothing is fetched until somebody is listening. + */ + return defer(() => + from( + // The run's own conversation, not the actor's channels at large. + inline(history, input.threadId), + ).pipe( + switchMap((messages) => + nativeApprovalRun({ ...input, messages }, () => + observe(super.run({ ...input, messages })), + ), + ), + ), + ); + } + + /** + * Carried by hand, for the same reason {@link RunBuiltAgent.clone} is. + * + * The runtime clones an agent before every run, and the base class's clone hard-codes + * `new BuiltInAgent(this.config)`: inherited unchanged, the very first message anybody sends + * would go through an agent that does none of the above. The middleware list is copied because + * the base clone copies it, and it is reached through a cast because `AbstractAgent` declares it + * private. Nothing registers middleware on a built-in Bot today, and this is here so that the day + * something does, it is not lost in a clone. + */ + clone(): BuiltInAgentWithSaneHistory { + const cloned = new BuiltInAgentWithSaneHistory( + this.configuration, + this.hooks, + ); + type WithMiddlewares = { middlewares: unknown[] }; + (cloned as unknown as WithMiddlewares).middlewares = [ + ...(this as unknown as WithMiddlewares).middlewares, + ]; + return cloned; + } +} + +/** + * An agent whose tools are decided when the run starts, because that is the first moment anybody + * knows what the run is about, and who is asking on whose behalf. + * + * WHY A WRAPPER AND NOT A NARROWER `loadTools`. Tools are resolved per request, and a request is + * earlier than a run: at that point there is a Bot and a person and no message, so there is nothing + * to select against. `run(input)` is the first place the message exists. Both underlying agents take + * their tools at construction — a built-in one in its configuration, a remote one in the middleware + * that sends them — so the only way to hand either a set chosen from the message is to build it + * after the message arrives. That is all this does: it defers `build` to the first subscription and + * then gets out of the way. + * + * The deferral is per subscription, so a retried run reselects rather than reusing a decision made + * for a message that is no longer the last one. + */ +export class RunBuiltAgent extends AbstractAgent { + /** + * Stop must reach the pending build as well as the eventual model. Keep both in one per-run + * record so a late build or teardown cannot replace the next run's cancellation target. + */ + private active?: { controller: AbortController; inner?: AbstractAgent }; + /** The same Bot with nothing narrowed, kept to answer questions that are not about one run. */ + private whole: AbstractAgent; + private build: ( + input: RunAgentInput, + signal: AbortSignal, + ) => Promise; + + constructor( + identity: { agentId: string; description: string }, + whole: AbstractAgent, + build: ( + input: RunAgentInput, + signal: AbortSignal, + ) => Promise, + ) { + super(identity); + this.whole = whole; + this.build = build; + } + + run(input: RunAgentInput): Observable { + return defer(() => { + const active: NonNullable = { + controller: new AbortController(), + }; + this.active = active; + const { signal } = active.controller; + return defer(() => this.build(input, signal)).pipe( + switchMap((agent) => { + signal.throwIfAborted(); + active.inner = agent; + return agent.run(input); + }), + // Settle Stop even if a collaborator ignores the signal or rejects after cancellation. + takeUntil(fromEvent(signal, "abort")), + finalize(() => { + if (this.active === active) this.active = undefined; + active.controller.abort(PLAN_RUN_COMPLETED); + }), + ); + }); + } + + /** + * What the Bot can do, answered from the un-narrowed agent. + * + * Capabilities are asked for outside a run, where there is no message and so nothing to select + * against. They are also a fact about the Bot rather than about one turn: a deployment that + * narrowed this run to three tools has not stopped supporting whatever the underlying agent + * supports. + */ + getCapabilities() { + return this.whole.getCapabilities?.() ?? Promise.resolve({}); + } + + /** + * Carried by hand, because `AbstractAgent.clone` does not know this class exists. + * + * It builds a bare object on the prototype and copies a fixed list of base fields onto it, so + * every field declared here arrives `undefined`. The runtime clones an agent before every run + * (`agents[agentId].clone()`), which means the omission is not a corner case: without this, the + * first message anybody sends fails on a `build` that is not a function. + */ + clone(): RunBuiltAgent { + const cloned = super.clone() as RunBuiltAgent; + cloned.whole = this.whole; + cloned.build = this.build; + // A clone owns its cancellation state, including while its build is pending. + cloned.active = undefined; + return cloned; + } + + abortRun(): void { + const active = this.active; + active?.controller.abort(); + active?.inner?.abortRun(); + super.abortRun(); + } +} + +export class UnavailableAgent extends AbstractAgent { + private reason: string; + + constructor(agent: { id: string; name: string; reason: string }) { + super({ agentId: agent.id, description: agent.name }); + this.reason = agent.reason; + } + + clone(): UnavailableAgent { + const cloned = super.clone() as UnavailableAgent; + // The runtime clones before running; the base clone only carries base-class fields. + cloned.reason = this.reason; + return cloned; + } + + // Refused here rather than at the endpoint: a deleted coworker has no endpoint worth contacting, + // and the person is owed the reason rather than a transport error. + run(): never { + throw new Error(this.reason); + } +} + +/** + * The platform client, with one answer corrected. + * + * A subclass rather than a wrapper. The runtime is handed this object and calls many methods on it, + * and the base class keeps its state in `#private` fields. Extending keeps every other method on + * the instance that owns those fields. The authentication observer separately binds methods to + * that original instance, so it preserves this override and its private-field receiver. + * + * `getThreadMessages` is the only override. `handleGetThreadMessages` in the runtime calls it and + * returns `Response.json` of whatever comes back, so an empty history here is the `{ messages: [] }` + * the browser expects and a 200 instead of a 500. + */ +export class IntelligenceKnowingANewThread extends CopilotKitIntelligence { + override getThreadMessages( + params: Parameters[0], + ) { + return historyOrEmpty(() => super.getThreadMessages(params), { + messages: [], + }); + } +} diff --git a/toolkit/src/copilotkit/thread-lock.ts b/toolkit/src/copilotkit/thread-lock.ts new file mode 100644 index 000000000..61cfcdce7 --- /dev/null +++ b/toolkit/src/copilotkit/thread-lock.ts @@ -0,0 +1,109 @@ +import type { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; +import type { ThreadLock } from "../ports"; + +/** + * How long a conversation's lock is held before it lapses on its own. + * + * Matches the platform's own default rather than picking a number: this is renewed while a Bot works, + * so what it really sets is how long a conversation stays stuck after a process dies mid-run. + * + * Advisory today: the platform applies its own fixed 120 seconds on acquire and renew whatever is + * asked for (`THREAD_LOCK_TTL_SECONDS` in Intelligence's `apps/app-api/src/routes/thread-lock.ts`). + * See `TURN_LIMITS` in `@openbot/toolkit/limits` for the headless turn's side of the same lock. + */ +export const THREAD_LOCK_TTL_SECONDS = 120; + +/** The four platform calls the lock makes, and nothing else of the client. */ +export type ThreadLockClient = Pick< + CopilotKitIntelligence, + | "getOrCreateThread" + | "ɵacquireThreadLock" + | "ɵrenewThreadLock" + | "ɵcleanupThreadLock" +>; + +/** + * The conversation's run lock, as the platform issues it. + * + * ONE RUN AT A TIME PER CONVERSATION. Taken before anything is streamed, because the gateway + * checks every event against the run the lock names: a run that skips this is claiming to be one + * nobody was told about, and every event is refused. That refusal reads like a platform + * limitation and is a missing step. + * + * A conversation somebody else is already running in refuses rather than queues, which is right: + * the caller waits and tries again rather than two Bots writing over each other. + */ +export function createIntelligenceThreadLock(options: { + client: ThreadLockClient; + learning?: { + containerForThread: (input: { + agentId: string; + threadId: string; + userId: string; + }) => Promise; + }; + onRunBusy?: (input: { threadId: string; busy: boolean }) => void; +}): ThreadLock { + const { client, learning, onRunBusy } = options; + return { + acquire: async (input) => { + try { + const learningContainerId = await learning?.containerForThread(input); + await client.getOrCreateThread({ + ...input, + ...(learningContainerId ? { learningContainerId } : {}), + }); + const held = await client.ɵacquireThreadLock({ + ...input, + ...(learningContainerId ? { learningContainerId } : {}), + }); + // A run started on this thread. Side effect only, never awaited: a channel showing it is + // working is worth nothing next to the lock the run depends on. + try { + onRunBusy?.({ threadId: input.threadId, busy: true }); + } catch {} + /* + * The run id only. The lock also hands back a join token, which is what a browser presents + * to watch the conversation; the runner's socket has its own credential and passing this + * one in place of it means a socket that is refused and a run that never starts. See the + * note on `runner.run` in handoff-delivery.ts. + */ + return { runId: held.runId }; + } catch (error) { + /* + * ONLY A CONFLICT MEANS "NOT NOW". Everything else is raised. + * + * A conversation somebody is already running in answers 409, and that is ordinary: the hop + * waits and is tried again. Anything else is not — a platform that cannot be reached, a + * token that stopped working, or one of the underscored APIs below being renamed by a + * routine version bump. Returned as `null` those all read as contention: every hop retries + * to exhaustion, every person is told their question was never answered, and the only + * evidence is a warning line that looks like a busy conversation. + * + * Raised, the runner writes the real reason onto `agent.handoff_failed`, and the sentence + * the person eventually gets names it. + */ + const status = + error instanceof Error && "status" in error + ? (error as { status?: unknown }).status + : undefined; + if (status === 409) return null; + throw error; + } + }, + renew: async (input) => { + await client.ɵrenewThreadLock({ + ...input, + ttlSeconds: THREAD_LOCK_TTL_SECONDS, + }); + }, + release: async (input) => { + // The run on this thread is over. Cleared here rather than trusting a browser: the run may + // have outlived the tab that started it, and this is where the platform is told it ended. + try { + onRunBusy?.({ threadId: input.threadId, busy: false }); + } catch {} + await client.ɵcleanupThreadLock(input); + }, + }; +} diff --git a/toolkit/src/granted-tool.ts b/toolkit/src/granted-tool.ts new file mode 100644 index 000000000..96f05b387 --- /dev/null +++ b/toolkit/src/granted-tool.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import type { AuditInitiator } from "./initiator"; + +/** A tool a Bot holds, as the built-in runtime is handed it and executes it on the server. */ +export type GrantedTool = { + name: string; + description: string; + parameters: z.ZodType; + execute: (args: unknown) => Promise; + initiator?: AuditInitiator; + /** + * `/`, carried alongside the name the model is offered. + * + * The two spellings exist because a model tool name may not contain a slash, and selection has to + * compare a tool against what a skill declared, which is written in the ref spelling because that + * is how a grant is written. Carried rather than derived at the comparison, so there is one place + * the two forms are converted (`toolNameFor`) and no second parser to drift from it. + */ + ref: string; +}; + +/** + * A vendor's JSON Schema as something the model can be handed. + * + * Anything that is not an object schema describes something other than a tool's arguments, and a + * schema we cannot read must not stop the tool being offered: an open object lets the model call it + * and lets the vendor be the one to reject a bad argument, which is where that error belongs. + */ +export function parametersFor(inputSchema: Record): z.ZodType { + try { + const converted = z.fromJSONSchema(inputSchema as never); + if (converted instanceof z.ZodObject) return converted; + } catch {} + return z.object({}).catchall(z.unknown()); +} diff --git a/toolkit/src/headless-tools.ts b/toolkit/src/headless-tools.ts new file mode 100644 index 000000000..58958a2f9 --- /dev/null +++ b/toolkit/src/headless-tools.ts @@ -0,0 +1,340 @@ +import { + AbstractAgent, + type BaseEvent, + EventType, + type Message, + Middleware, + type RunAgentInput, + type RunFinishedEvent, + type Tool, + type ToolCall, +} from "@ag-ui/client"; +import { Observable, type Subscription } from "rxjs"; +import { withApprovalContext } from "./continuation"; +import type { AuditInitiator } from "./initiator"; + +/** Client tools supplied by OpenBot when the caller has no browser. */ +export type HeadlessTool = { + definition: Tool; + /** + * Executed when called but never offered to the model. For a tool the Bot does not hold, so a + * call it makes anyway gets a governed refusal rather than going unanswered. + */ + hidden?: boolean; + execute( + args: unknown, + context: { toolCallId: string; signal: AbortSignal }, + ): Promise; +}; + +/** Where a suspended run's waiting details ride on its AG-UI interrupt. */ +export const OPENBOT_WAITING_METADATA = "openbotWaiting"; + +export type HeadlessWaiting = { + kind: string; + requestId?: string; + [key: string]: unknown; +}; + +/** A durable request remains open; continuing the Bot would invent a human answer. */ +export class HeadlessToolSuspension extends Error { + constructor( + message: string, + readonly waiting: HeadlessWaiting, + ) { + super(message); + this.name = "HeadlessToolSuspension"; + } +} + +const MAX_TOOL_SEGMENTS = 30; + +/** + * Executes AG-UI client tools inside the Intelligence run. Backend results are + * forwarded unchanged; only newly emitted unanswered calls can be executed. + */ +export class HeadlessToolsMiddleware extends Middleware { + constructor( + private readonly tools: HeadlessTool[], + private readonly initiator?: AuditInitiator, + ) { + super(); + const names = tools.map((tool) => tool.definition.name); + if (new Set(names).size !== names.length) + throw new Error("Headless tool names must be unique."); + } + + run( + originalInput: RunAgentInput, + next: AbstractAgent, + ): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + const tools = new Map( + this.tools.map((tool) => [tool.definition.name, tool]), + ); + const executed = new Set(); + let subscription: Subscription | undefined; + let started = false; + const work = async () => { + let request: RunAgentInput = { + ...originalInput, + tools: [ + ...originalInput.tools.filter((tool) => !tools.has(tool.name)), + ...this.tools + .filter((tool) => !tool.hidden) + .map((tool) => tool.definition), + ], + }; + for (let segment = 0; segment <= MAX_TOOL_SEGMENTS; segment += 1) { + if (subscriber.closed) return; + const historical = new Set( + request.messages.flatMap((message) => + message.role === "assistant" + ? (message.toolCalls ?? []).map((call) => call.id) + : [], + ), + ); + const emitted = new Set(); + let messages = request.messages; + let state = request.state; + let terminal: RunFinishedEvent | undefined; + let failed = false; + await new Promise((resolve, reject) => { + subscription = this.runNextWithState( + request, + new InvocationTransport(next, request), + ).subscribe({ + next: ({ + event, + messages: updatedMessages, + state: updatedState, + }) => { + messages = updatedMessages; + state = updatedState; + if (event.type === EventType.TOOL_CALL_START) + emitted.add(String(event.toolCallId)); + if (event.type === EventType.MESSAGES_SNAPSHOT) { + for (const message of messages) { + if (message.role !== "assistant") continue; + for (const call of message.toolCalls ?? []) { + if (!historical.has(call.id)) emitted.add(call.id); + } + } + } + if (event.type === EventType.RUN_FINISHED) { + terminal = event as RunFinishedEvent; + return; + } + if (event.type === EventType.RUN_STARTED) { + if (started) return; + started = true; + } + if (event.type === EventType.RUN_ERROR) failed = true; + subscriber.next(event); + }, + error: reject, + complete: resolve, + }); + }); + if (subscriber.closed || failed) return; + if (!terminal) + throw new Error( + "The headless agent ended without a terminal event.", + ); + const answered = new Set( + messages.flatMap((message) => + message.role === "tool" ? [message.toolCallId] : [], + ), + ); + const pending: ToolCall[] = messages.flatMap((message) => + message.role === "assistant" + ? (message.toolCalls ?? []).filter( + (call) => + emitted.has(call.id) && + !historical.has(call.id) && + !answered.has(call.id) && + !executed.has(call.id), + ) + : [], + ); + const owned = pending.filter( + (call) => + tools.has(call.function.name) || + call.function.name.startsWith("computer_"), + ); + if (owned.length === 0) { + subscriber.next(terminal); + return; + } + if (segment === MAX_TOOL_SEGMENTS) + throw new Error( + "The headless tool continuation limit was reached.", + ); + const results = new Map(); + for (const call of owned) { + if (subscriber.closed) return; + executed.add(call.id); + let content: string; + let error: string | undefined; + try { + const tool = tools.get(call.function.name); + if (!tool) + throw new Error( + `The tool ${call.function.name} is unavailable for this headless turn.`, + ); + const args: unknown = JSON.parse(call.function.arguments || "{}"); + const continuation = { + runId: request.runId, + threadId: request.threadId, + toolCallId: call.id, + toolName: call.function.name, + args, + messages, + state, + context: request.context, + forwardedProps: request.forwardedProps, + initiator: this.initiator, + }; + const result = await withApprovalContext(continuation, () => + tool.execute(args, { + toolCallId: call.id, + signal: controller.signal, + }), + ); + content = + typeof result === "string" ? result : JSON.stringify(result); + if (content === undefined) + throw new Error( + `The tool ${call.function.name} returned no result.`, + ); + } catch (failure) { + if (failure instanceof HeadlessToolSuspension) { + failure.waiting.continuation ??= { + runId: request.runId, + threadId: request.threadId, + toolCallId: call.id, + toolName: call.function.name, + args: JSON.parse(call.function.arguments || "{}"), + messages, + state, + context: request.context, + forwardedProps: request.forwardedProps, + initiator: this.initiator, + }; + subscriber.next({ + type: EventType.CUSTOM, + name: "openbot.headless.waiting", + value: { + ...failure.waiting, + message: failure.message, + toolCallId: call.id, + }, + }); + subscriber.next({ + ...terminal, + outcome: { + type: "interrupt", + interrupts: [ + { + id: call.id, + toolCallId: call.id, + reason: "human_input", + message: failure.message, + metadata: { openbotWaiting: failure.waiting }, + }, + ], + }, + }); + return; + } + error = + failure instanceof Error ? failure.message : String(failure); + content = JSON.stringify({ ok: false, error }); + } + if (subscriber.closed) return; + const message: Message = { + id: crypto.randomUUID(), + role: "tool", + toolCallId: call.id, + content, + ...(error ? { error } : {}), + }; + messages = [...messages, message]; + results.set(call.id, content); + // AG-UI 1.0's TOOL_CALL_RESULT has no `error` field and validation strips one; the + // refusal travels in `content`, and on the tool message, which keeps `error`. + subscriber.next({ + type: EventType.TOOL_CALL_RESULT, + messageId: message.id, + toolCallId: call.id, + role: "tool", + content, + }); + } + const interrupts = + terminal.outcome?.type === "interrupt" + ? terminal.outcome.interrupts + : []; + const unresolved = interrupts.filter( + (interrupt) => !results.has(interrupt.toolCallId ?? interrupt.id), + ); + if (owned.length !== pending.length || unresolved.length > 0) { + subscriber.next( + unresolved.length > 0 + ? { + ...terminal, + outcome: { type: "interrupt", interrupts: unresolved }, + } + : terminal, + ); + return; + } + request = { + ...request, + messages, + state, + resume: + interrupts.length > 0 + ? interrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved" as const, + payload: results.get(interrupt.toolCallId ?? interrupt.id), + })) + : undefined, + }; + } + }; + // Observable teardown owns cancellation; both asynchronous exits are observed. + work().then( + () => subscriber.complete(), + (error: unknown) => { + if (subscriber.closed) return; + subscriber.next({ + type: EventType.RUN_ERROR, + code: "OPENBOT_HEADLESS_TOOL_ERROR", + message: error instanceof Error ? error.message : String(error), + }); + subscriber.complete(); + }, + ); + return () => { + controller.abort(); + subscription?.unsubscribe(); + }; + }); + } +} + +/** AG-UI accumulation reads transport history, so seed each continuation explicitly. */ +class InvocationTransport extends AbstractAgent { + constructor( + private readonly transport: AbstractAgent, + input: RunAgentInput, + ) { + super({ initialMessages: input.messages, initialState: input.state }); + } + run(input: RunAgentInput) { + return this.transport.run(input); + } +} diff --git a/server/src/agents/history-sanitize.ts b/toolkit/src/history-sanitize.ts similarity index 97% rename from server/src/agents/history-sanitize.ts rename to toolkit/src/history-sanitize.ts index 0c4fa22cb..1f75c48ab 100644 --- a/server/src/agents/history-sanitize.ts +++ b/toolkit/src/history-sanitize.ts @@ -2,10 +2,10 @@ * The one filter that keeps a broken conversation from being replayed at a model provider for ever. * * IT LIVES HERE BECAUSE BOTH TURN PATHS NEED IT. A routine's headless turn seeds history itself - * (`routines/run-turn.ts`) and a chat turn is handed history by the browser (`copilot.ts`). Both + * (`toolkit/run-turn.ts`) and a chat turn is handed history by the browser (`copilot.ts`). Both * hand that history to a `BuiltInAgent`, which converts it and lets the model provider validate the - * call/result pairing. `run-turn.ts` imports `../copilot`, so this cannot live in either of them - * without one importing the other back. + * call/result pairing. A module of its own, in the toolkit, so the chat path can use it without + * importing the headless turn runner. */ import type { Message, ToolCall } from "@ag-ui/client"; diff --git a/toolkit/src/initiator.ts b/toolkit/src/initiator.ts new file mode 100644 index 000000000..60730152a --- /dev/null +++ b/toolkit/src/initiator.ts @@ -0,0 +1,11 @@ +/** + * What caused a turn, or a row on the audit trail, where `actorUserId` is only whose authority it + * borrowed. Re-exported from audit.ts, which is where the rest of the server reads it. + */ +export type AuditInitiator = + | { kind: "person" } + | { kind: "deployment" } + | { kind: "routine"; id: string } + | { kind: "responsibility"; id: string } + | { kind: "memory"; id: string } + | { kind: "handoff"; id: string; origin?: AuditInitiator }; diff --git a/toolkit/src/lease.ts b/toolkit/src/lease.ts new file mode 100644 index 000000000..4b1db0c2e --- /dev/null +++ b/toolkit/src/lease.ts @@ -0,0 +1,97 @@ +/** + * Keeping a thread's lock while a run works, shared by every turn that takes one. + */ + +/** + * Whether a failed renewal means the lock now belongs to somebody else. + * + * The platform answers a renewal for a lock this run no longer owns — expired, or taken by another + * run — with 409 `THREAD_LOCK_RENEWAL_FAILED`, which it marks as not retryable. That is the fast way + * to find out. It is not the only one: `keepLease` also stops once renewals have failed for longer + * than the lock lives, whatever the failures were, so a runtime upgrade that renames this error + * delays the stop to the lifetime rather than removing it. + * + * Matched on the shape, as `isMissingThread` is, because `PlatformRequestError` is not exported by + * `@copilotkit/runtime`. + */ +export function isLockConflict(error: unknown): boolean { + return ( + error instanceof Error && + error.name === "PlatformRequestError" && + (error as { status?: unknown }).status === 409 + ); +} + +/** + * No renewal has succeeded for nearly as long as the lock lives, so it will have lapsed before the + * next one could land, whatever the reason was. + */ +export class LockLapsedError extends Error { + override name = "LockLapsedError"; + constructor(lifetimeMs: number) { + super( + `The conversation's lock has not been renewed within its ${Math.round(lifetimeMs / 1000)}s lifetime, so it is lapsing and may be somebody else's.`, + ); + } +} + +/** + * Renew a held lock every `everyMs` until stopped, and say when it is lost. + * + * Lost means one of two things, and either one stops the heartbeat and calls `onLost`, once: + * + * - A renewal is refused as a conflict ({@link isLockConflict}): somebody else holds it now. + * - No renewal has succeeded for so long that the lock will have lapsed before the next tick: + * longer than `lifetimeMs - everyMs`, whatever the failures were — network errors, 5xx, 401, a + * request that never settles. Checked on each tick rather than when a renewal fails, because a + * renewal that hangs never fails. One interval short of the lifetime, so the turn stops while the + * lock is still its own rather than up to one interval after it has gone. + * + * Anything else is a renewal that did not land this time and is skipped; the next tick tries again. + * That is the point of the lifetime: a blip shorter than it rides through, instead of stopping the + * turn and having it re-run from the start. `lifetimeMs` counts from when this is called, which is + * right after the lock was acquired with that lifetime. + */ +export function keepLease(input: { + renew: () => Promise; + everyMs: number; + lifetimeMs: number; + onLost: (error: unknown) => void; + /** The clock, for tests. */ + now?: () => number; +}): { stop: () => void } { + const { renew, everyMs, lifetimeMs, onLost, now = Date.now } = input; + // The last moment a tick can find the lock unrenewed and still stop before it lapses. + const giveUpAfterMs = Math.max(lifetimeMs - everyMs, 0); + let renewedAt = now(); + let heartbeat: ReturnType | undefined; + const stop = () => { + if (heartbeat === undefined) return; + clearInterval(heartbeat); + heartbeat = undefined; + }; + const lose = (error: unknown) => { + if (heartbeat === undefined) return; + stop(); + onLost(error); + }; + heartbeat = setInterval(() => { + if (now() - renewedAt > giveUpAfterMs) { + lose(new LockLapsedError(lifetimeMs)); + return; + } + const asked = now(); + void renew().then( + () => { + // A renewal that lands late still only extends the lock from when it was asked for. + if (heartbeat !== undefined) renewedAt = Math.max(renewedAt, asked); + }, + (error: unknown) => { + if (isLockConflict(error)) lose(error); + }, + ); + }, everyMs); + // So a heartbeat that is still pending cannot hold a one-shot process open. + heartbeat.unref?.(); + return { stop }; +} diff --git a/toolkit/src/limits.ts b/toolkit/src/limits.ts new file mode 100644 index 000000000..7195e82e8 --- /dev/null +++ b/toolkit/src/limits.ts @@ -0,0 +1,43 @@ +/** + * The limits a headless turn runs under, in one place. + * + * No imports, on purpose: the routines sweep runs as a CronJob and derives its own cutoff from + * `turnTimeoutMs`, so it reads this file without pulling the turn runner, the Intelligence client and + * everything behind them into that process. + */ +export const TURN_LIMITS = { + /** How long one headless turn may take before it is stopped. */ + turnTimeoutMs: 5 * 60_000, + + /** + * The gap between stopping a turn and giving up on it. + * + * `abortRun` on `RunSelectedAgent` reaches the agent the run turned into, and that agent does not + * exist until `build()` resolves (`copilot.ts:649, 664-673`): during that window the wrapper has no + * `inner`, so abort is a no-op and the deadline cannot actually stop anything. This is the backstop + * that settles the promise anyway, so a firing cannot hang for ever on a build that never finishes. + */ + abortGraceMs: 5_000, + + /** + * The lock lifetime this turn asks for, and how often it renews. + * + * The same relationship the runtime's own handler uses: renew comfortably inside the lifetime so one + * slow request does not drop a lock we still hold. The lifetime matters to a person: while it is + * held, their browser's next message is refused with 409 "Thread lock denied" (`run.mjs:91`), so a + * lock leaked by a failed routine locks them out of their own conversation for that long. + * + * 120 SECONDS, THE PLATFORM'S OWN. The Intelligence platform currently ignores the requested + * lifetime and applies its fixed 120 seconds on acquire and renew (`THREAD_LOCK_TTL_SECONDS` in + * `apps/app-api/src/routes/thread-lock.ts`), so asking for the same number keeps this one true. It + * was 20, which never took effect. + * + * And it is the right number if the platform starts honouring it. A renewal that merely failed is + * skipped rather than ending the turn (see `keepLease`), so the lifetime is what carries a turn + * across a missed renewal: 120 seconds survives about seven at a 15-second heartbeat, where 20 + * would have lost the lock to one. The cost is how long a crashed turn holds the conversation, + * which is two minutes either way today. + */ + lockTtlSeconds: 120, + heartbeatMs: 15_000, +} as const; diff --git a/server/src/approvals/native-context.ts b/toolkit/src/native-approval.ts similarity index 95% rename from server/src/approvals/native-context.ts rename to toolkit/src/native-approval.ts index 008e89756..74dfe315b 100644 --- a/server/src/approvals/native-context.ts +++ b/toolkit/src/native-approval.ts @@ -8,9 +8,14 @@ import { type RunAgentInput, } from "@ag-ui/client"; import { Observable, type Subscription } from "rxjs"; -import type { AuditInitiator } from "../audit"; -import { HeadlessToolSuspension } from "../computer/headless-tools"; -import { withApprovalContext } from "./types"; +import { withApprovalContext } from "./continuation"; +import { + HeadlessToolSuspension, + OPENBOT_WAITING_METADATA, +} from "./headless-tools"; +import type { AuditInitiator } from "./initiator"; + +export { OPENBOT_WAITING_METADATA }; type NativeRun = { input: RunAgentInput; @@ -22,9 +27,6 @@ type NativeRun = { }; const nativeRuns = new AsyncLocalStorage(); -/** Where a suspended run's waiting details ride on its AG-UI interrupt. */ -export const OPENBOT_WAITING_METADATA = "openbotWaiting"; - export async function executeNativeApprovalTool( name: string, args: unknown, diff --git a/toolkit/src/ports.ts b/toolkit/src/ports.ts new file mode 100644 index 000000000..b751a62f3 --- /dev/null +++ b/toolkit/src/ports.ts @@ -0,0 +1,68 @@ +import type { HeadlessTool } from "./headless-tools"; +import type { AuditInitiator } from "./initiator"; + +/** + * What the toolkit needs from its host, named so the turn code can be read without the server. + * + * Each port is the narrowest shape the turn code calls. The server supplies the implementations. + */ + +/** + * Whether a turn may start, and the signal that stops it if that changes while it runs. + * + * Throws to refuse: a paused Bot, or a person without "Use Bots" or on a model off the allowlist. + * Resolves with a signal that aborts when the Bot is paused mid-turn, joined with the caller's own + * signal when there is one. + */ +export type TurnAdmission = (input: { + ownerUserId: string; + agentId: string; + signal?: AbortSignal; +}) => Promise; + +/** + * The display components a Bot can draw in a turn with no browser, so it can still answer with a + * chart. The server's catalog is `headlessComponentCatalog` in components/headless.ts. + */ +export type ComponentCatalog = { + /** Every component's tool name, so a turn can tell which of its tool calls drew one. */ + names: ReadonlySet; + /** The component tools this Bot is offered, decided as the browser would decide them. */ + toolsForTurn: (input: { + agentId: string; + ownerUserId: string; + initiator: AuditInitiator; + }) => Promise; +}; + +/** + * The conversation's run lock. + * + * ONE RUN AT A TIME PER CONVERSATION, taken before anything is streamed. The platform hands the lock + * out through an ordinary authenticated call and hands back the token that proves it: a run that + * skips this and starts streaming is refused, because it is claiming to be a run nobody was told + * about. That is what every delivery did before this existed, and the refusal read like a platform + * limitation rather than a missing step. + * + * Taken with `NX`, so a conversation somebody else is already running in refuses rather than queues. + * That is the right answer and the hop simply waits its turn: it is released back to the queue and + * tried again, which is a wait rather than a failure. + */ +export type ThreadLock = { + /** + * The run id the platform issued, or null when somebody else is running in this conversation. + * + * ITS OWN ID, NOT THE ONE ASKED FOR. That id is what the gateway checks every streamed event + * against, so a run that used the local one would be claiming to be a run nobody was told about. + */ + acquire: (input: { + threadId: string; + runId: string; + userId: string; + agentId: string; + }) => Promise<{ runId: string } | null>; + /** Keep it while the addressed Bot works. The lock expires on its own otherwise. */ + renew: (input: { threadId: string; runId: string }) => Promise; + /** Give it back, so the next run does not wait out the whole expiry. */ + release: (input: { threadId: string; runId: string }) => Promise; +}; diff --git a/server/src/routines/run-turn.ts b/toolkit/src/run-turn.ts similarity index 62% rename from server/src/routines/run-turn.ts rename to toolkit/src/run-turn.ts index 27373607c..cff83a08c 100644 --- a/server/src/routines/run-turn.ts +++ b/toolkit/src/run-turn.ts @@ -55,52 +55,48 @@ import type { RunAgentInput, } from "@ag-ui/client"; import { EventType } from "@ag-ui/client"; -import { NOT_SHOWN } from "../../../shared/component-markers"; -import { frameFiring } from "../../../shared/routine-firing"; -import { headlessTurnRefusal } from "../admin/controls"; -import { sanitizeSeededHistory } from "../agents/history-sanitize"; -import { guardBotTurn } from "../agents/lifecycle"; -import { OPENBOT_WAITING_METADATA } from "../approvals/native-context"; -import type { AuditInitiator, AuditStore } from "../audit"; -import { - createHeadlessComponentTools, - HEADLESS_COMPONENTS, -} from "../components/headless"; -import type { ComponentStore } from "../components/store"; +import { NOT_SHOWN } from "@openbot/shared/component-markers"; +import { frameFiring } from "@openbot/shared/routine-firing"; +import type { ApprovalContinuation } from "./continuation"; import { type HeadlessTool, HeadlessToolSuspension, HeadlessToolsMiddleware, type HeadlessWaiting, -} from "../computer/headless-tools"; -import { historyOrEmpty } from "../copilot"; -import type { DrawnComponent, TurnRunner } from "./runner"; - -/** - * The gap between stopping a turn and giving up on it. - * - * `abortRun` on `RunSelectedAgent` reaches the agent the run turned into, and that agent does not - * exist until `build()` resolves (`copilot.ts:649, 664-673`): during that window the wrapper has no - * `inner`, so abort is a no-op and the deadline cannot actually stop anything. This is the backstop - * that settles the promise anyway, so a firing cannot hang for ever on a build that never finishes. - * - * Injectable only so the test can exercise the backstop without waiting five real seconds for it. - */ -const DEFAULT_ABORT_GRACE_MS = 5_000; - -/** How long one headless turn may take before it is stopped. */ -const DEFAULT_TURN_TIMEOUT_MS = 5 * 60_000; - -/** - * The lock TTL and how often it is renewed. - * - * The same relationship the runtime's own handler uses: renew comfortably inside the TTL so one slow - * request does not drop a lock we still hold. The TTL matters to a person: while it is held, their - * browser's next message is refused with 409 "Thread lock denied" (`run.mjs:91`), so a lock leaked by - * a failed routine locks them out of their own conversation for exactly this long. - */ -const DEFAULT_LOCK_TTL_SECONDS = 20; -const DEFAULT_HEARTBEAT_MS = 15_000; + OPENBOT_WAITING_METADATA, +} from "./headless-tools"; +import { sanitizeSeededHistory } from "./history-sanitize"; +import type { AuditInitiator } from "./initiator"; +import { keepLease } from "./lease"; +import { TURN_LIMITS } from "./limits"; +import type { ComponentCatalog, TurnAdmission } from "./ports"; +import { historyOrEmpty } from "./thread-history"; + +/** Everything a headless turn needs, injectable so tests never dial a model. */ +export type TurnRunner = (input: { + ownerUserId: string; // the actor the run asserts — grants and connections resolve to them + routineId: string; // what the trail names as having started this turn, rather than the owner + agentId: string; + threadId: string; // the owner's thread for the routine's channel + instruction: string; // the user message of this turn + initiator?: AuditInitiator; + signal?: AbortSignal; + continuation?: { + snapshot: ApprovalContinuation; + result: { content: string; error?: string }; + messageId: string; + }; + userMessage?: Message; + /** Durable inbound delivery id; reused by lock, runner and emitted events. */ + runId?: string; + /** How deep in a Bot-to-Bot chain this turn is. Absent is a person's own turn, depth zero. */ + depth?: number; + /** The reply so far, each time more of it arrives, for a surface that shows a turn in progress. */ + onText?: (text: string) => void; +}) => Promise<{ replyText: string; components?: DrawnComponent[] }>; + +/** A display component the Bot drew in a turn and the server allowed, with the props it drew. */ +export type DrawnComponent = { name: string; args: Record }; /** * One row of Intelligence history, as `ThreadMessagesResponse` declares it @@ -216,10 +212,9 @@ function toAgentMessage(message: ThreadHistoryMessage): Message { } /** - * Re-exported from `agents/history-sanitize.ts`, where it now lives, because a chat turn needs - * it too and this module cannot be imported from `copilot.ts`, since the import already runs the - * other way. Kept as a name on this module because this is where the reasoning was found and where the - * tests that cover the seeding path still reach for it. + * Re-exported from `./history-sanitize.ts`, where it lives because a chat turn needs it too. Kept as + * a name on this module because this is where the reasoning was found and where the tests that + * cover the seeding path still reach for it. */ export { sanitizeSeededHistory }; @@ -232,13 +227,14 @@ function assistantText(message: Message): string | undefined { : undefined; } -const COMPONENT_NAMES = new Set(HEADLESS_COMPONENTS.map(({ name }) => name)); - /** * The display components this turn drew, in order: a component call whose result is not a refusal. * A chat surface that cannot open the conversation (Teams, Slack) draws them from this. */ -export function drawnComponents(messages: Message[]): DrawnComponent[] { +export function drawnComponents( + messages: Message[], + componentNames: ReadonlySet, +): DrawnComponent[] { const results = new Map(); for (const message of messages) if (message.role === "tool" && typeof message.content === "string") @@ -247,7 +243,7 @@ export function drawnComponents(messages: Message[]): DrawnComponent[] { for (const message of messages) { if (message.role !== "assistant") continue; for (const call of message.toolCalls ?? []) { - if (!COMPONENT_NAMES.has(call.function.name)) continue; + if (!componentNames.has(call.function.name)) continue; const result = results.get(call.id); if (result === undefined || result.startsWith(NOT_SHOWN)) continue; try { @@ -276,9 +272,277 @@ export function drawnComponents(messages: Message[]): DrawnComponent[] { */ export { frameFiring }; +/** + * What a turn is given, and what it may persist. + * + * `messages` is the conversation the run sees: the seeded history plus this turn's message, or, for a + * continuation, the snapshot it stopped on plus the answer to the call it was waiting for. + * + * `persistedInputMessages` is WHAT THIS RUN IS ALLOWED TO PERSIST, and it is mandatory. `run.mjs:117-127`: + * the set subtraction on ids, not on positions. The runner defaults it to the WHOLE input + * (`intelligence.mjs:283`), so omitting it re-persists every message in the thread on every firing — a + * transcript that doubles in size every night until the person's channel is unreadable. + */ +export function turnMessages(input: { + /** The thread's history as the platform holds it, already converted and sanitized. */ + seeded: Message[]; + /** The ids of that history, before sanitizing, which is what the platform already holds. */ + historicIds: ReadonlySet; + /** This turn's own message; ignored for a continuation. */ + turn: Message; + continuation?: Parameters[0]["continuation"]; +}): { messages: Message[]; persistedInputMessages: Message[] } { + const { seeded, historicIds, turn, continuation } = input; + const messages: Message[] = continuation + ? sanitizeSeededHistory([ + ...continuation.snapshot.messages, + { + id: continuation.messageId, + role: "tool", + toolCallId: continuation.snapshot.toolCallId, + ...continuation.result, + }, + ]) + : [...seeded, turn]; + return { + messages, + persistedInputMessages: messages.filter( + (message) => !historicIds.has(message.id), + ), + }; +} + +/** The run's input: this run's id, the conversation, the tools it is offered, and any resume. */ +export function turnInput(input: { + threadId: string; + runId: string; + messages: Message[]; + state: unknown; + tools: readonly HeadlessTool[]; + continuation?: Parameters[0]["continuation"]; +}): RunAgentInput { + const { threadId, runId, messages, state, tools, continuation } = input; + return { + threadId, + runId, + messages, + state, + tools: tools.filter((tool) => !tool.hidden).map((tool) => tool.definition), + context: continuation?.snapshot.context ?? [], + forwardedProps: continuation?.snapshot.forwardedProps, + ...(continuation + ? { + resume: [ + { + interruptId: continuation.snapshot.toolCallId, + status: "resolved" as const, + payload: continuation.result.content, + }, + ], + } + : {}), + }; +} + +/** + * The thread's lock for one run, held until it is released. + * + * Acquired with this run's own id and renewed on a heartbeat with the same id, so the lock the + * cleanup releases is the lock that was taken. A renew refused as a conflict means somebody else is + * in this thread — the person, most likely, having just typed something — so the heartbeat stops + * and `onLost` is told, once; the caller stops the turn rather than write its events into their + * run. A renew that merely failed is skipped; see `keepLease`. + * + * This is a deliberate departure from the runtime's own channel runner (`channel-manager.mjs:257-273`), + * which stops on any renewal error: a turn stopped by a network blip is retried from the start, and + * a Bot that already sent an email sends it again. + */ +type Lease = { + /** Stop renewing. Safe to call more than once. */ + stopHeartbeat: () => void; + /** Give the lock back. Never rejects: the TTL is the backstop for a cleanup that cannot be reached. */ + release: () => Promise; +}; + +async function takeLease(input: { + intelligence: IntelligenceLike; + threadId: string; + runId: string; + userId: string; + agentId: string; + ttlSeconds: number; + learningContainerId?: string; + heartbeatMs: number; + onLost: (error: unknown) => void; +}): Promise { + const { + intelligence, + threadId, + runId, + userId, + agentId, + ttlSeconds, + learningContainerId, + heartbeatMs, + onLost, + } = input; + await intelligence.ɵacquireThreadLock({ + threadId, + runId, + userId, + agentId, + ttlSeconds, + ...(learningContainerId ? { learningContainerId } : {}), + }); + + const heartbeat = keepLease({ + renew: () => intelligence.ɵrenewThreadLock({ threadId, runId, ttlSeconds }), + everyMs: heartbeatMs, + lifetimeMs: ttlSeconds * 1000, + onLost, + }); + + return { + stopHeartbeat: heartbeat.stop, + release: () => + intelligence + .ɵcleanupThreadLock({ threadId, runId }) + .catch(() => undefined), + }; +} + +/** + * How a headless turn ended: exactly one of these per turn, decided by {@link settle} and turned into + * a reply or an error only by {@link conclude}. + */ +export type TurnOutcome = + /** The Bot answered. */ + | { kind: "completed"; replyText: string; components: DrawnComponent[] } + /** The run itself failed: a RUN_ERROR, a broken stream, a cancellation, or a stop that never landed. */ + | { kind: "failed"; error: unknown } + /** The thread's lock was lost partway through, so whatever the turn said is not an answer. */ + | { kind: "lockLost"; error: unknown } + /** The deadline stopped the run; whatever it had said is half a sentence. */ + | { kind: "timedOut" } + /** The turn is waiting on a person, with a durable request open. */ + | { kind: "suspended"; suspension: HeadlessToolSuspension } + /** The turn stopped to ask a question with nobody there to answer it. */ + | { kind: "interrupted" } + /** The run finished and said nothing. */ + | { kind: "empty" }; + +/** Everything a finished run left behind, which is all {@link settle} decides from. */ +export type TurnEvidence = { + /** Set when the run's own promise rejected; wrapped because the rejection itself may be anything. */ + failed?: { error: unknown }; + /** The renew failure that lost the lock, when one did. */ + heartbeatError?: unknown; + /** Whether the deadline stopped the run. */ + stopped: boolean; + /** The waiting the run announced through its `openbot.headless.waiting` event. */ + waiting?: HeadlessToolSuspension; + /** The messages this run added to the agent. */ + added: Message[]; + /** What was streamed, for when nothing was added. */ + chunks: string[]; + pendingInterrupts: AbstractAgent["pendingInterrupts"]; + componentNames: ReadonlySet; +}; + +/** + * The one decision about how a turn ended, in this order and no other. + * + * A failure that escaped the run wins, because nothing after it was reached. Then a lost lock and the + * deadline: a turn that lost its lock or was stopped is not a turn that answered, however much text it + * produced first. Then a waiting, announced or carried on the interrupt, which names the request a + * person still has to answer. Then a bare interrupt, checked BEFORE the empty reply: a turn that + * interrupted before saying anything has both conditions true at once, and "finished without saying + * anything" would be a lie about a turn that in fact stopped to ask a question. + */ +export function settle(evidence: TurnEvidence): TurnOutcome { + if (evidence.failed) return { kind: "failed", error: evidence.failed.error }; + if (evidence.heartbeatError !== undefined) + return { kind: "lockLost", error: evidence.heartbeatError }; + if (evidence.stopped) return { kind: "timedOut" }; + if (evidence.waiting) + return { kind: "suspended", suspension: evidence.waiting }; + + // The diff first, the streamed chunks as the fallback: the diff is what was persisted, which is + // what the person will read in the channel, and the chunks are only what went past. + const said = evidence.added + .map(assistantText) + .filter((text): text is string => text !== undefined); + const replyText = (said.length > 0 ? said : evidence.chunks).join("\n\n"); + + // The waiting details, when the CUSTOM event did not survive the trip, ride on the interrupt. + const carried = evidence.pendingInterrupts + .map((interrupt) => interrupt.metadata?.[OPENBOT_WAITING_METADATA]) + .find( + (value): value is HeadlessWaiting & { message?: string } => + !!value && + typeof value === "object" && + typeof (value as { kind?: unknown }).kind === "string", + ); + if (carried) + return { + kind: "suspended", + suspension: new HeadlessToolSuspension( + carried.message ?? "The turn is waiting for human input.", + carried, + ), + }; + if (evidence.pendingInterrupts.length > 0) return { kind: "interrupted" }; + if (replyText.length === 0) return { kind: "empty" }; + return { + kind: "completed", + replyText, + components: drawnComponents(evidence.added, evidence.componentNames), + }; +} + +/** + * The outcome as the caller receives it: a reply, or the error that names what happened. + * + * For a lost lock and a deadline, `stopped` is awaited first — the reference's own order + * (`channel-manager.mjs:311-313`) — so a stop this turn itself requested has actually settled before + * it is reported on, not just been requested. + */ +async function conclude( + outcome: TurnOutcome, + stopped: Promise | undefined, + turnTimeoutMs: number, +): Promise<{ replyText: string; components?: DrawnComponent[] }> { + switch (outcome.kind) { + case "failed": + throw outcome.error; + case "lockLost": + await stopped; + throw outcome.error; + case "timedOut": + await stopped; + throw new Error( + `The routine's turn was stopped after ${Math.round(turnTimeoutMs / 1000)}s.`, + ); + case "suspended": + throw outcome.suspension; + case "interrupted": + throw new Error( + "The turn stopped to ask a question, and a routine has nobody to ask.", + ); + case "empty": + throw new Error("The turn finished without saying anything."); + case "completed": + return outcome.components.length > 0 + ? { replyText: outcome.replyText, components: outcome.components } + : { replyText: outcome.replyText }; + } +} + export function createTurnRunner(options: { intelligence: IntelligenceLike; runner: RunnerLike; + /** Checked before anything else; see `serverTurnAdmission` in agents/turn-admission.ts. */ + admission: TurnAdmission; learningContainerForThread?: (input: { threadId: string; agentId: string; @@ -300,27 +564,29 @@ export function createTurnRunner(options: { }) => Promise; /** * The governed gallery, so a turn with no browser can still answer with a chart. Offered and - * decided exactly as the browser would for this Bot; see `components/headless.ts`. + * decided exactly as the browser would for this Bot. Absent, nothing is offered and nothing is + * reported as drawn. */ - components?: { store: ComponentStore; auditStore?: AuditStore }; + components?: ComponentCatalog; /** How long one headless turn may take before it is stopped. */ turnTimeoutMs?: number; lockTtlSeconds?: number; heartbeatMs?: number; - /** See {@link DEFAULT_ABORT_GRACE_MS}. */ + /** See `TURN_LIMITS.abortGraceMs`; injectable so a test need not wait five real seconds. */ abortGraceMs?: number; }): TurnRunner { const { intelligence, runner, + admission, buildAgentFor, learningContainerForThread, toolsForTurn, components, - turnTimeoutMs = DEFAULT_TURN_TIMEOUT_MS, - lockTtlSeconds = DEFAULT_LOCK_TTL_SECONDS, - heartbeatMs = DEFAULT_HEARTBEAT_MS, - abortGraceMs = DEFAULT_ABORT_GRACE_MS, + turnTimeoutMs = TURN_LIMITS.turnTimeoutMs, + lockTtlSeconds = TURN_LIMITS.lockTtlSeconds, + heartbeatMs = TURN_LIMITS.heartbeatMs, + abortGraceMs = TURN_LIMITS.abortGraceMs, } = options; return async ({ @@ -338,16 +604,8 @@ export function createTurnRunner(options: { onText, }) => { if (signal?.aborted) throw new Error("The headless turn was cancelled."); - // Paused Bots do not start; a pause while running aborts this signal. See agents/lifecycle.ts. - signal = await guardBotTurn({ ownerUserId, agentId, signal }); - // "Use Bots" and the model allowlist, which a browser run meets at the HTTP gate. No headless turn - // crosses that gate, so every one of them meets the same check here instead. - const refused = await headlessTurnRefusal({ ownerUserId, agentId }); - if (refused) { - const error = new Error(refused); - error.name = "CapabilityRefusedError"; - throw error; - } + // A paused Bot or a refused capability throws here; a pause mid-turn aborts the returned signal. + signal = await admission({ ownerUserId, agentId, signal }); if (continuation && continuation.snapshot.threadId !== threadId) throw new Error( "The interrupted conversation does not match this thread.", @@ -421,30 +679,12 @@ export function createTurnRunner(options: { role: "user", content: frameFiring(instruction), } as Message); - const messages: Message[] = continuation - ? sanitizeSeededHistory([ - ...continuation.snapshot.messages, - { - id: continuation.messageId, - role: "tool", - toolCallId: continuation.snapshot.toolCallId, - ...continuation.result, - }, - ]) - : [...seeded, turn]; - - /* - * WHAT THIS RUN IS ALLOWED TO PERSIST, and it is mandatory. - * - * `run.mjs:117-127`: the set subtraction on ids, not on positions. The runner defaults it to the - * WHOLE input (`intelligence.mjs:283`), so omitting it re-persists every message in the thread on - * every firing — a transcript that doubles in size every night until the person's channel is - * unreadable. - */ - const historicIds = new Set(history.messages.map((message) => message.id)); - const persistedInputMessages = messages.filter( - (message) => !historicIds.has(message.id), - ); + const { messages, persistedInputMessages } = turnMessages({ + seeded, + historicIds: new Set(history.messages.map((message) => message.id)), + turn, + ...(continuation ? { continuation } : {}), + }); /* * The Bot, resolved as its owner, and pointed at this thread. @@ -476,44 +716,24 @@ export function createTurnRunner(options: { threadId, runId, })) ?? []), - ...(components - ? await createHeadlessComponentTools({ - store: components.store, - botId: agentId, - ownerUserId, - initiator, - ...(components.auditStore - ? { auditStore: components.auditStore } - : {}), - }) - : []), + ...((await components?.toolsForTurn({ + agentId, + ownerUserId, + initiator, + })) ?? []), ]; if (headlessTools.length > 0) agent.use(new HeadlessToolsMiddleware(headlessTools, initiator)); if (signal?.aborted) throw new Error("The headless turn was cancelled."); - const input: RunAgentInput = { + const input = turnInput({ threadId, runId, messages, state: agent.state, - tools: headlessTools - .filter((tool) => !tool.hidden) - .map((tool) => tool.definition), - context: continuation?.snapshot.context ?? [], - forwardedProps: continuation?.snapshot.forwardedProps, - ...(continuation - ? { - resume: [ - { - interruptId: continuation.snapshot.toolCallId, - status: "resolved" as const, - payload: continuation.result.content, - }, - ], - } - : {}), - }; + tools: headlessTools, + ...(continuation ? { continuation } : {}), + }); /* * The reply is recovered by diffing the agent, because the runner throws away what `runAgent` @@ -544,22 +764,14 @@ export function createTurnRunner(options: { : {}), }); - await intelligence.ɵacquireThreadLock({ - threadId, - runId, - userId: ownerUserId, - agentId, - ttlSeconds: lockTtlSeconds, - ...(learningContainerId ? { learningContainerId } : {}), - }); - - let heartbeat: ReturnType | undefined; let deadline: ReturnType | undefined; let backstop: ReturnType | undefined; let heartbeatError: unknown; let waiting: HeadlessToolSuspension | undefined; - /** Whether the deadline stopped this turn. See the throw below the `finally`. */ + /** Whether the deadline stopped this turn. See `settle`. */ let stopped = false; + /** Set when the run's promise rejected, so it can be reported after the lock is released. */ + let failed: { error: unknown } | undefined; /** * `stopCanonicalRun`'s shape (`channel-manager.mjs:222-229`): one promise for the whole turn, * not one per caller. Both the heartbeat-reject path and the deadline path call `stopTurn`, and @@ -571,12 +783,6 @@ export function createTurnRunner(options: { let stopPromise: Promise | undefined; let cancelTurn: (() => void) | undefined; - const clearHeartbeat = () => { - if (heartbeat === undefined) return; - clearInterval(heartbeat); - heartbeat = undefined; - }; - /** Stop this exact run, both ends: the agent's own abort and the runner's stop flag. */ const stopTurn = () => { try { @@ -590,23 +796,25 @@ export function createTurnRunner(options: { stopPromise ??= runner.stop({ threadId, runId }).catch(() => undefined); }; - heartbeat = setInterval(() => { - void intelligence - .ɵrenewThreadLock({ threadId, runId, ttlSeconds: lockTtlSeconds }) - .catch((error: unknown) => { - if (heartbeat === undefined) return; - /* - * A lock we no longer hold means somebody else is in this thread — the person, most - * likely, having just typed something. Continuing would write this turn's events into - * their run, so the turn is stopped and the failure is raised rather than recovered. - */ - clearHeartbeat(); - heartbeatError = error; - stopTurn(); - }); - }, heartbeatMs); - // So a heartbeat that is still pending cannot hold a one-shot process open. - heartbeat.unref?.(); + const lease = await takeLease({ + intelligence, + threadId, + runId, + userId: ownerUserId, + agentId, + ttlSeconds: lockTtlSeconds, + ...(learningContainerId ? { learningContainerId } : {}), + heartbeatMs, + /* + * A lock we no longer hold means somebody else is in this thread — the person, most likely, + * having just typed something. Continuing would write this turn's events into their run, so + * the turn is stopped and the failure is raised rather than recovered. + */ + onLost: (error) => { + heartbeatError = error; + stopTurn(); + }, + }); try { const cancelled = new Promise((_resolve, reject) => { @@ -675,6 +883,8 @@ export function createTurnRunner(options: { }); await Promise.race([completed, timeout, cancelled]); + } catch (error) { + failed = { error }; } finally { /* * THE SINGLE MOST IMPORTANT LINES IN THIS FILE, on every exit path — success, a thrown run, the @@ -683,84 +893,30 @@ export function createTurnRunner(options: { * While this lock is held, the person's next browser message is refused with 409 "Thread lock * denied" (`run.mjs:85-92`) for the whole TTL. A routine that fails quietly and leaks its lock * does not just fail: it locks somebody out of their own conversation, at three in the morning, - * for a reason no screen explains. `.catch` because a cleanup that cannot be reached must not - * replace the real failure with a second one — the TTL is the backstop for that case. + * for a reason no screen explains. `release` never rejects, because a cleanup that cannot be + * reached must not replace the real failure with a second one — the TTL is the backstop for + * that case. */ - clearHeartbeat(); + lease.stopHeartbeat(); if (cancelTurn) signal?.removeEventListener("abort", cancelTurn); if (signal?.aborted) await stopPromise; if (deadline !== undefined) clearTimeout(deadline); if (backstop !== undefined) clearTimeout(backstop); spoken.unsubscribe(); - await intelligence - .ɵcleanupThreadLock({ threadId, runId }) - .catch(() => undefined); + await lease.release(); } - // Raised after the lock is released, and ahead of any reply: a turn that lost its lock partway - // through is not a turn that answered, however much text it produced first. `stopPromise` is - // awaited first — the reference's own order (`channel-manager.mjs:311-313`) — so a stop this - // path itself requested has actually settled before we report on it, not just been requested. - if (heartbeatError !== undefined) { - await stopPromise; - throw heartbeatError; - } - - /* - * And the same for a turn the deadline stopped, even when the abort worked and the run then - * completed inside the grace window. A stopped run is a truncated one: whatever text it had - * reached is half a sentence, and returning it here would post it into the channel as the answer - * and close the firing as a success. - */ - if (stopped) { - await stopPromise; - throw new Error( - `The routine's turn was stopped after ${Math.round(turnTimeoutMs / 1000)}s.`, - ); - } - if (waiting) throw waiting; - - const added = agent.messages.filter((message) => !before.has(message.id)); - const said = added - .map(assistantText) - .filter((text): text is string => text !== undefined); - // The diff first, the streamed chunks as the fallback: the diff is what was persisted, which is - // what the person will read in the channel, and the chunks are only what went past. - const replyText = (said.length > 0 ? said : chunks).join("\n\n"); - - /* - * An interrupt is an unfinished turn with nobody to ask, and it is checked BEFORE the empty-reply - * case below. A turn that interrupted before saying anything has both conditions true at once, - * and only one sentence can go on the run row and into the channel: "finished without saying - * anything" would be a lie about a turn that in fact stopped to ask a question. The Bot stopped - * to put a question to a person who is not there, so whatever it said first is half of an - * exchange. Posting it as the answer would be the worst of the options: the routine would read as - * successful and the channel would carry a reply that is waiting on something. - */ - // The waiting details, when the CUSTOM event did not survive the trip, ride on the interrupt. - const carried = agent.pendingInterrupts - .map((interrupt) => interrupt.metadata?.[OPENBOT_WAITING_METADATA]) - .find( - (value): value is HeadlessWaiting & { message?: string } => - !!value && - typeof value === "object" && - typeof value.kind === "string", - ); - if (carried) - throw new HeadlessToolSuspension( - carried.message ?? "The turn is waiting for human input.", - carried, - ); - if (agent.pendingInterrupts.length > 0) { - throw new Error( - "The turn stopped to ask a question, and a routine has nobody to ask.", - ); - } - if (replyText.length === 0) { - throw new Error("The turn finished without saying anything."); - } - - const drawn = drawnComponents(added); - return drawn.length > 0 ? { replyText, components: drawn } : { replyText }; + // Decided and reported only now, after the lock is released, on every path. + const outcome = settle({ + ...(failed ? { failed } : {}), + heartbeatError, + stopped, + ...(waiting ? { waiting } : {}), + added: agent.messages.filter((message) => !before.has(message.id)), + chunks, + pendingInterrupts: agent.pendingInterrupts, + componentNames: components?.names ?? new Set(), + }); + return conclude(outcome, stopPromise, turnTimeoutMs); }; } diff --git a/toolkit/src/thread-history.ts b/toolkit/src/thread-history.ts new file mode 100644 index 000000000..c9c3fe988 --- /dev/null +++ b/toolkit/src/thread-history.ts @@ -0,0 +1,46 @@ +/** + * Whether this failure means "the platform has never heard of that thread". + * + * A thread id is minted before the thread exists — the platform creates it on the first run — so + * reading history on a brand-new conversation is the normal opening move, and the platform answers + * `THREAD_NOT_FOUND` with a 404. The runtime's own handler catches everything and returns a bare 500, + * so every new chat produced one, with a stack trace behind it. + * + * Matched on the shape rather than with `instanceof`. The class is `PlatformRequestError` and it + * carries `.status` for exactly this — its own documentation gives `error.status === 404` as the + * example — but it is not re-exported from `@copilotkit/runtime/v2`, and the package's `exports` map + * offers no subpath that reaches it, so there is no type to test against. The name is set by the + * constructor and the status is a number on the instance; both are checked, so an unrelated error + * carrying a `status` of 404 does not qualify. + * + * 404 ONLY, and nothing wider. A 500 from the platform means an outage or a bad key, and answering + * that with an empty history would tell the browser the conversation is gone and invite somebody to + * start it over. That is the failure this must not introduce while removing the noisy one. + */ +export function isMissingThread(error: unknown): boolean { + return ( + error instanceof Error && + error.name === "PlatformRequestError" && + (error as { status?: unknown }).status === 404 + ); +} + +/** + * Read a thread's history, treating a thread the platform does not know about as having none. + * + * Takes the read as a function rather than being folded into `IntelligenceKnowingANewThread` in + * copilot.ts, so the decision can be exercised against a function that really throws. The previous + * attempt at this fix (#71) was tested by re-implementing its middleware inside the test file, which + * passes with the real code deleted; this is the actual code path in both places. + */ +export async function historyOrEmpty( + read: () => Promise, + whenMissing: T, +): Promise { + try { + return await read(); + } catch (error) { + if (isMissingThread(error)) return whenMissing; + throw error; + } +} diff --git a/server/tests/history-sanitize.test.ts b/toolkit/tests/history-sanitize.test.ts similarity index 99% rename from server/tests/history-sanitize.test.ts rename to toolkit/tests/history-sanitize.test.ts index 450b84782..fefc81d82 100644 --- a/server/tests/history-sanitize.test.ts +++ b/toolkit/tests/history-sanitize.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { Message } from "@ag-ui/client"; -import { sanitizeSeededHistory } from "../src/agents/history-sanitize"; +import { sanitizeSeededHistory } from "../src/history-sanitize"; /** * The filter itself, asserted as a function rather than through either turn path. diff --git a/toolkit/tests/lease.test.ts b/toolkit/tests/lease.test.ts new file mode 100644 index 000000000..a86172000 --- /dev/null +++ b/toolkit/tests/lease.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; +import { isLockConflict, keepLease, LockLapsedError } from "../src/lease"; + +function platformError(status: number): Error { + const error = new Error(`Intelligence platform error ${status}`); + error.name = "PlatformRequestError"; + (error as Error & { status?: number }).status = status; + return error; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("isLockConflict", () => { + test("only the platform's 409 is a lock that belongs to somebody else", () => { + expect(isLockConflict(platformError(409))).toBe(true); + for (const status of [401, 404, 500, 503]) + expect(isLockConflict(platformError(status))).toBe(false); + expect(isLockConflict(new TypeError("fetch failed"))).toBe(false); + // The status alone is not enough: an unrelated error carrying 409 is not the platform's answer. + expect(isLockConflict(Object.assign(new Error("x"), { status: 409 }))).toBe( + false, + ); + expect(isLockConflict(undefined)).toBe(false); + }); +}); + +describe("keepLease", () => { + test("renews until stopped", async () => { + let renewals = 0; + const lease = keepLease({ + renew: async () => { + renewals += 1; + }, + everyMs: 2, + lifetimeMs: 60_000, + onLost: () => { + throw new Error("not lost"); + }, + }); + await wait(20); + lease.stop(); + const after = renewals; + await wait(20); + expect(after).toBeGreaterThan(1); + expect(renewals).toBe(after); + }); + + test("a conflict stops the heartbeat and is reported once", async () => { + const lost: unknown[] = []; + let renewals = 0; + keepLease({ + renew: async () => { + renewals += 1; + throw platformError(409); + }, + everyMs: 2, + lifetimeMs: 60_000, + onLost: (error) => lost.push(error), + }); + await wait(30); + expect(lost).toHaveLength(1); + expect(renewals).toBe(1); + }); + + test("a renewal that merely failed is skipped, and the next one still runs", async () => { + const lost: unknown[] = []; + let renewals = 0; + const lease = keepLease({ + renew: async () => { + renewals += 1; + throw renewals % 2 ? new TypeError("fetch failed") : platformError(503); + }, + everyMs: 2, + lifetimeMs: 60_000, + onLost: (error) => lost.push(error), + }); + await wait(30); + lease.stop(); + expect(renewals).toBeGreaterThan(2); + expect(lost).toEqual([]); + }); + + /* + * The time limit, on a clock each renewal moves forward by 10ms, so how many renewals fit in the + * lifetime is exact rather than a matter of how fast the machine is. + */ + test("failures that outlast the lifetime lose the lock, once, whatever they were", async () => { + let clock = 0; + let renewals = 0; + const lost: unknown[] = []; + keepLease({ + renew: async () => { + renewals += 1; + clock += 10; + throw platformError(503); + }, + everyMs: 1, + lifetimeMs: 35, + now: () => clock, + onLost: (error) => lost.push(error), + }); + await wait(40); + expect(lost).toHaveLength(1); + expect(lost[0]).toBeInstanceOf(LockLapsedError); + // Asked at 0, 10, 20 and 30; at 40 the lock has gone 40ms unrenewed, past 35 - 1. + expect(renewals).toBe(4); + }); + + test("it gives up one interval before the lifetime, while the lock is still held", async () => { + // The clock moves one interval per tick here, as it does for real. + let clock = 0; + let renewals = 0; + const lostAt: number[] = []; + keepLease({ + renew: async () => { + renewals += 1; + clock += 10; + throw platformError(503); + }, + everyMs: 10, + lifetimeMs: 35, + now: () => clock, + onLost: () => lostAt.push(clock), + }); + await wait(80); + // Asked at 0, 10 and 20. At 30 the next tick would land at 40, past the 35ms lifetime, so it + // stops now, at 30, with the lock still its own. + expect(renewals).toBe(3); + expect(lostAt).toEqual([30]); + }); + + test("a renewal that never settles still loses the lock when the lifetime passes", async () => { + let clock = 0; + const lost: unknown[] = []; + keepLease({ + renew: () => { + clock += 10; + return new Promise(() => {}); + }, + everyMs: 1, + lifetimeMs: 35, + now: () => clock, + onLost: (error) => lost.push(error), + }); + await wait(40); + expect(lost).toHaveLength(1); + expect(lost[0]).toBeInstanceOf(LockLapsedError); + }); + + test("a success inside the lifetime starts it again, so occasional blips never lose the lock", async () => { + let clock = 0; + let renewals = 0; + const lost: unknown[] = []; + const lease = keepLease({ + renew: async () => { + renewals += 1; + clock += 10; + // Two failures, then a success, over and over: never 35ms without a renewal landing. + if (renewals % 3) throw new TypeError("fetch failed"); + }, + everyMs: 1, + lifetimeMs: 35, + now: () => clock, + onLost: (error) => lost.push(error), + }); + await wait(40); + lease.stop(); + expect(renewals).toBeGreaterThan(6); + expect(lost).toEqual([]); + }); +}); diff --git a/server/tests/thread-history.test.ts b/toolkit/tests/thread-history.test.ts similarity index 98% rename from server/tests/thread-history.test.ts rename to toolkit/tests/thread-history.test.ts index eb7e70f72..115343df8 100644 --- a/server/tests/thread-history.test.ts +++ b/toolkit/tests/thread-history.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { historyOrEmpty, isMissingThread } from "../src/copilot"; +import { historyOrEmpty, isMissingThread } from "../src/thread-history"; /** * Reading history on a thread the platform has never seen. diff --git a/toolkit/tests/thread-lock.test.ts b/toolkit/tests/thread-lock.test.ts new file mode 100644 index 000000000..75f82c5ce --- /dev/null +++ b/toolkit/tests/thread-lock.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { + createIntelligenceThreadLock, + type ThreadLockClient, +} from "../src/copilotkit/thread-lock"; + +/** + * The lock a hop takes on a conversation, as the platform issues it. + * + * The property that matters is the one line between "busy" and "broken": a 409 is somebody else's + * run and the hop waits, and every other failure is raised so the person is eventually told the real + * reason rather than that their conversation was busy. + */ + +const INPUT = { + threadId: "thread-1", + runId: "run-asked", + userId: "user-1", + agentId: "researcher", +}; + +function platformError(status: number): Error { + const error = new Error(`HTTP ${status}`); + (error as Error & { status?: number }).status = status; + return error; +} + +function harness( + options: { + acquire?: () => unknown; + container?: string; + onRunBusy?: (input: { threadId: string; busy: boolean }) => void; + } = {}, +) { + const calls: { method: string; input: Record }[] = []; + const busy: { threadId: string; busy: boolean }[] = []; + const client = { + getOrCreateThread: async (input: Record) => { + calls.push({ method: "getOrCreateThread", input }); + return {}; + }, + ɵacquireThreadLock: async (input: Record) => { + calls.push({ method: "acquire", input }); + if (options.acquire) return options.acquire(); + return { runId: "platform-run", joinToken: "browser-only" }; + }, + ɵrenewThreadLock: async (input: Record) => { + calls.push({ method: "renew", input }); + }, + ɵcleanupThreadLock: async (input: Record) => { + calls.push({ method: "cleanup", input }); + }, + } as unknown as ThreadLockClient; + const lock = createIntelligenceThreadLock({ + client, + ...(options.container + ? { + learning: { + containerForThread: async () => options.container, + }, + } + : {}), + onRunBusy: + options.onRunBusy ?? + ((input) => { + busy.push(input); + }), + }); + return { lock, calls, busy }; +} + +describe("taking a conversation's lock", () => { + test("creates the thread, then locks it, and hands back the platform's run id only", async () => { + const { lock, calls } = harness(); + + expect(await lock.acquire(INPUT)).toEqual({ runId: "platform-run" }); + expect(calls.map((call) => call.method)).toEqual([ + "getOrCreateThread", + "acquire", + ]); + }); + + test("a conversation somebody else is running in is busy, not broken", async () => { + const { lock, busy } = harness({ + acquire: () => { + throw platformError(409); + }, + }); + + expect(await lock.acquire(INPUT)).toBeNull(); + expect(busy).toEqual([]); + }); + + test("any other failure is raised, so it is not mistaken for contention", async () => { + for (const status of [401, 404, 500, 503]) { + const { lock } = harness({ + acquire: () => { + throw platformError(status); + }, + }); + await expect(lock.acquire(INPUT)).rejects.toThrow(`HTTP ${status}`); + } + }); + + test("a failure with no status at all is raised too", async () => { + const { lock } = harness({ + acquire: () => { + throw new Error("socket hang up"); + }, + }); + + await expect(lock.acquire(INPUT)).rejects.toThrow("socket hang up"); + }); + + test("the thread and the lock both carry the Learning container when there is one", async () => { + const { lock, calls } = harness({ container: "container-1" }); + + await lock.acquire(INPUT); + + for (const call of calls) { + expect(call.input.learningContainerId).toBe("container-1"); + } + }); + + test("lights the conversation as busy once the lock is held", async () => { + const { lock, busy } = harness(); + + await lock.acquire(INPUT); + + expect(busy).toEqual([{ threadId: "thread-1", busy: true }]); + }); + + test("a busy signal that throws does not cost the run its lock", async () => { + const { lock } = harness({ + onRunBusy: () => { + throw new Error("channel gone"); + }, + }); + + expect(await lock.acquire(INPUT)).toEqual({ runId: "platform-run" }); + }); +}); + +describe("keeping and giving back the lock", () => { + test("renews for the platform's own lifetime", async () => { + const { lock, calls } = harness(); + + await lock.renew({ threadId: "thread-1", runId: "platform-run" }); + + expect(calls).toEqual([ + { + method: "renew", + input: { + threadId: "thread-1", + runId: "platform-run", + // The literal, not the constant, so moving the lifetime off the platform's 120 fails here. + ttlSeconds: 120, + }, + }, + ]); + }); + + test("release clears the busy signal and cleans up the exact run", async () => { + const { lock, calls, busy } = harness(); + + await lock.release({ threadId: "thread-1", runId: "platform-run" }); + + expect(busy).toEqual([{ threadId: "thread-1", busy: false }]); + expect(calls).toEqual([ + { + method: "cleanup", + input: { threadId: "thread-1", runId: "platform-run" }, + }, + ]); + }); + + test("a busy signal that throws does not stop the lock being given back", async () => { + const { lock, calls } = harness({ + onRunBusy: () => { + throw new Error("channel gone"); + }, + }); + + await lock.release({ threadId: "thread-1", runId: "platform-run" }); + + expect(calls.map((call) => call.method)).toEqual(["cleanup"]); + }); +}); diff --git a/toolkit/tests/turn-settle.test.ts b/toolkit/tests/turn-settle.test.ts new file mode 100644 index 000000000..262fed288 --- /dev/null +++ b/toolkit/tests/turn-settle.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from "bun:test"; +import type { Message } from "@ag-ui/client"; +import { + HeadlessToolSuspension, + OPENBOT_WAITING_METADATA, +} from "../src/headless-tools"; +import { + settle, + type TurnEvidence, + turnInput, + turnMessages, +} from "../src/run-turn"; + +/** + * How a headless turn ends, decided without running one. + * + * `routine-run-turn.test.ts` drives every exit path through a real runner; this file pins the one + * decision those paths feed, so a change to the order is a change to a table rather than a reading + * of the runner's control flow. + */ + +const REPLY: Message = { + id: "assistant_1", + role: "assistant", + content: "Three things happened.", +}; + +function evidence(overrides: Partial = {}): TurnEvidence { + return { + stopped: false, + added: [], + chunks: [], + pendingInterrupts: [], + componentNames: new Set(), + ...overrides, + }; +} + +const waiting = new HeadlessToolSuspension("Waiting for Ada.", { + kind: "approval", + requestId: "request_1", +}); + +const interrupt = (metadata?: Record) => + ({ id: "interrupt_1", ...(metadata ? { metadata } : {}) }) as never; + +describe("settle", () => { + test("a run that answered completes with what it said", () => { + expect(settle(evidence({ added: [REPLY] }))).toEqual({ + kind: "completed", + replyText: "Three things happened.", + components: [], + }); + }); + + test("falls back to the streamed chunks when nothing was added", () => { + expect(settle(evidence({ chunks: ["One.", "Two."] }))).toEqual({ + kind: "completed", + replyText: "One.\n\nTwo.", + components: [], + }); + }); + + test("nothing said and nothing pending is empty", () => { + expect(settle(evidence())).toEqual({ kind: "empty" }); + }); + + /* + * Each row adds one condition that outranks everything below it, on top of evidence that would + * otherwise settle as the row below. The order is the contract. + */ + const failure = new Error("the model refused"); + const lost = new Error("lock lost"); + const ladder: [string, TurnEvidence, string][] = [ + [ + "a failure that escaped the run outranks everything", + evidence({ + failed: { error: failure }, + heartbeatError: lost, + stopped: true, + waiting, + added: [REPLY], + }), + "failed", + ], + [ + "a lost lock outranks the deadline", + evidence({ + heartbeatError: lost, + stopped: true, + waiting, + added: [REPLY], + }), + "lockLost", + ], + [ + "the deadline outranks a waiting", + evidence({ stopped: true, waiting, added: [REPLY] }), + "timedOut", + ], + [ + "an announced waiting outranks a reply", + evidence({ waiting, added: [REPLY] }), + "suspended", + ], + [ + "a waiting carried on the interrupt outranks a reply", + evidence({ + added: [REPLY], + pendingInterrupts: [ + interrupt({ [OPENBOT_WAITING_METADATA]: { kind: "question" } }), + ], + }), + "suspended", + ], + [ + "a bare interrupt outranks a reply", + evidence({ added: [REPLY], pendingInterrupts: [interrupt()] }), + "interrupted", + ], + [ + "a bare interrupt outranks saying nothing", + evidence({ pendingInterrupts: [interrupt()] }), + "interrupted", + ], + ]; + for (const [name, input, kind] of ladder) { + test(name, () => { + expect(settle(input).kind).toBe(kind); + }); + } + + test("a failure keeps the exact value the run rejected with, even undefined", () => { + expect(settle(evidence({ failed: { error: undefined } }))).toEqual({ + kind: "failed", + error: undefined, + }); + }); + + test("the announced waiting wins over the copy carried on the interrupt", () => { + const outcome = settle( + evidence({ + waiting, + pendingInterrupts: [ + interrupt({ + [OPENBOT_WAITING_METADATA]: { + kind: "question", + requestId: "other", + }, + }), + ], + }), + ); + expect(outcome).toEqual({ kind: "suspended", suspension: waiting }); + }); + + test("a carried waiting without a kind is not a waiting", () => { + expect( + settle( + evidence({ + pendingInterrupts: [ + interrupt({ [OPENBOT_WAITING_METADATA]: { requestId: "r" } }), + ], + }), + ).kind, + ).toBe("interrupted"); + }); + + test("components are reported only by the names it was given", () => { + const drew: Message[] = [ + { + id: "a", + role: "assistant", + content: "Here.", + toolCalls: [ + { + id: "c1", + type: "function", + function: { name: "showBarChart", arguments: '{"title":"Top"}' }, + }, + ], + }, + { id: "t", role: "tool", toolCallId: "c1", content: "saved" }, + ]; + expect( + settle( + evidence({ added: drew, componentNames: new Set(["showBarChart"]) }), + ), + ).toEqual({ + kind: "completed", + replyText: "Here.", + components: [{ name: "showBarChart", args: { title: "Top" } }], + }); + expect(settle(evidence({ added: drew }))).toEqual({ + kind: "completed", + replyText: "Here.", + components: [], + }); + }); +}); + +describe("turnMessages", () => { + const seeded: Message[] = [ + { id: "m1", role: "user", content: "Hello." }, + { id: "m2", role: "assistant", content: "Hello back." }, + ]; + const turn: Message = { id: "now", role: "user", content: "Again." }; + + test("a fresh turn is the history plus its own message, and persists only that message", () => { + const { messages, persistedInputMessages } = turnMessages({ + seeded, + historicIds: new Set(["m1", "m2"]), + turn, + }); + expect(messages.map((message) => message.id)).toEqual(["m1", "m2", "now"]); + expect(persistedInputMessages.map((message) => message.id)).toEqual([ + "now", + ]); + }); + + test("a continuation is its snapshot plus the answer, and never the turn message", () => { + const { messages, persistedInputMessages } = turnMessages({ + seeded, + historicIds: new Set(["m1"]), + turn, + continuation: { + snapshot: { + runId: "run", + threadId: "thread", + toolCallId: "call_1", + toolName: "send", + args: {}, + messages: [ + { id: "m1", role: "user", content: "Send it." }, + { + id: "a1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_1", + type: "function", + function: { name: "send", arguments: "{}" }, + }, + ], + }, + ], + state: {}, + context: [], + forwardedProps: undefined, + }, + result: { content: "approved" }, + messageId: "answer_1", + }, + }); + expect(messages.map((message) => message.id)).toEqual([ + "m1", + "a1", + "answer_1", + ]); + expect(persistedInputMessages.map((message) => message.id)).toEqual([ + "a1", + "answer_1", + ]); + }); +}); + +describe("turnInput", () => { + const tools = [ + { + definition: { name: "offered", description: "", parameters: {} }, + execute: async () => "", + }, + { + definition: { name: "refusal_only", description: "", parameters: {} }, + hidden: true, + execute: async () => "", + }, + ]; + + test("offers only the tools that are not hidden, and resumes nothing on a fresh turn", () => { + const input = turnInput({ + threadId: "thread", + runId: "run", + messages: [], + state: {}, + tools, + }); + expect(input.tools.map((tool) => tool.name)).toEqual(["offered"]); + expect(input.context).toEqual([]); + expect("resume" in input).toBe(false); + }); +}); diff --git a/toolkit/tsconfig.json b/toolkit/tsconfig.json new file mode 100644 index 000000000..4bd6962d4 --- /dev/null +++ b/toolkit/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/worker/package.json b/worker/package.json index 8a136363d..90b403a7f 100644 --- a/worker/package.json +++ b/worker/package.json @@ -8,5 +8,8 @@ "build": "tsc --noEmit", "dev": "bun --watch src/index.ts", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openbot/shared": "workspace:*" } } diff --git a/worker/src/env.ts b/worker/src/env.ts index 1aad6e143..c2beaaf07 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -1,4 +1,4 @@ -import { workOwner } from "../../shared/work-owner"; +import { workOwner } from "@openbot/shared/work-owner"; /** * What the worker needs from its environment, parsed and ready to use.