diff --git a/.env.example b/.env.example index a93ba82..605e19d 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,34 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Notion (Optional) -- # export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token + +# -- Composio (Optional) -- +# Connect any Composio toolkit without writing an MCP block. Two steps per app: +# add the toolkit at https://app.composio.dev (that creates its auth config), +# then list its slug below and restart the runtime. Read by the Node runtime, +# not the Python agent. Requires Node 22+ (package.json `engines`). +# Slugs are Composio's own, lowercase and unspaced — Google Calendar is +# `googlecalendar`. Take it from the toolkit's page URL at app.composio.dev. A +# typo is silent: the toolkit is configured and simply never appears. +# export COMPOSIO_API_KEY=ak_... +# Shared team accounts — every Slack user acts through the SAME connection. +# After setting this, connect each one once: `pnpm composio:connect linear`, +# then open the link it prints. Nobody can do it from Slack, and the dashboard +# binds the wrong identity (see COMPOSIO_WORKSPACE_USER_ID below). +# export COMPOSIO_TOOLKITS=linear,jira +# Personal accounts — each person connects their own from inside a thread. +# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar +# off | destructive (default) | writes +# export COMPOSIO_APPROVALS=destructive +# The Composio user_id shared toolkits run as. Defaults to +# INTELLIGENCE_CHANNEL_NAME; a shared toolkit is only connected when a connected +# account exists under this exact value. The dashboard's "connect my account" +# button binds to the dashboard's own user id and the bot never sees it — +# `pnpm composio:connect` binds to this value instead, which is why it exists. +# export COMPOSIO_WORKSPACE_USER_ID= +# Rarely needed, and case-sensitive. Read ONLY by `pnpm composio:connect`, not +# by the runtime: it pins which auth config a SHARED toolkit connects against +# when it has more than one (the script prints the ids to choose from and will +# not guess). Personal toolkits cannot be pinned — session.authorize() takes no +# auth config id and resolves one itself. +# export COMPOSIO_AUTH_CONFIGS=gmail:ac_ExAmPle2Cd,linear:ac_ExAmPle1-aB diff --git a/.gitignore b/.gitignore index 2a1463b..f2ff65a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,11 @@ state.db-wal # AWS CDK synthesis artifacts deployment/aws/cdk.out deployment/aws/cdk.context.json + +# Subagent-driven-development scratch (ledger, briefs, review packages) +.superpowers + +# Composio design and implementation plan — working documents, kept locally. +# The repo removed docs/superpowers specs and plans in 0577c63; same call here. +docs/composio-tools-design.md +docs/composio-tools-plan.md diff --git a/README.md b/README.md index b9485b4..8afe1cb 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ AGENT_DISPLAY_NAME=OpenTag Both the Node runtime and the Python agent load this one root `.env`; Railway supplies the same values as service variables. Tavily, GitHub, PostHog, Linear, -and Notion are optional — see [Optional research +Notion, and Composio are optional — see [Optional research sources](#optional-research-sources). `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` already default to the @@ -279,7 +279,8 @@ or one directory, and none of them require touching the Channel lifecycle. | Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their credentials are present | | What gets rendered in chat | [`app/components/`](./app/components), [`app/tools/`](./app/tools) | Issue cards, tables, charts, diagrams | | Mentions, commands, triggers | [`app/channel.tsx`](./app/channel.tsx) | The whole Channel surface in one file | -| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write` | +| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write`; `COMPOSIO_APPROVALS` is the equivalent dial for Composio | +| Which connected apps people can use | `COMPOSIO_TOOLKITS`, `COMPOSIO_USER_TOOLKITS` | One slug per app. Adding one later needs no code change — but it does need a dashboard step and a restart | | The deployment topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services, declared as code | If you are customizing with a coding agent, read [`AGENTS.md`](./AGENTS.md) @@ -295,6 +296,8 @@ workflow instead of letting an agent improvise one. - File-aware prompts. - A LangGraph interrupt and resumable confirmation card before Linear or Notion writes. +- Optional Composio toolkits resolved per turn — a shared team account, each + person's own, or both at once — behind their own approval gate. - Graceful, idempotent shutdown for Channels, HTTP, and the rendering browser. - Nullable parent-message ID normalization through `SanitizingHttpAgent`. @@ -312,6 +315,8 @@ CopilotKit Intelligence │ outbound websocket from your runtime ▼ runtime (Node + CopilotRuntime with embedded Channels) + ├── Composio toolkits (optional; shared or per-Slack-user) + │ │ AG-UI ▼ agent (Python + LangGraph deepagents) @@ -363,6 +368,16 @@ knowledge work, and renders UI from model knowledge. | `LINEAR_API_KEY` | Hosted Linear MCP | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | +| `COMPOSIO_API_KEY` + `COMPOSIO_TOOLKITS` and/or `COMPOSIO_USER_TOOLKITS` | Any Composio toolkit — shared team accounts, or each person's own connected from inside a thread | + +Composio is the one that grows without code. Adding Salesforce six months from +now is two steps: add the toolkit at [app.composio.dev](https://app.composio.dev), +then add its slug to a list and restart the runtime. No new MCP block, no +TypeScript, no test change. Two steps, though — neither of them automatic, and a +shared team account takes a third: `pnpm composio:connect ` once, to bind +the connection to the identity shared calls actually run as. See +[`setup.md`](./setup.md#composio) for approval modes, shared versus personal +accounts, and what Google's consent screen will tell your users. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; diff --git a/app/channel-components.test.ts b/app/channel-components.test.ts new file mode 100644 index 0000000..8e9dda9 --- /dev/null +++ b/app/channel-components.test.ts @@ -0,0 +1,62 @@ +/** + * What the Channel is actually told to register. + * + * A click that arrives after the in-process cache is gone is served by + * re-rendering the named component from `createChannel({ components })`. A card + * missing from that list resolves to nothing, `dispatch` raises + * `ActionExpiredError`, and the Channel swallows it (`create-channel.js`) — so + * the person clicks and nothing happens, with no error anywhere to explain it. + * + * The assertion is on the argument `createChannel` receives, not on any + * constant this file could read: the wiring is the thing that has to be right, + * and a future edit that passes a different array must fail here. + */ +import { describe, expect, it, vi } from "vitest"; + +type ChannelsModule = typeof import("@copilotkit/channels"); +type CreateChannelOptions = Parameters[0]; + +const optionsSeen: CreateChannelOptions[] = []; + +vi.mock("@copilotkit/channels", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createChannel: (options: CreateChannelOptions) => { + optionsSeen.push(options); + return actual.createChannel(options); + }, + }; +}); + +const { FakeAgent } = await import("@copilotkit/channels"); +const { createOpenTagChannel } = await import("./channel.js"); + +/** The `components` array of the most recent `createChannel` call, by name. */ +function registeredComponentNames(): string[] { + const components = optionsSeen.at(-1)?.components; + if (!Array.isArray(components)) { + throw new Error("createChannel was not given a components array"); + } + return components.map((component) => + typeof component === "function" ? component.name : String(component), + ); +} + +describe("createOpenTagChannel component registration", () => { + it("registers every card whose buttons outlive the turn that posted them", () => { + createOpenTagChannel("opentag", new FakeAgent()); + + // `ConfirmToolRun`'s entire premise is that the agent's turn already ended, + // and a `ConnectAccount` click works perfectly cold — it mints a fresh link + // and needs nothing from the process that posted the card. Dropping either + // is a restart-only breakage that no other test would notice. + expect(registeredComponentNames()).toEqual( + expect.arrayContaining([ + "ConfirmWrite", + "ConfirmToolRun", + "ConnectAccount", + ]), + ); + }); +}); diff --git a/app/channel.tsx b/app/channel.tsx index 311a2c3..02eab2c 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -13,11 +13,16 @@ import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { createAppContext } from "./context/app-context.js"; import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js"; -import { ConfirmWrite } from "./human-in-the-loop/index.js"; +import { + ConfirmToolRun, + ConfirmWrite, + ConnectAccount, +} from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; import { RenderChart } from "./tools/render-chart.js"; +import { composioTools } from "./tools/composio/index.js"; import { createAppTools } from "./tools/index.js"; import { subscribeThreadTool, @@ -39,16 +44,29 @@ export function createOpenTagChannel( tools: createAppTools(agentDisplayName), context: [...createAppContext(agentDisplayName)], commands: appCommands, + // Not bookkeeping: once the in-process cache is gone, a click is served by + // re-rendering the named component from here. An unregistered card's + // buttons raise `ActionExpiredError`, which the Channel swallows — so the + // person clicks and nothing happens at all. `ConfirmToolRun` and + // `ConnectAccount` are the ones that make this load-bearing; both exist to + // be clicked minutes later, long after their turn ended. components: [ IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite, + ConfirmToolRun, + ConnectAccount, RenderChart, ], }); + // Built once, here, rather than per turn: an unconfigured deployment gets an + // empty array and never constructs the SDK, and a misconfigured one prints + // its warnings at boot instead of once per message. + const composio = composioTools(process.env, name); + type MessageHandlerInput = Parameters< Parameters[0] >[0]; @@ -84,7 +102,10 @@ export function createOpenTagChannel( if (message.actor.kind === "bot" || message.actor.kind === "app") return; if (await thread.isSubscribed()) { - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); return; } @@ -101,18 +122,27 @@ export function createOpenTagChannel( if (isNewConversation) { await thread.subscribe(); - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); return; } - await runAgentSafely({ thread, message }, [subscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + subscribeThreadTool, + ...composio, + ]); }); channel.onMessage(async ({ thread, message }) => { if (message.actor.kind === "bot" || message.actor.kind === "app") return; if (await thread.isSubscribed()) { - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); } }); diff --git a/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx b/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx new file mode 100644 index 0000000..159607b --- /dev/null +++ b/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from "vitest"; +import { + renderToIR, + type ChannelNode, + type InteractionContext, + type ClickHandler, +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, + type ConfirmDecision, +} from "../confirm-tool-run.js"; + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if ( + children && + typeof children === "object" && + "type" in (children as object) + ) { + return [children as ChannelNode]; + } + return []; +} + +/** Concatenate the text of all descendant `text` nodes (depth-first). */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** All button nodes in the tree. */ +function findButtons(nodes: ChannelNode[]): ChannelNode[] { + const out: ChannelNode[] = []; + for (const n of nodes) { + if (n.type === "button") out.push(n); + out.push(...findButtons(childNodes(n))); + } + return out; +} + +function buttonByText(ir: ChannelNode[], text: string): ChannelNode { + const btn = findButtons(ir).find((b) => collectText(b) === text); + if (!btn) throw new Error(`button "${text}" not found`); + return btn; +} + +/** A click on the posted card, recording whatever the handler writes back. */ +function clickOn( + update: ReturnType, + value: ConfirmDecision, +): InteractionContext { + return { + thread: { update, resume: vi.fn(), post: vi.fn() }, + message: { ref: { id: "m1" } }, + action: { id: "b1", value }, + actor: { id: "U1" }, + } as unknown as InteractionContext; +} + +describe("toolRunFields", () => { + it("humanizes keys and stringifies values", () => { + expect(toolRunFields({ recipient_email: "a@b.c" })).toEqual([ + { label: "Recipient email", value: "a@b.c" }, + ]); + }); + + it("drops empty values but keeps 0 and false", () => { + expect( + toolRunFields({ a: "", b: null, c: [], priority: 0, draft: false }), + ).toEqual([ + { label: "Priority", value: "0" }, + { label: "Draft", value: "No" }, + ]); + }); + + it("joins arrays and truncates long values", () => { + expect(toolRunFields({ cc: ["a@b.c", "d@e.f"] })[0]?.value).toBe( + "a@b.c, d@e.f", + ); + const long = toolRunFields({ body: "x".repeat(400) })[0]?.value ?? ""; + expect(long.startsWith("x".repeat(300))).toBe(true); + expect(long).not.toContain("x".repeat(301)); + }); + + it("shows array members as JSON rather than [object Object]", () => { + // A row reading "[object Object]" looks populated while withholding + // everything, and unlike an elision it never admits anything was withheld. + const value = + toolRunFields({ attachments: ["notes.txt", { name: "q3.pdf" }] })[0] + ?.value ?? ""; + expect(value).toBe('notes.txt, {"name":"q3.pdf"}'); + expect(value).not.toContain("[object Object]"); + }); + + it("says how much a truncated value withheld", () => { + // A bare "…" reads identically on a 305-char body and a 4000-char one. + expect(toolRunFields({ body: "x".repeat(400) })[0]?.value).toBe( + `${"x".repeat(300)}… (100 more characters)`, + ); + expect(toolRunFields({ body: "x".repeat(4000) })[0]?.value).toContain( + "(3700 more characters)", + ); + // Same wording as the row cap, down to the singular. + expect(toolRunFields({ body: "x".repeat(301) })[0]?.value).toContain( + "(1 more character)", + ); + // A value at the limit is not elided at all. + expect(toolRunFields({ body: "x".repeat(300) })[0]?.value).toBe( + "x".repeat(300), + ); + }); + + it("caps the row count and says how many were hidden", () => { + const many = Object.fromEntries( + Array.from({ length: 20 }, (_, i) => [`field_${i}`, `v${i}`]), + ); + const rows = toolRunFields(many); + expect(rows).toHaveLength(13); + expect(rows.at(-1)).toEqual({ label: "…", value: "8 more fields" }); + }); +}); + +describe("ConfirmToolRun", () => { + it("renders the action, the fields table and Approve/Cancel", () => { + const ir = renderToIR( + , + ); + const { blocks, accent } = renderSlackMessage(ir); + + expect(accent).toBe("#010507"); + + const header = blocks.find((b) => b.type === "header") as + | { text: { text: string } } + | undefined; + expect(header?.text.text).toContain("Send Gmail message"); + + // No `columns` prop, so the first row is data rather than a header row. + const table = blocks.find((b) => b.type === "table") as + | { rows: { text: string }[][] } + | undefined; + expect(table?.rows.map((row) => row.map((cell) => cell.text))).toEqual([ + ["Recipient email", "a@b.c"], + ]); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + expect(actions?.elements.map((e) => e.text.text)).toEqual([ + "Approve", + "Cancel", + ]); + expect(actions?.elements[0]?.style).toBe("primary"); + }); + + it("omits the table when there is nothing worth showing", () => { + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + expect(blocks.some((b) => b.type === "table")).toBe(false); + }); + + it("warns and reddens the confirm button for a destructive run", () => { + const ir = renderToIR( + , + ); + const { blocks, accent } = renderSlackMessage(ir); + + expect(accent).toBe("#EB5757"); + + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("cannot be undone"); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + // The red marks the irreversible choice, never the safe escape hatch. + expect(actions?.elements.map((e) => e.text.text)).toEqual([ + "Delete", + "Cancel", + ]); + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("names a destructive run by its own verb, never by 'Delete'", () => { + /** The two action-button labels for a destructive `action`. */ + const labelsFor = (action: string): string[] => { + const { blocks } = renderSlackMessage( + renderToIR( + , + ), + ); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string } }[] } + | undefined; + return actions?.elements.map((e) => e.text.text) ?? []; + }; + + // A humanised Composio action leads with the app, not with the verb. + expect(labelsFor("Gmail delete thread")).toEqual(["Delete", "Cancel"]); + + // Real destructive slugs whose verb is not "delete". Measured against the + // live Composio API: LINEAR_REMOVE_ISSUE_LABEL, GOOGLECALENDAR_CLEAR_ + // CALENDAR, GOOGLECALENDAR_CHANNELS_STOP. + expect(labelsFor("Linear remove issue label")).toEqual([ + "Remove", + "Cancel", + ]); + expect(labelsFor("Googlecalendar clear calendar")).toEqual([ + "Clear", + "Cancel", + ]); + expect(labelsFor("Googlecalendar channels stop")).toEqual([ + "Stop", + "Cancel", + ]); + for (const action of [ + "Linear remove issue label", + "Googlecalendar clear calendar", + "Googlecalendar channels stop", + ]) { + expect(labelsFor(action)).not.toContain("Delete"); + } + + // Unrecognised verb: a vague button beats a wrong one. + expect(labelsFor("Zzz frobnicate widget")).toEqual(["Confirm", "Cancel"]); + + // Would otherwise render Cancel/Cancel, one cancelling the event and one + // cancelling the request. + const collision = labelsFor("Googlecalendar cancel event"); + expect(collision).toEqual(["Confirm", "Cancel"]); + expect(new Set(collision).size).toBe(2); + }); + + it("carries only the token in the button values, never the arguments", async () => { + const ir = renderToIR( + , + ); + + const approve = buttonByText(ir, "Approve"); + const cancel = buttonByText(ir, "Cancel"); + expect(approve.props.value).toEqual({ token: "tok-1", approved: true }); + expect(cancel.props.value).toEqual({ token: "tok-1", approved: false }); + + // Slack action values have a size limit and must not ferry user data. + expect(JSON.stringify(approve.props.value)).not.toContain( + "secret-body-text", + ); + expect(JSON.stringify(cancel.props.value)).not.toContain( + "secret-body-text", + ); + + // Both buttons run the decision themselves — no handler is passed in, and + // nothing is resumed here. No pending call exists under this token, so the + // real handler rewrites the card as expired: two clicks, two updates. + const update = vi.fn(); + await (approve.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-1", approved: true }), + ); + await (cancel.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-1", approved: false }), + ); + expect(update).toHaveBeenCalledTimes(2); + }); + + /** + * The property the card's shape exists for. A click that lands after a + * restart is served by re-rendering this component from its **stored** props + * — which have been through the state store and carry no functions. A + * handler taken as a prop would be missing here, the re-rendered button would + * have no `onClick`, and the dispatcher's `ActionExpiredError` is swallowed + * by the Channel: the person clicks and sees nothing at all. + */ + it("still carries a working handler when re-rendered from stored props alone", async () => { + const posted = { + action: "Gmail delete thread", + fields: [{ label: "Thread id", value: "t-1" }], + destructive: true, + token: "tok-cold", + }; + // What a durable store hands back: data only. + const stored = JSON.parse(JSON.stringify(posted)) as typeof posted; + + const button = buttonByText(renderToIR(), "Delete"); + expect(typeof button.props.onClick).toBe("function"); + + const update = vi.fn(); + await (button.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-cold", approved: true }), + ); + + // The pending call is gone with the process, so the best available answer + // is the one the user actually gets — rather than silence. + expect(update).toHaveBeenCalledTimes(1); + expect(JSON.stringify(update.mock.calls[0])).toContain("expired"); + }); +}); + +describe("ToolRunOutcome", () => { + it("renders a green success card", () => { + const { blocks, accent } = renderSlackMessage( + renderToIR( + , + ), + ); + + expect(accent).toBe("#2E7D32"); + const header = blocks.find((b) => b.type === "header") as + | { text: { text: string } } + | undefined; + expect(header?.text.text).toContain("Send Gmail message"); + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("Sent."); + }); + + it("renders a red failure card", () => { + const { blocks, accent } = renderSlackMessage( + renderToIR( + , + ), + ); + + expect(accent).toBe("#EB5757"); + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("Declined."); + }); +}); diff --git a/app/human-in-the-loop/confirm-tool-run.tsx b/app/human-in-the-loop/confirm-tool-run.tsx new file mode 100644 index 0000000..bae26a8 --- /dev/null +++ b/app/human-in-the-loop/confirm-tool-run.tsx @@ -0,0 +1,289 @@ +/** + * Approval card for a Composio tool run. + * + * `ConfirmWrite` resumes a paused LangGraph interrupt. Nothing is paused here — + * managed Channels cannot block on a choice (`supportsBlockingChoice: false`), + * so the decision travels in the button `value` and the click handler does the + * work. The card carries only a token; arguments stay in process. + */ +import { + Actions, + Button, + Cell, + Header, + Message, + Row, + Section, + Table, +} from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; + +/** One argument of the pending run, already labelled and stringified. */ +export interface ConfirmToolRunField { + label: string; + value: string; +} + +/** Rows past this many are collapsed into a single "n more fields" row. */ +const MAX_FIELDS = 12; + +/** Longest value a single row shows before it is elided. */ +const MAX_VALUE = 300; + +/** `recipient_email` / `addTeams` -> `Recipient email` / `Add teams`. */ +function humanize(key: string): string { + const spaced = key + .replace(/(?<=[a-z0-9])(?=[A-Z])/g, " ") + .replace(/[_-]+/g, " ") + .trim(); + const [first, ...rest] = spaced.split(/\s+/); + if (!first) return key; + return [ + first[0]!.toUpperCase() + first.slice(1), + ...rest.map((w) => w.toLowerCase()), + ].join(" "); +} + +/** + * Carries no information for an approver. Deliberately not JS falsiness: `0` is + * a real priority and `false` a real flag, so both must survive — dropping them + * would show an approver an incomplete picture of what they are approving. + */ +function isEmpty(value: unknown): boolean { + if (value === null || value === undefined || value === "") return true; + if (Array.isArray(value)) return value.length === 0; + return typeof value === "object" && Object.keys(value as object).length === 0; +} + +/** + * One member of an array value. Objects become JSON, matching what the + * top-level object branch already does — `String({})` renders `[object + * Object]`, a row that looks populated while withholding everything, and unlike + * the elisions below it does not admit that anything was withheld. + */ +function stringifyMember(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (value !== null && typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** The approver-readable form of one argument value. */ +function stringify(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.map(stringifyMember).join(", "); + if (value !== null && typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** `1 more field` / `8 more fields` — one wording for every elision. */ +function more(count: number, unit: string): string { + return `${count} more ${unit}${count === 1 ? "" : "s"}`; +} + +/** + * A value clipped to `MAX_VALUE`, naming what it withheld. A bare `…` reads the + * same on a 305-character body as on a 4000-character one; the row cap already + * says how much it dropped, so the value elision says so too. + */ +function elide(text: string): string { + if (text.length <= MAX_VALUE) return text; + return `${text.slice(0, MAX_VALUE)}… (${more(text.length - MAX_VALUE, "character")})`; +} + +/** + * A tool call's raw arguments as table rows an approver can read. Bounded on + * both axes — a mail body or a fifty-key payload would otherwise push the + * buttons off the bottom of the card. + */ +export function toolRunFields( + args: Record, +): ConfirmToolRunField[] { + const rows: ConfirmToolRunField[] = []; + for (const [key, value] of Object.entries(args)) { + if (isEmpty(value)) continue; + rows.push({ label: humanize(key), value: elide(stringify(value)) }); + } + + if (rows.length > MAX_FIELDS) { + return [ + ...rows.slice(0, MAX_FIELDS), + { label: "…", value: more(rows.length - MAX_FIELDS, "field") }, + ]; + } + return rows; +} + +/** What a click reports back: which pending call, and whether it may run. */ +export type ConfirmDecision = { token: string; approved: boolean }; + +/** The label of the button that declines the run. */ +const DECLINE_LABEL = "Cancel"; + +/** + * Verbs that name an irreversible action, so the confirm button can say which + * one it is. `confirm-write.tsx`'s `DESTRUCTIVE` set, widened by the verbs real + * Composio slugs use (`GOOGLECALENDAR_CLEAR_CALENDAR`, + * `GOOGLECALENDAR_CHANNELS_STOP`). + * + * Unlike the sibling's set, this one does not decide *whether* a run is + * destructive — the caller's `destructive` prop does. It only names the button + * once the caller has said so. + */ +const DESTRUCTIVE_VERBS = new Set([ + "delete", + "remove", + "archive", + "cancel", + "revoke", + "clear", + "stop", + "trash", + "purge", + "destroy", + "drop", +]); + +/** + * The action's own destructive verb, e.g. `Linear remove issue label` -> + * `Remove`. Scans every word rather than taking the first the way + * `confirm-write.tsx`'s `verbOf` does, because a humanised Composio action + * leads with the app (`Gmail delete thread`), not with the verb. + */ +function destructiveVerbOf(action: string): string { + for (const word of action.trim().split(/\s+/)) { + if (DESTRUCTIVE_VERBS.has(word.toLowerCase())) { + return word[0]!.toUpperCase() + word.slice(1).toLowerCase(); + } + } + return ""; +} + +/** + * How the confirm button reads. A destructive run is named by its own verb, so + * the button never claims a remove is a delete — `Linear remove issue label` + * confirms with `Remove`. + * + * Falls back to `Confirm` twice over: when no verb is recognised (better a + * vague button than a wrong one), and when the verb would collide with the + * decline button — `Googlecalendar cancel event` would otherwise render two + * buttons both reading "Cancel", one cancelling the event and one cancelling + * the request. + */ +function confirmLabel(action: string, destructive: boolean): string { + if (!destructive) return "Approve"; + const verb = destructiveVerbOf(action); + if (!verb) return "Confirm"; + return verb.toLowerCase() === DECLINE_LABEL.toLowerCase() ? "Confirm" : verb; +} + +interface ConfirmToolRunProps { + /** Short imperative title of the run, e.g. 'Delete Gmail thread'. */ + action: string; + /** + * The run's arguments as approver-readable rows, rendered as a headerless + * two-column table. No `columns` prop is passed, so neither renderer emits a + * header row — "Field | Value" would spend a row restating the layout. + */ + fields: ConfirmToolRunField[]; + /** + * Whether the run cannot be undone. Decided by the caller from the tool's + * slug rather than derived from `action` here, because the Composio tool name + * is the authority on that, not the prose title. + */ + destructive: boolean; + /** Looks up the pending call. The args themselves never enter the payload. */ + token: string; +} + +/** + * Run the decision this button carries. + * + * Defined here rather than taken as a prop, and that is load-bearing. A click + * that arrives after a restart is resolved by re-rendering this component from + * its **stored props** and re-plucking `onClick`; a function handed in as a + * prop cannot survive that round trip, so the re-rendered button would carry no + * handler, the dispatcher would raise `ActionExpiredError`, and the Channel + * swallows that — the person would click and see nothing at all. Everything + * this closure captures (`action`) is a serializable prop, so it is rebuilt + * intact every time. Same reason `ConfirmWrite` defines its handlers inline. + * + * The import is dynamic only to keep the module graph acyclic: `run-tool.tsx` + * imports this card. It is a cache hit — the runtime loaded that module at + * startup, before any card could be posted. + */ +async function decide( + interaction: InteractionContext, + action: string, +): Promise { + const { handleToolRunDecision } = await import( + "../tools/composio/run-tool.js" + ); + await handleToolRunDecision(interaction, action); +} + +export function ConfirmToolRun({ + action, + fields, + destructive, + token, +}: ConfirmToolRunProps) { + return ( + +
{`${destructive ? "⚠️ " : ""}${action}`}
+
+ {destructive ? "This cannot be undone." : "Approve to continue."} +
+ {fields.length > 0 ? ( + + {fields.map((field) => ( + + {field.label} + {field.value} + + ))} +
+ ) : null} + + {/* + The warning colour marks the irreversible choice, not the safe one, so + Cancel stays neutral on a destructive card. + */} + + + +
+ ); +} + +/** Terminal state after a decision, replacing the card in place. */ +export function ToolRunOutcome({ + action, + text, + ok, +}: { + action: string; + text: string; + ok: boolean; +}) { + return ( + +
{`${ok ? "✅" : "⚠️"} ${action}`}
+
{text}
+
+ ); +} diff --git a/app/human-in-the-loop/connect-account.tsx b/app/human-in-the-loop/connect-account.tsx new file mode 100644 index 0000000..6c03974 --- /dev/null +++ b/app/human-in-the-loop/connect-account.tsx @@ -0,0 +1,59 @@ +/** + * The connect prompt. Public in the thread, and deliberately carries no URL: a + * connect link binds whoever completes it to the user id it was minted for, so + * a link posted in a channel is an account-takeover hazard. The link is minted + * on click, for the clicker, and delivered privately. + */ +import { Actions, Button, Context, Header, Message, Section } from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; + +/** What the button carries. The toolkit only — never an id, never a link. */ +export type ConnectRequest = { toolkit: string }; + +/** + * Mint and deliver the link for whoever clicked. + * + * Defined here rather than taken as a prop, and that is the difference between + * a working button and a dead one. A click after a restart is served by + * re-rendering this card from its **stored props**; a function passed in as a + * prop does not survive that, so the button would come back with no handler and + * the Channel swallows the resulting `ActionExpiredError` — the person clicks + * "Connect" and nothing happens, every time, with nothing to explain it. This + * flow needs no in-process state at all (the link is minted fresh, for the + * clicker), so re-derived it works exactly as well as it did before the + * restart. + * + * The import is dynamic only to keep the module graph acyclic: `connect-tool.tsx` + * imports this card. It resolves from the module cache — the runtime loaded + * that module at startup, before any card could be posted. + */ +async function connect(interaction: InteractionContext, toolkit: string) { + const { handleConnectClick } = await import("../tools/composio/connect-tool.js"); + await handleConnectClick(toolkit, interaction); +} + +export function ConnectAccount({ toolkit }: { toolkit: string }) { + const label = toolkit.charAt(0).toUpperCase() + toolkit.slice(1); + return ( + +
{`🔗 Connect ${label}`}
+
+ {`I need access to your ${label} account to do that. The link is private to whoever clicks.`} +
+ + + + + {`Google will show "Composio" — that's the service OpenTag uses to connect apps. Anyone else in this thread can click to connect their own account.`} + +
+ ); +} diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index d20645f..a793709 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -5,5 +5,24 @@ * * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. + * + * `ConfirmToolRun` is its sibling for Composio tool runs, which have no paused + * graph behind them: the decision rides in the button `value` and a click + * handler does the work. + * + * `ConnectAccount` asks one person to connect one of their own accounts. It is + * public in the thread but carries no link — see the file for why a pre-minted + * connect URL must never be posted where someone else can click it. */ export { ConfirmWrite } from "./confirm-write.js"; +export { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, +} from "./confirm-tool-run.js"; +export type { + ConfirmDecision, + ConfirmToolRunField, +} from "./confirm-tool-run.js"; +export { ConnectAccount } from "./connect-account.js"; +export type { ConnectRequest } from "./connect-account.js"; diff --git a/app/tools/composio/__tests__/classify.test.ts b/app/tools/composio/__tests__/classify.test.ts new file mode 100644 index 0000000..e6a9979 --- /dev/null +++ b/app/tools/composio/__tests__/classify.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { effectOf, needsApproval } from "../classify.js"; + +describe("effectOf", () => { + it("reads readOnlyHint", () => { + expect(effectOf(["readOnlyHint", "inbox"])).toBe("read"); + }); + + it("reads destructiveHint", () => { + expect(effectOf(["destructiveHint"])).toBe("destructive"); + }); + + it("treats create and update as write", () => { + expect(effectOf(["createHint"])).toBe("write"); + expect(effectOf(["updateHint"])).toBe("write"); + }); + + it("prefers destructive when a tool carries both hints", () => { + expect(effectOf(["readOnlyHint", "destructiveHint"])).toBe("destructive"); + }); + + it("treats unknown, empty, and missing tags as write", () => { + expect(effectOf(["important"])).toBe("write"); + expect(effectOf([])).toBe("write"); + expect(effectOf(undefined)).toBe("write"); + }); +}); + +describe("needsApproval", () => { + it("never asks when off", () => { + expect(needsApproval("destructive", "off")).toBe(false); + expect(needsApproval("write", "off")).toBe(false); + }); + + it("asks only for destructive by default", () => { + expect(needsApproval("destructive", "destructive")).toBe(true); + expect(needsApproval("write", "destructive")).toBe(false); + expect(needsApproval("read", "destructive")).toBe(false); + }); + + it("asks for anything that is not a read when writes", () => { + expect(needsApproval("destructive", "writes")).toBe(true); + expect(needsApproval("write", "writes")).toBe(true); + expect(needsApproval("read", "writes")).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/client.test.ts b/app/tools/composio/__tests__/client.test.ts new file mode 100644 index 0000000..ca7ae64 --- /dev/null +++ b/app/tools/composio/__tests__/client.test.ts @@ -0,0 +1,109 @@ +/** + * Covers client.ts — the lazily constructed Composio SDK client. + * + * The SDK is mocked because the properties that matter here are invisible from + * the returned object: that construction happens lazily, exactly once, and with + * telemetry off. Only an assertion on the constructor argument can see the last + * one, and it is load-bearing rather than hygiene — Composio's default + * telemetry registers SIGINT/SIGTERM handlers that re-raise the signal, which + * would truncate the graceful shutdown in server.ts. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Composio } from "@composio/core"; +import type { ComposioConfig } from "../config.js"; + +/** + * A fresh object per construction, so a reset is observable by identity. Not an + * arrow — the client calls this with `new`, and a returned object wins over the + * constructed `this`. + */ +const ComposioConstructor = vi.fn(function fakeComposio() { + return { + sessions: { create: vi.fn() }, + tools: { getRawComposioTools: vi.fn() }, + }; +}); + +vi.mock("@composio/core", () => ({ Composio: ComposioConstructor })); + +const { composioClient, resetComposioClient } = await import("../client.js"); + +/** + * Sampled at import time, before any test body runs. Importing the module must + * construct nothing — the whole design rests on an unconfigured deployment + * never instantiating the SDK. + */ +const constructionsAfterImport = ComposioConstructor.mock.calls.length; + +const config: ComposioConfig = { + apiKey: "ak_test", + workspaceToolkits: ["linear"], + userToolkits: [], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, +}; + +beforeEach(() => { + resetComposioClient(); + ComposioConstructor.mockClear(); +}); + +describe("composioClient", () => { + it("exposes the surfaces the cache needs", () => { + const client = composioClient(config); + expect(typeof client.sessions.create).toBe("function"); + expect(typeof client.tools.getRawComposioTools).toBe("function"); + }); + + it("constructs once and reuses the instance", () => { + expect(composioClient(config)).toBe(composioClient(config)); + expect(ComposioConstructor).toHaveBeenCalledTimes(1); + }); + + it("constructs nothing until it is called", () => { + expect(constructionsAfterImport).toBe(0); + + composioClient(config); + composioClient(config); + expect(ComposioConstructor).toHaveBeenCalledTimes(1); + }); + + it("constructs a new instance after a reset", () => { + const before = composioClient(config); + resetComposioClient(); + const after = composioClient(config); + + expect(after).not.toBe(before); + expect(ComposioConstructor).toHaveBeenCalledTimes(2); + }); + + it("disables telemetry and the version check", () => { + composioClient(config); + + // Telemetry's SIGINT/SIGTERM handlers re-raise the signal after removing + // themselves, which kills the process mid-drain in server.ts. The version + // check is an unasked-for network call at construction. + expect(ComposioConstructor).toHaveBeenCalledWith({ + apiKey: "ak_test", + allowTracking: false, + disableVersionCheck: true, + }); + }); +}); + +/** + * Compile-time guard on the `as unknown as ComposioSdk` cast in client.ts. That + * cast disables drift detection, and later tasks call through it, so assert the + * real instance still carries the two members `ComposioSdk` requires: an SDK + * rename then fails `pnpm check-types` instead of failing at runtime. + */ +interface SdkShapeGuard { + sessions: { create: (userId: string, options: Record) => unknown }; + tools: { getRawComposioTools: (query: { toolkits: string[]; limit: number }) => unknown }; +} + +/** Errors on the type argument, rather than quietly resolving to `never`. */ +type Satisfies = TActual; + +type _AssertSdkShape = Satisfies; diff --git a/app/tools/composio/__tests__/config.test.ts b/app/tools/composio/__tests__/config.test.ts new file mode 100644 index 0000000..488d5cd --- /dev/null +++ b/app/tools/composio/__tests__/config.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { readComposioConfig } from "../config.js"; + +describe("readComposioConfig", () => { + it("returns null without an API key", () => { + expect(readComposioConfig({ COMPOSIO_TOOLKITS: "linear" }, "open-tag")).toBeNull(); + }); + + it("returns null when a key is set but no toolkits are", () => { + expect(readComposioConfig({ COMPOSIO_API_KEY: "ak_x" }, "open-tag")).toBeNull(); + }); + + it("returns null for a whitespace-only API key", () => { + expect( + readComposioConfig({ COMPOSIO_API_KEY: " ", COMPOSIO_TOOLKITS: "linear" }, "open-tag"), + ).toBeNull(); + }); + + it("parses toolkits, trimming, lowercasing, dropping empties", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: " LINEAR , ,jira " }, + "open-tag", + ); + expect(config?.workspaceToolkits).toEqual(["linear", "jira"]); + }); + + it("defaults approvals to destructive and userId to the channel name", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }, + "open-tag", + ); + expect(config?.approvals).toBe("destructive"); + expect(config?.workspaceUserId).toBe("open-tag"); + }); + + it("overrides the userId when one is configured", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_TOOLKITS: "linear", + COMPOSIO_WORKSPACE_USER_ID: " acme-workspace ", + }, + "open-tag", + ); + expect(config?.workspaceUserId).toBe("acme-workspace"); + }); + + it("rejects an unknown approval mode", () => { + expect(() => + readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: "sometimes" }, + "open-tag", + ), + ).toThrow('Invalid COMPOSIO_APPROVALS: "sometimes"'); + }); + + it("treats an empty approvals value as unset", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: " " }, + "open-tag", + ); + expect(config?.approvals).toBe("destructive"); + }); + + it("accepts off and writes, case-insensitively", () => { + const off = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: "OFF" }, + "open-tag", + ); + expect(off?.approvals).toBe("off"); + + const writes = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: " Writes " }, + "open-tag", + ); + expect(writes?.approvals).toBe("writes"); + }); + + it("parses optional auth config pins", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "gmail:ac_123, jira:ac_456", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ gmail: "ac_123", jira: "ac_456" }); + }); + + it("preserves auth config id case", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "GMAIL:ac_ExAmPle1-aB, linear:ac_ExAmPle2Cd", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ + gmail: "ac_ExAmPle1-aB", + linear: "ac_ExAmPle2Cd", + }); + }); + + it("keeps a colon inside an auth config id and skips entries without one", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "gmail:ac_a:b, bogus", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ gmail: "ac_a:b" }); + }); + + it("defaults authConfigs to empty when unset", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({}); + }); + + it("accepts either toolkit list alone", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_USER_TOOLKITS: "gmail" }, + "open-tag", + ); + expect(config?.workspaceToolkits).toEqual([]); + expect(config?.userToolkits).toEqual(["gmail"]); + }); +}); diff --git a/app/tools/composio/__tests__/connect-link.test.ts b/app/tools/composio/__tests__/connect-link.test.ts new file mode 100644 index 0000000..2c405d3 --- /dev/null +++ b/app/tools/composio/__tests__/connect-link.test.ts @@ -0,0 +1,127 @@ +/** + * The two decisions `scripts/composio-connect.ts` makes before it calls + * anything. Nothing here touches the network — the script's own API calls are + * deliberately not exercised, since a success path would mint a real Connect + * Link against a real project. + */ +import { describe, expect, it } from "vitest"; +import { + resolveSharedToolkit, + selectAuthConfig, + type AuthConfigSummary, +} from "../connect-link.js"; + +const config = { workspaceToolkits: ["linear", "jira"], userToolkits: ["gmail"] }; + +function authConfig(id: string, slug: string, managed = false): AuthConfigSummary { + return { id, toolkit: { slug }, isComposioManaged: managed }; +} + +describe("resolveSharedToolkit", () => { + it("accepts a slug in COMPOSIO_TOOLKITS", () => { + expect(resolveSharedToolkit(config, "linear")).toEqual({ ok: true, value: "linear" }); + }); + + it("normalizes a shouted or padded slug", () => { + expect(resolveSharedToolkit(config, " LINEAR ")).toEqual({ ok: true, value: "linear" }); + }); + + it("refuses a personal toolkit and points at the in-thread flow", () => { + const result = resolveSharedToolkit(config, "gmail"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("personal toolkit"); + expect(result.message).toContain("Connect card"); + }); + + it("refuses an unconfigured toolkit and lists the shared ones", () => { + const result = resolveSharedToolkit(config, "salesforce"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("COMPOSIO_TOOLKITS"); + expect(result.message).toContain("linear, jira"); + }); + + // Table-driven rather than a loop with an early return: `if (result.ok) + // return` inside a loop exits the whole test as a pass, so a regression that + // accepted only the *second* case would never fail it. + it.each([["undefined", undefined], ["empty", ""], ["whitespace", " "]])( + "prints usage when the argument is %s", + (_label, missing) => { + const result = resolveSharedToolkit(config, missing); + + expect(result).toEqual({ ok: false, message: expect.stringContaining("Usage:") }); + }, + ); + + it("says so plainly when no shared toolkit is configured at all", () => { + const result = resolveSharedToolkit({ workspaceToolkits: [], userToolkits: [] }, undefined); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("(none configured)"); + }); +}); + +describe("selectAuthConfig", () => { + it("uses the pin without consulting the listing", () => { + // Pinning is the override for a bad guess, so an empty listing is not an + // error when a pin exists. + expect(selectAuthConfig("linear", "ac_ExAmPle1-aB", [])).toEqual({ + ok: true, + value: "ac_ExAmPle1-aB", + }); + }); + + it("ignores a whitespace-only pin", () => { + const result = selectAuthConfig("linear", " ", [authConfig("ac_one", "linear")]); + + expect(result).toEqual({ ok: true, value: "ac_one" }); + }); + + it("takes the only auth config for the toolkit", () => { + const result = selectAuthConfig("linear", undefined, [ + authConfig("ac_gmail", "gmail"), + authConfig("ac_linear", "linear"), + ]); + + expect(result).toEqual({ ok: true, value: "ac_linear" }); + }); + + it("matches a slug the API returned in another case", () => { + const result = selectAuthConfig("linear", undefined, [authConfig("ac_linear", "LINEAR")]); + + expect(result).toEqual({ ok: true, value: "ac_linear" }); + }); + + it("tells the operator to add the toolkit when none exists", () => { + const result = selectAuthConfig("linear", undefined, [authConfig("ac_gmail", "gmail")]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("No auth config exists"); + expect(result.message).toContain("app.composio.dev"); + }); + + it("refuses to guess between several and asks for a pin", () => { + const result = selectAuthConfig("linear", undefined, [ + authConfig("ac_one", "linear", true), + authConfig("ac_two", "linear"), + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("ac_one"); + expect(result.message).toContain("Composio-managed"); + expect(result.message).toContain("ac_two"); + expect(result.message).toContain("COMPOSIO_AUTH_CONFIGS=linear:"); + }); + + it("does not crash on an item with no toolkit", () => { + const result = selectAuthConfig("linear", undefined, [{ id: "ac_orphan" }]); + + expect(result.ok).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/connect-tool.test.tsx b/app/tools/composio/__tests__/connect-tool.test.tsx new file mode 100644 index 0000000..be5e51a --- /dev/null +++ b/app/tools/composio/__tests__/connect-tool.test.tsx @@ -0,0 +1,677 @@ +/** + * `connect_my_app` is where one person's provider account gets bound to one + * Composio user id, so the tests are mostly about who that id belongs to. + * + * A connect link is a bearer capability: whoever completes the flow attaches + * THEIR mailbox to the id the link was minted for. A link minted for Alice and + * posted into a channel therefore hands Bob a way to become Alice. Half of what + * follows exists to hold that line — no URL in the public card, the link minted + * from the clicker, delivered ephemerally, never logged. + * + * The click handler is driven both ways: directly, and through the `onClick` + * the posted card actually carries, so a card wired to the wrong handler fails + * here rather than in production. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + renderToIR, + type ChannelNode, + type ClickHandler, + type InteractionContext, +} from "@copilotkit/channels"; +import { ConnectAccount } from "../../../human-in-the-loop/index.js"; +import { resetComposioClient } from "../client.js"; +import { createConnectTool, handleConnectClick } from "../connect-tool.js"; +import type { ComposioConfig } from "../config.js"; +import { + clearSessionCache, + getSession, + type Authorization, + type ComposioSdk, +} from "../sessions.js"; + +/** + * The click takes no SDK: it re-derives the memoized client from the + * environment, which is what lets a card posted before a restart still work. + * So the fake is installed where that client is constructed — `makeSdk` + * registers itself here, and every test drives the production path. + */ +let currentSdk: object | undefined; + +vi.mock("@composio/core", () => ({ + Composio: vi.fn(function fakeComposio() { + if (!currentSdk) throw new Error("no fake SDK was installed for this test"); + return currentSdk; + }), +})); + +const config: ComposioConfig = { + apiKey: "ak_x", + workspaceToolkits: [], + userToolkits: ["gmail"], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, +}; + +/** The bearer capability under test. Nothing public may ever contain it. */ +const REDIRECT = "https://connect.composio.dev/link/lk_x"; + +type Authorize = () => Promise; + +/** A promise plus the handles to settle it from the test body. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Let every already-queued microtask and timer callback run. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** A fake SDK that records every session it was asked to create. */ +function makeSdk(authorize: Authorize = async () => ({ redirectUrl: REDIRECT })) { + const created: Array<{ userId: string; options: Record }> = []; + const sdk = { + created, + sessions: { + create: vi.fn(async (userId: string, options: Record) => { + created.push({ userId, options }); + return { + sessionId: "trs_1", + authorize: vi.fn(authorize), + search: vi.fn(), + execute: vi.fn(), + toolkits: vi.fn(), + }; + }), + }, + tools: { getRawComposioTools: vi.fn(async () => []) }, + }; + currentSdk = sdk; + return sdk; +} + +/** `null` means the platform gave us no verified identity for this event. */ +type ActorId = string | null; + +function actorOf(actorId: ActorId) { + return actorId === null ? undefined : { id: actorId }; +} + +/** The tool handler's context: who asked, and the thread they asked in. */ +function makeCtx(actorId: ActorId = "U1") { + const post = vi.fn(); + const postEphemeral = vi.fn(); + return { + ctx: { actor: actorOf(actorId), thread: { post, postEphemeral } } as never, + post, + postEphemeral, + }; +} + +/** What a surface reports back from `postEphemeral`. `null` is a real answer. */ +type EphemeralOutcome = { ok: boolean; usedFallback?: boolean } | null; + +/** Slack: a native only-you message, delivered. */ +const NATIVE: EphemeralOutcome = { ok: true, usedFallback: false }; + +/** A button click: a different person, in the same thread. */ +function makeInteraction( + actorId: ActorId = "U2", + ephemeral: (() => Promise) | (() => never) = async () => NATIVE, +) { + const post = vi.fn(); + const postEphemeral = vi.fn( + async (_user: unknown, _ui: unknown, _opts: unknown): Promise => + ephemeral(), + ); + return { + interaction: { + actor: actorOf(actorId), + action: { id: "b1", value: { toolkit: "gmail" } }, + platform: "teams", + thread: { post, postEphemeral }, + } as unknown as InteractionContext<{ toolkit: string }>, + post, + postEphemeral, + }; +} + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if (children && typeof children === "object" && "type" in (children as object)) { + return [children as ChannelNode]; + } + return []; +} + +function findAll(nodes: ChannelNode[], type: string): ChannelNode[] { + const out: ChannelNode[] = []; + for (const node of nodes) { + if (node.type === type) out.push(node); + out.push(...findAll(childNodes(node), type)); + } + return out; +} + +/** Every descendant `text` node's text, concatenated depth-first. */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** + * Everything the card would carry to the platform — text, button values, props. + * Serialized rather than walked, because the assertion is about the whole + * payload: a URL smuggled into a button `value` is as public as one in a + * `Section`. + */ +function payloadOf(renderable: unknown): string { + return JSON.stringify(renderToIR(renderable as never), (_key, value) => + typeof value === "function" ? "[fn]" : value, + ); +} + +/** The click handler the posted card actually wires to its button. */ +function clickHandlerOf(renderable: unknown): ClickHandler<{ toolkit: string }> { + const button = findAll(renderToIR(renderable as never), "button")[0]; + const onClick = button?.props?.onClick; + if (typeof onClick !== "function") throw new Error("posted card carries no button handler"); + return onClick as ClickHandler<{ toolkit: string }>; +} + +/** Post the card, then return the handler its button carries. */ +async function postAndGetHandler(triggerer = "U1") { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(triggerer); + await tool.handler({ toolkit: "gmail" }, ctx); + return { onClick: clickHandlerOf(post.mock.calls[0]?.[0]), post }; +} + +/** Every string any console channel saw, so a leak anywhere is one assertion. */ +function captureConsole() { + const lines: string[] = []; + const record = (...args: unknown[]) => void lines.push(args.map(String).join(" ")); + for (const channel of ["log", "warn", "error", "info", "debug"] as const) { + vi.spyOn(console, channel).mockImplementation(record); + } + return lines; +} + +/** Env this deployment is configured with, restored after every test. */ +const ENV_UNDER_TEST = { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", +}; + +const savedEnv: Record = {}; + +beforeEach(() => { + clearSessionCache(); + // The click reads the environment, so the environment is part of the setup. + for (const [key, value] of Object.entries(ENV_UNDER_TEST)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } + // Each test installs its own fake through `makeSdk`; the memo must not carry + // the previous test's across. + resetComposioClient(); + currentSdk = undefined; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); +}); + +describe("connect_my_app", () => { + it("posts a card and does not mint a link", async () => { + const sdk = makeSdk(); + const { ctx, post } = makeCtx(); + const tool = createConnectTool(config); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(sdk.sessions.create).not.toHaveBeenCalled(); + expect(String(result)).toContain("Connect"); + }); + + it("refuses a toolkit that is not configured", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "salesforce" }, ctx); + + expect(String(result)).toContain("not configured"); + expect(post).not.toHaveBeenCalled(); + }); + + it("refuses a shared team toolkit and says who connects it", async () => { + const sdk = makeSdk(); + const tool = createConnectTool({ ...config, workspaceToolkits: ["linear"] }); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "linear" }, ctx); + + expect(String(result)).toContain("shared team app"); + // Names the one path that works. The dashboard's connect button binds to + // the dashboard's own user id, so pointing there was worse than silence. + expect(String(result)).toContain("pnpm composio:connect linear"); + expect(post).not.toHaveBeenCalled(); + expect(sdk.sessions.create).not.toHaveBeenCalled(); + }); + + it("still connects a toolkit that is both shared and personal", async () => { + const tool = createConnectTool({ ...config, workspaceToolkits: ["gmail"] }); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).not.toContain("shared team app"); + }); + + it("refuses without a verified actor", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(null); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(String(result)).toContain("who you are"); + expect(post).not.toHaveBeenCalled(); + }); + + it("accepts a slug the model shouted or padded", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: " GMAIL " }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).not.toContain("not configured"); + }); +}); + +describe("the public card", () => { + it("carries a button and no URL", async () => { + const { ctx, post } = makeCtx(); + const tool = createConnectTool(config); + + await tool.handler({ toolkit: "gmail" }, ctx); + + const card = post.mock.calls[0]?.[0]; + const payload = payloadOf(card); + expect(findAll(renderToIR(card as never), "button")).toHaveLength(1); + // Not just the known link: ANY url in the public payload is the bug. + expect(payload).not.toContain("http"); + expect(payload).not.toContain(REDIRECT); + expect(renderToIR(card as never).map(collectText).join("")).toContain("Connect Gmail"); + }); + + it("is posted to the thread, not sent privately to the triggerer", async () => { + const { ctx, post, postEphemeral } = makeCtx(); + const tool = createConnectTool(config); + + await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(postEphemeral).not.toHaveBeenCalled(); + }); +}); + +describe("the connect click", () => { + it("mints the link for the clicker, not for whoever triggered the card", async () => { + const sdk = makeSdk(); + const { onClick } = await postAndGetHandler("U1"); + const { interaction } = makeInteraction("U2"); + + await onClick(interaction); + + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + expect(sdk.created[0]?.userId).toBe("U2"); + expect(sdk.created[0]?.userId).not.toBe("U1"); + }); + + it("creates the connect session with the workbench disabled", async () => { + const sdk = makeSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(sdk.created[0]?.options).toMatchObject({ + toolkits: ["gmail"], + workbench: { enable: false }, + }); + }); + + it("delivers the link privately to the clicker and never to the channel", async () => { + const sdk = makeSdk(); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + const [target, ui, opts] = postEphemeral.mock.calls[0]!; + expect(target).toEqual({ id: "U2" }); + expect(String(ui)).toContain(REDIRECT); + expect(opts).toEqual({ fallbackToDM: true }); + }); + + it("never logs the redirect url", async () => { + const lines = captureConsole(); + const sdk = makeSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(lines.join("\n")).not.toContain(REDIRECT); + expect(lines.join("\n")).not.toContain("http"); + }); + + it("drops the clicker's cached sessions so the new app is usable next message", async () => { + const sdk = makeSdk(); + const typed = sdk as unknown as ComposioSdk; + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + + const { interaction } = makeInteraction("U2"); + await handleConnectClick("gmail", interaction); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + + // A third create means the cached entry is gone, not merely stale. + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(3); + }); + + it("leaves another user's cached session alone", async () => { + const sdk = makeSdk(); + const typed = sdk as unknown as ComposioSdk; + await getSession(typed, "U9", ["gmail"]); + + const { interaction } = makeInteraction("U2"); + await handleConnectClick("gmail", interaction); + + await getSession(typed, "U9", ["gmail"]); + // Priming plus the click. A third would mean U9 was evicted too. + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("ignores a click it cannot attribute to anyone", async () => { + const sdk = makeSdk(); + const { interaction, post, postEphemeral } = makeInteraction(null); + + await handleConnectClick("gmail", interaction); + + expect(sdk.sessions.create).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("reports a failed authorize privately, with no link", async () => { + const sdk = makeSdk(async () => { + throw new Error("composio is down"); + }); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(String(postEphemeral.mock.calls[0]?.[1])).not.toContain("http"); + }); + + it("reports an authorize that came back without a link", async () => { + const sdk = makeSdk(async () => ({ redirectUrl: "" })); + const { interaction, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + const text = String(postEphemeral.mock.calls[0]?.[1]); + expect(text).not.toContain("Authorize"); + expect(text).toContain("could not"); + }); +}); + +/** + * A surface without a private channel is the case that decides whether this + * flow degrades or leaks. Teams implements no `postEphemeral`, so channels-core + * answers `{ ok: false }` — and OpenTag advertises Teams support, so an ignored + * result is a dead platform, not an edge case. + */ +describe("a surface with no private message", () => { + const noPrivateChannel: Array<[string, () => Promise]> = [ + ["reports {ok:false} (Teams: no ephemeral path at all)", async () => ({ ok: false })], + ["resolves null (no ephemeral, no DM fallback)", async () => null], + [ + "throws (the transport failed)", + async () => { + throw new Error("request to https://teams.example/api failed"); + }, + ], + ]; + + for (const [name, ephemeral] of noPrivateChannel) { + it(`says so instead of posting the link when the surface ${name}`, async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2", ephemeral); + + await expect( + handleConnectClick("gmail", interaction), + ).resolves.toBeUndefined(); + + expect(post).toHaveBeenCalledTimes(1); + const text = String(post.mock.calls[0]?.[0]); + // The whole point: no private channel means the link goes nowhere. + expect(text).not.toContain(REDIRECT); + expect(text).not.toContain("http"); + expect(text).toContain("teams"); + }); + } + + it("keeps the provider's error out of the channel when it cannot be sent privately", async () => { + const sdk = makeSdk(async () => { + throw new Error("composio said U2's account is barred"); + }); + const { interaction, post } = makeInteraction("U2", async () => ({ ok: false })); + + await handleConnectClick("gmail", interaction); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(post.mock.calls[0]?.[0])).not.toContain("barred"); + }); + + it("says where the link went when it fell back to a DM", async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2", async () => ({ + ok: true, + usedFallback: true, + })); + + await handleConnectClick("gmail", interaction); + + expect(post).toHaveBeenCalledTimes(1); + const text = String(post.mock.calls[0]?.[0]); + expect(text).toContain("direct message"); + expect(text).not.toContain("http"); + }); + + it("posts nothing publicly when the private message landed natively", async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + }); +}); + +/** + * The mint-time invalidation is too early on its own: the tool tells the user + * to ask again, and asking again refills the cache with a session that still + * predates the connection. So the completion is chained too — without ever + * being awaited, because the turn has to end before Slack cuts the update. + */ +describe("the connection completing later", () => { + /** An SDK whose authorization exposes a wait the test settles by hand. */ + function makeWaitingSdk() { + const pending = deferred(); + const waitForConnection = vi.fn(async (_timeoutMs?: number) => pending.promise); + const sdk = makeSdk(async () => ({ redirectUrl: REDIRECT, waitForConnection })); + return { sdk, pending, waitForConnection }; + } + + it("returns without waiting for the browser flow", async () => { + makeWaitingSdk(); + const { interaction } = makeInteraction("U2"); + + const outcome = await Promise.race([ + handleConnectClick("gmail", interaction).then( + () => "returned", + ), + new Promise((resolve) => setTimeout(() => resolve("still waiting"), 50)), + ]); + + expect(outcome).toBe("returned"); + }); + + it("waits far longer than Composio's own default", async () => { + const { waitForConnection } = makeWaitingSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + // Ten minutes. Composio's ~60s default would reject before a real consent + // screen is read, making the whole chain useless. + expect(waitForConnection).toHaveBeenCalledWith(10 * 60 * 1000); + }); + + it("drops the clicker's cache again once the connection completes", async () => { + const { sdk, pending } = makeWaitingSdk(); + const typed = sdk as unknown as ComposioSdk; + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + // The user does what the tool told them to: asks again, mid-flow. This + // refills the cache with a session that predates the connection. + await getSession(typed, "U2", ["gmail"]); + const beforeConnect = sdk.sessions.create.mock.calls.length; + + pending.resolve({ id: "ca_1" }); + await flush(); + + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create.mock.calls.length).toBe(beforeConnect + 1); + }); + + it("survives an abandoned authorization without an unhandled rejection", async () => { + const unhandled: unknown[] = []; + const record = (reason: unknown) => void unhandled.push(reason); + process.on("unhandledRejection", record); + try { + const { sdk, pending } = makeWaitingSdk(); + const typed = sdk as unknown as ComposioSdk; + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + await getSession(typed, "U2", ["gmail"]); + const beforeTimeout = sdk.sessions.create.mock.calls.length; + + // What a closed browser tab looks like. Unhandled, this terminates the + // process on Node 22 — the bot would go down with the user's tab. + pending.reject(new Error("connection timed out")); + await flush(); + + expect(unhandled).toEqual([]); + // Nothing connected, so nothing is invalidated. + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create.mock.calls.length).toBe(beforeTimeout); + } finally { + process.off("unhandledRejection", record); + } + }); + + it("does not chain a wait onto a link nobody could be sent", async () => { + const { waitForConnection } = makeWaitingSdk(); + const { interaction } = makeInteraction("U2", async () => ({ ok: false })); + + await handleConnectClick("gmail", interaction); + + expect(waitForConnection).not.toHaveBeenCalled(); + }); + + it("still works against a session that offers no wait at all", async () => { + makeSdk(); + const { interaction, postEphemeral } = makeInteraction("U2"); + + await expect( + handleConnectClick("gmail", interaction), + ).resolves.toBeUndefined(); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); +}); + +/** + * The regression that makes the card's shape load-bearing. + * + * A click arriving after a restart is served by re-rendering `ConnectAccount` + * from its **stored** props — which have been through the state store and carry + * no functions. A handler passed in as a prop would be gone, the re-rendered + * button would have no `onClick`, and the Channel swallows the resulting + * `ActionExpiredError`: the person clicks "Connect Gmail" and nothing happens, + * every time, with nothing anywhere to explain it. + * + * Nothing in this flow needs the process that posted the card — the link is + * minted fresh, for the clicker — so cold it must work exactly as warm. + */ +describe("a click that lands after a restart", () => { + it("mints a link from the stored props alone, with no handler carried over", async () => { + const sdk = makeSdk(); + const stored = JSON.parse(JSON.stringify({ toolkit: "gmail" })) as { + toolkit: string; + }; + + const onClick = clickHandlerOf(); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + await onClick(interaction); + + expect(sdk.created[0]?.userId).toBe("U2"); + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(String(postEphemeral.mock.calls[0]?.[1])).toContain(REDIRECT); + // And still never in the thread, cold path or not. + expect(post).not.toHaveBeenCalled(); + }); + + it("tells the clicker privately when the deployment is no longer configured", async () => { + // An operator pulled the key while a card was live. Silence would be + // indistinguishable from the bug above. + delete process.env.COMPOSIO_API_KEY; + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + const told = String(postEphemeral.mock.calls[0]?.[1]); + expect(told).toContain("not configured"); + // A missing key is permanent. "Try again in a moment" would send the + // clicker into a loop that cannot end and hide whose problem this is. + expect(told).not.toContain("Try again in a moment"); + expect(told).toContain("Retrying will not help"); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tools/composio/__tests__/index.test.ts b/app/tools/composio/__tests__/index.test.ts new file mode 100644 index 0000000..9af8dc3 --- /dev/null +++ b/app/tools/composio/__tests__/index.test.ts @@ -0,0 +1,188 @@ +/** + * Covers index.ts — which tools a deployment exposes, and what an unconfigured + * one costs. + * + * The SDK is mocked so the assertions can see construction itself: the central + * promise of this module is that an unconfigured deployment constructs nothing, + * and that is invisible from the returned array. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Shared across constructions so a test can decide, per user id, whether that + * scope's session comes up at all — which is the only way to see what one + * failing scope costs the others. + */ +const sessionsCreate = vi.fn(); +const getRawComposioTools = vi.fn(); + +const ComposioConstructor = vi.fn(function fakeComposio() { + return { + sessions: { create: sessionsCreate }, + tools: { getRawComposioTools }, + }; +}); + +vi.mock("@composio/core", () => ({ Composio: ComposioConstructor })); + +const { composioTools } = await import("../index.js"); +const { resetComposioClient } = await import("../client.js"); +const { clearSessionCache } = await import("../sessions.js"); + +beforeEach(() => { + resetComposioClient(); + clearSessionCache(); + ComposioConstructor.mockClear(); + sessionsCreate.mockReset(); + getRawComposioTools.mockReset(); + vi.restoreAllMocks(); +}); + +function toolNames(env: NodeJS.ProcessEnv): string[] { + return composioTools(env, "open-tag").map((tool) => tool.name); +} + +describe("composioTools", () => { + it("returns nothing without a key", () => { + expect(composioTools({ COMPOSIO_TOOLKITS: "linear" }, "open-tag")).toEqual( + [], + ); + }); + + it("returns nothing without toolkits", () => { + expect(composioTools({ COMPOSIO_API_KEY: "ak_x" }, "open-tag")).toEqual([]); + }); + + it("constructs no SDK client when unconfigured", () => { + composioTools({}, "open-tag"); + composioTools({ COMPOSIO_TOOLKITS: "linear" }, "open-tag"); + composioTools({ COMPOSIO_API_KEY: "ak_x" }, "open-tag"); + + expect(ComposioConstructor).not.toHaveBeenCalled(); + }); + + it("returns search and run for a workspace-only config", () => { + expect(toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" })).toEqual( + ["search_my_tools", "run_my_tool"], + ); + }); + + it("adds connect_my_app when personal toolkits are configured", () => { + expect( + toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_USER_TOOLKITS: "gmail" }), + ).toContain("connect_my_app"); + }); + + it("omits connect_my_app when every toolkit is shared", () => { + expect( + toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }), + ).not.toContain("connect_my_app"); + }); + + /** + * The warnings are a boot-time diagnostic. Emitting them from the per-turn + * path would bury a real misconfiguration under one repetition per message. + */ + it("emits startup warnings once per call, not once per turn", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const tools = composioTools( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_TOOLKITS: "gmail", + COMPOSIO_USER_TOOLKITS: "gmail", + }, + "open-tag", + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain( + "is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS", + ); + // The tools exist and were built without warning again. + expect(tools.length).toBeGreaterThan(0); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("never logs the API key", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + composioTools( + { + COMPOSIO_API_KEY: "ak_secret", + COMPOSIO_TOOLKITS: "gmail", + LINEAR_API_KEY: "lin_x", + COMPOSIO_USER_TOOLKITS: "linear", + }, + "open-tag", + ); + + for (const call of warn.mock.calls) { + expect(JSON.stringify(call)).not.toContain("ak_secret"); + } + }); +}); + +/** + * The per-turn scope resolution, reached through `search_my_tools` because that + * is the only door to it — `resolve` is a closure, and the thing worth asserting + * is what a turn gets when one identity's session cannot be created. + */ +describe("a scope whose session fails", () => { + const BOTH = { + COMPOSIO_API_KEY: "ak_secret", + COMPOSIO_TOOLKITS: "linear", + COMPOSIO_USER_TOOLKITS: "gmail", + }; + + /** A session that answers one search with one candidate slug. */ + function workingSession(slug: string) { + return { + sessionId: "trs_1", + search: vi.fn(async () => ({ + results: [{ primaryToolSlugs: [slug] }], + toolSchemas: { [slug]: { description: "d", inputSchema: {} } }, + })), + }; + } + + async function searchAsAlice(): Promise<{ tools: Array<{ slug: string }> }> { + const tool = composioTools(BOTH, "open-tag").find((t) => t.name === "search_my_tools"); + if (!tool) throw new Error("search_my_tools was not exposed"); + return (await tool.handler({ query: "anything" }, { + actor: { id: "U_ALICE" }, + thread: { post: vi.fn() }, + } as never)) as { tools: Array<{ slug: string }> }; + } + + beforeEach(() => { + getRawComposioTools.mockResolvedValue([{ slug: "LINEAR_LIST_ISSUES", tags: ["readOnlyHint"] }]); + // Alice's personal session is the one that cannot be created. + sessionsCreate.mockImplementation(async (userId: string) => { + if (userId === "U_ALICE") throw new Error("connection refused"); + return workingSession("LINEAR_LIST_ISSUES"); + }); + }); + + it("costs only its own tools, not the other scope's", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await searchAsAlice(); + + expect(result.tools.map((t) => t.slug)).toEqual(["LINEAR_LIST_ISSUES"]); + }); + + it("warns, naming the scope and no credential", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await searchAsAlice(); + + const lines = warn.mock.calls.map((call) => String(call[0])); + const reported = lines.filter((line) => line.includes("no session for")); + expect(reported).toHaveLength(1); + expect(reported[0]).toContain("U_ALICE"); + expect(reported[0]).toContain("gmail"); + expect(reported[0]).toContain("connection refused"); + for (const line of lines) expect(line).not.toContain("ak_secret"); + }); +}); diff --git a/app/tools/composio/__tests__/pending.test.ts b/app/tools/composio/__tests__/pending.test.ts new file mode 100644 index 0000000..ac763c0 --- /dev/null +++ b/app/tools/composio/__tests__/pending.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearPending, + mayApprove, + registerPending, + restorePending, + takePending, +} from "../pending.js"; + +function call(overrides: Partial[0]> = {}) { + return { + session: {} as never, + slug: "GMAIL_DELETE_THREAD", + args: { id: "t1" }, + effect: "destructive" as const, + userId: "U_ALICE", + workspaceUserId: "open-tag", + action: "Gmail delete thread", + ...overrides, + }; +} + +beforeEach(() => clearPending()); + +describe("pending registry", () => { + it("round-trips a call by token", () => { + const token = registerPending(call()); + expect(takePending(token)?.slug).toBe("GMAIL_DELETE_THREAD"); + }); + + it("issues distinct tokens", () => { + expect(registerPending(call())).not.toBe(registerPending(call())); + }); + + it("consumes the token so a card cannot be replayed", () => { + const token = registerPending(call()); + takePending(token); + expect(takePending(token)).toBeUndefined(); + }); + + it("returns undefined for an unknown token", () => { + expect(takePending("nope")).toBeUndefined(); + }); + + it("draws the token from the CSPRNG, not from Math.random", () => { + // For a workspace-scope call, holding the token IS the authorization, so + // the ~41 predictable bits of `Math.random()` are not enough to defend a + // delete with. A v4 UUID is 122 bits from `crypto`. + const token = registerPending(call()); + expect(token).toMatch( + /^ctr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("keeps the arguments out of the token itself", () => { + // The token is what travels in the Slack action value. Anything the caller + // passed must stay in this process. + const token = registerPending(call({ args: { body: "secret-payload" } })); + expect(token).not.toContain("secret-payload"); + }); +}); + +describe("bounded registry", () => { + it("evicts the oldest call once the cap is passed", () => { + // Each entry pins a live session and the call's arguments — mail bodies + // included — and this map outlives every conversation. + const first = registerPending(call({ slug: "FIRST" })); + const tokens = [first]; + for (let i = 0; i < 64; i++) tokens.push(registerPending(call({ slug: `T_${i}` }))); + + expect(takePending(first)).toBeUndefined(); + // Only the oldest went: everything after it is still approvable. + for (const token of tokens.slice(1)) expect(takePending(token)).toBeDefined(); + }); + + it("keeps exactly the cap's worth without evicting", () => { + const first = registerPending(call({ slug: "FIRST" })); + for (let i = 0; i < 63; i++) registerPending(call()); + expect(takePending(first)?.slug).toBe("FIRST"); + }); + + it("never evicts the call it was just handed", () => { + for (let i = 0; i < 64; i++) registerPending(call()); + const newest = registerPending(call({ slug: "NEWEST" })); + expect(takePending(newest)?.slug).toBe("NEWEST"); + }); +}); + +describe("restorePending", () => { + it("puts a call back under the same token the live card carries", () => { + const token = registerPending(call()); + const taken = takePending(token); + expect(taken).toBeDefined(); + if (!taken) return; + + restorePending(token, taken); + // A fresh token would strand the call: the posted card still carries the + // original one, so the rightful approver's click would read "expired". + expect(takePending(token)?.slug).toBe("GMAIL_DELETE_THREAD"); + }); + + it("still consumes on the next read", () => { + const token = registerPending(call()); + const taken = takePending(token); + if (!taken) throw new Error("expected a call"); + restorePending(token, taken); + takePending(token); + expect(takePending(token)).toBeUndefined(); + }); +}); + +describe("mayApprove", () => { + it("lets anyone approve a workspace-scope call", () => { + const c = call({ userId: "open-tag" }); + expect(mayApprove(c, "U_BOB")).toBe(true); + }); + + it("lets the originator approve their own personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), "U_ALICE")).toBe(true); + }); + + it("refuses someone else approving a personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), "U_BOB")).toBe(false); + }); + + it("refuses an unidentified clicker on a personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), undefined)).toBe(false); + }); + + it("refuses a blank clicker id on a personal-scope call", () => { + // `""` is not an identity, and `"" === call.userId` must never be the test + // that admits a click. + expect(mayApprove(call({ userId: "U_ALICE" }), "")).toBe(false); + expect(mayApprove(call({ userId: "" }), "")).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/run-tool.test.tsx b/app/tools/composio/__tests__/run-tool.test.tsx new file mode 100644 index 0000000..90e250e --- /dev/null +++ b/app/tools/composio/__tests__/run-tool.test.tsx @@ -0,0 +1,492 @@ +/** + * `run_my_tool` is the gate between the model and irreversible action, so the + * tests split in two. + * + * The first half drives `tool.handler` and asserts what reaches Composio and + * what only reaches a card. The second half drives `handleToolRunDecision` — + * the Approve/Cancel click — directly. That handler carries the whole + * authorization story (expired token, wrong approver, provider failure), which + * is why it is an exported function rather than a closure inside the JSX. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToIR, type ChannelNode, type InteractionContext } from "@copilotkit/channels"; +import { createRunTool, handleToolRunDecision } from "../run-tool.js"; +import { clearPending, registerPending, type PendingCall } from "../pending.js"; +import type { CachedSession, RawSession } from "../sessions.js"; +import type { ConfirmDecision } from "../../../human-in-the-loop/index.js"; + +/** Exactly what the SDK hands back — note that a failure resolves, never throws. */ +type ExecuteResult = Awaited>; + +/** What `session.execute` resolves to on a successful run. */ +function okResult(): ExecuteResult { + return { data: { ok: true }, error: null, logId: "log_1" }; +} + +function cached( + execute: RawSession["execute"] = vi.fn(async () => okResult()), + userId = "open-tag", +) { + return { + session: { sessionId: "trs_1", execute }, + userId, + effects: new Map([ + ["GMAIL_SEND_EMAIL", "write"], + ["GMAIL_DELETE_THREAD", "destructive"], + ["GMAIL_FETCH_EMAILS", "read"], + ]), + toolkits: ["gmail"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +function makeCtx() { + const post = vi.fn(); + return { ctx: { actor: { id: "U1" }, thread: { post } } as never, post }; +} + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if (children && typeof children === "object" && "type" in (children as object)) { + return [children as ChannelNode]; + } + return []; +} + +function findAll(nodes: ChannelNode[], type: string): ChannelNode[] { + const out: ChannelNode[] = []; + for (const node of nodes) { + if (node.type === type) out.push(node); + out.push(...findAll(childNodes(node), type)); + } + return out; +} + +/** Every descendant `text` node's text, concatenated depth-first. */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** The approval token a posted card carries in its buttons. */ +function tokenOf(renderable: unknown): string { + const button = findAll(renderToIR(renderable as never), "button")[0]; + const value = button?.props?.value as ConfirmDecision | undefined; + if (!value?.token) throw new Error("posted card carries no token"); + return value.token; +} + +function textOf(renderable: unknown): string { + return renderToIR(renderable as never) + .map(collectText) + .join(" "); +} + +beforeEach(() => clearPending()); + +describe("run_my_tool", () => { + it("runs a read immediately without posting a card", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_FETCH_EMAILS", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalledWith("GMAIL_FETCH_EMAILS", {}); + }); + + it("runs a write immediately in destructive mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: { to: "a@b.c" } }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("posts a card and does NOT execute a destructive tool", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + const result = await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + expect(String(result)).toContain("Stop here"); + }); + + it("posts a card for any write in writes mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "writes", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + }); + + it("never posts a card in off mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("gates an unmapped slug in writes mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "writes", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: {} }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + }); + + it("gates an unmapped slug in destructive mode, the default", async () => { + // The effect map is filled with `limit: 300`, so a real slug past the cap + // is unmapped, as is anything hallucinated or injected. Calling those + // "writes" would run them unapproved in the mode that ships by default. + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: { id: "x" } }, ctx); + expect(entry.session.execute).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("still runs a classified write unapproved in destructive mode", async () => { + // The other half of the rule: gating the unmapped case must not quietly + // gate every write and turn `destructive` mode into `writes` mode. + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(entry.session.execute).toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it("runs an unmapped slug in off mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("routes to the scope holding the slug", async () => { + const other = cached(); + other.effects = new Map([["LINEAR_LIST_ISSUES", "read"]]) as never; + const gmail = cached(); + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [other, gmail], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_FETCH_EMAILS", args: {} }, ctx); + expect(gmail.session.execute).toHaveBeenCalled(); + expect(other.session.execute).not.toHaveBeenCalled(); + }); + + it("surfaces a returned error rather than treating it as success", async () => { + const entry = cached( + vi.fn(async () => ({ data: {}, error: "recipient_email must be a string", logId: "log_9" })), + ); + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + const result = await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(String(result)).toContain("recipient_email must be a string"); + }); + + it("returns an error string when nothing resolves", async () => { + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [], "off", "open-tag"); + expect(String(await tool.handler({ slug: "X", args: {} }, ctx))).toContain("not configured"); + }); + + it("puts only the token in the button payload, never the arguments", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler( + { slug: "GMAIL_DELETE_THREAD", args: { id: "t1", body: "secret-payload" } }, + ctx, + ); + + const buttons = findAll(renderToIR(post.mock.calls[0]?.[0] as never), "button"); + expect(buttons.length).toBeGreaterThan(0); + for (const button of buttons) { + const value = button.props?.value as ConfirmDecision; + // Slack action values are size-capped and must not carry user data. + expect(Object.keys(value).sort()).toEqual(["approved", "token"]); + expect(JSON.stringify(value)).not.toContain("secret-payload"); + } + }); +}); + +/** A pending call plus the `execute` spy behind its session. */ +function pending(overrides: Partial = {}, result: ExecuteResult = okResult()) { + const execute = vi.fn(async () => result); + const call: PendingCall = { + session: { sessionId: "trs_1", execute } as unknown as RawSession, + slug: "GMAIL_DELETE_THREAD", + args: { id: "t1" }, + effect: "destructive", + userId: "U_ALICE", + workspaceUserId: "open-tag", + action: "Gmail delete thread", + ...overrides, + }; + return { call, token: registerPending(call), execute }; +} + +function click(value: ConfirmDecision | undefined, clickerId: string | undefined) { + const update = vi.fn(); + const post = vi.fn(); + const interaction = { + thread: { update, post }, + message: { ref: { id: "m1" } }, + action: { id: "act_1", value }, + values: {}, + user: null, + actor: clickerId === undefined ? undefined : { id: clickerId, kind: "human" }, + platform: "slack", + } as unknown as InteractionContext; + return { interaction, update, post }; +} + +describe("handleToolRunDecision", () => { + it("reports an unknown token as expired and executes nothing", async () => { + const { execute } = pending(); + const { interaction, update } = click({ token: "ctr_gone", approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("treats a token consumed by an earlier click as expired", async () => { + const { token, execute } = pending(); + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(execute).toHaveBeenCalledTimes(1); + + const replay = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(replay.interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledTimes(1); + expect(textOf(replay.update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("ignores a click carrying no decision", async () => { + const { execute } = pending(); + const { interaction, update, post } = click(undefined, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it("refuses someone else's approval and leaves the request standing", async () => { + const { token, execute } = pending({ userId: "U_ALICE" }); + const bob = click({ token, approved: true }, "U_BOB"); + + await handleToolRunDecision(bob.interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + // The card keeps its buttons — refusing Bob must not cancel Alice's request. + expect(bob.update).not.toHaveBeenCalled(); + expect(String(bob.post.mock.calls[0]?.[0])).toContain("Only the person who asked"); + + // And the same card, clicked by Alice, still works. + const alice = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(alice.interaction, "Gmail delete thread"); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("refuses an unidentified clicker on a personal-scope call", async () => { + const { token, execute } = pending({ userId: "U_ALICE" }); + const { interaction, post } = click({ token, approved: true }, undefined); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(String(post.mock.calls[0]?.[0])).toContain("Only the person who asked"); + }); + + it("lets anyone approve a workspace-scope call", async () => { + const { token, execute } = pending({ userId: "open-tag" }); + const { interaction } = click({ token, approved: true }, "U_BOB"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("executes nothing on a decline and says so on the card", async () => { + const { token, execute } = pending(); + const { interaction, update } = click({ token, approved: false }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("Cancelled"); + }); + + it("runs the approved call with the arguments held in process", async () => { + const { token, execute } = pending({ args: { id: "t1", body: "secret-payload" } }); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledWith("GMAIL_DELETE_THREAD", { + id: "t1", + body: "secret-payload", + }); + expect(textOf(update.mock.calls[0]?.[1])).toContain("Done"); + }); + + it("puts a returned error on the card instead of reporting success", async () => { + // `execute` resolves rather than throws on failure, so a try/catch alone + // would rewrite the card to "Done." after deleting nothing. + const { token } = pending({}, { data: {}, error: "Invalid request data provided", logId: "l" }); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + const text = textOf(update.mock.calls[0]?.[1]); + expect(text).toContain("Invalid request data provided"); + expect(text).not.toContain("Done."); + }); + + it("reports a transport-level throw on the card instead of freezing it", async () => { + // The token is consumed before `execute` runs, so a throw that escaped + // would leave a card with dead buttons and no result at all. + const { call } = pending(); + const execute = vi.fn(async () => { + throw new Error("socket hang up"); + }); + call.session = { sessionId: "trs_1", execute } as unknown as RawSession; + const token = registerPending(call); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(update).toHaveBeenCalledTimes(1); + const text = textOf(update.mock.calls[0]?.[1]); + expect(text).toContain("socket hang up"); + expect(text).not.toContain("Done."); + }); + + it("gives an empty token the same answer as an expired one", async () => { + const { execute } = pending(); + const { interaction, update } = click({ token: "", approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("reports an evicted card as expired rather than crashing", async () => { + const { token, execute } = pending(); + for (let i = 0; i < 64; i++) pending({ slug: `FILLER_${i}` }); + + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("names the pending call's own action, not the label passed by the caller", async () => { + const { token } = pending({ action: "Gmail delete thread" }); + const { interaction, update } = click({ token, approved: false }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Some other card"); + + expect(textOf(update.mock.calls[0]?.[1])).toContain("Gmail delete thread"); + }); +}); + +describe("scope identity decides who may approve", () => { + /** Gate one destructive call and hand back the token its card carries. */ + async function gate(scope: CachedSession, actorId: string) { + const post = vi.fn(); + const tool = createRunTool(async () => [scope], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: actorId }, + thread: { post }, + } as never); + return tokenOf(post.mock.calls[0]?.[0]); + } + + it("binds a card routed to a personal scope to that scope's owner", async () => { + const entry = cached(undefined, "U_ALICE"); + const token = await gate(entry, "U_ALICE"); + + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(entry.session.execute).not.toHaveBeenCalled(); + + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(entry.session.execute).toHaveBeenCalledTimes(1); + }); + + it("lets a colleague approve a workspace-scope card a named person asked for", async () => { + // Alice sent the message, but the slug routed to the shared account. The + // call is the thread's, not hers, so Bob approving it touches nothing of + // his own — binding it to the actor would strand it behind one person. + const entry = cached(undefined, "open-tag"); + const token = await gate(entry, "U_ALICE"); + + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(entry.session.execute).toHaveBeenCalledTimes(1); + }); + + it("routes an unmapped personal slug by toolkit, not to the first scope", async () => { + // The effect map is filled with `limit: 300`, so a real slug can be absent + // from it. Falling back to `scopes[0]` would run Alice's gmail call through + // the shared session — which does not carry gmail — and bind its card to + // `workspaceUserId`, letting anyone in the thread approve it. + const workspace = cached(undefined, "open-tag"); + workspace.effects = new Map() as never; + workspace.toolkits = ["linear"] as never; + const personal = cached(undefined, "U_ALICE"); + personal.effects = new Map() as never; + + const post = vi.fn(); + const tool = createRunTool(async () => [workspace, personal], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: "U_ALICE" }, + thread: { post }, + } as never); + + const token = tokenOf(post.mock.calls[0]?.[0]); + // Bound to Alice: Bob's click does nothing, hers executes — and against the + // personal session, never the shared one. + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(personal.session.execute).not.toHaveBeenCalled(); + + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(personal.session.execute).toHaveBeenCalledTimes(1); + expect(workspace.session.execute).not.toHaveBeenCalled(); + }); + + it("picks the identity of the scope the slug routed to, not the first scope", async () => { + const workspace = cached(undefined, "open-tag"); + workspace.effects = new Map([["LINEAR_LIST_ISSUES", "read"]]) as never; + const personal = cached(undefined, "U_ALICE"); + const post = vi.fn(); + const tool = createRunTool(async () => [workspace, personal], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: "U_ALICE" }, + thread: { post }, + } as never); + + const token = tokenOf(post.mock.calls[0]?.[0]); + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(personal.session.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tools/composio/__tests__/scopes.test.ts b/app/tools/composio/__tests__/scopes.test.ts new file mode 100644 index 0000000..689461b --- /dev/null +++ b/app/tools/composio/__tests__/scopes.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { resolveScopes, startupWarnings } from "../scopes.js"; +import type { ComposioConfig } from "../config.js"; + +function config(overrides: Partial = {}): ComposioConfig { + return { + apiKey: "ak_x", + workspaceToolkits: [], + userToolkits: [], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, + ...overrides, + }; +} + +describe("resolveScopes", () => { + it("returns the workspace scope when only it is configured", () => { + expect(resolveScopes(config({ workspaceToolkits: ["linear"] }), { id: "U1" })).toEqual([ + { userId: "open-tag", toolkits: ["linear"] }, + ]); + }); + + it("returns the personal scope when only it is configured", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: "U1" })).toEqual([ + { userId: "U1", toolkits: ["gmail"] }, + ]); + }); + + it("returns BOTH when both are configured", () => { + const scopes = resolveScopes( + config({ workspaceToolkits: ["linear"], userToolkits: ["gmail"] }), + { id: "U1" }, + ); + expect(scopes).toEqual([ + { userId: "open-tag", toolkits: ["linear"] }, + { userId: "U1", toolkits: ["gmail"] }, + ]); + }); + + it("drops the personal scope without a verified actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["linear"], userToolkits: ["gmail"] }), undefined), + ).toEqual([{ userId: "open-tag", toolkits: ["linear"] }]); + }); + + it("returns nothing when personal-only and there is no actor", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), undefined)).toEqual([]); + }); + + it("drops a toolkit from the workspace scope when it is also personal", () => { + const scopes = resolveScopes( + config({ workspaceToolkits: ["linear", "jira"], userToolkits: ["linear"] }), + { id: "U1" }, + ); + expect(scopes).toEqual([ + { userId: "open-tag", toolkits: ["jira"] }, + { userId: "U1", toolkits: ["linear"] }, + ]); + }); + + it("does not fall back to the shared identity for a dual-listed toolkit", () => { + expect( + resolveScopes( + config({ workspaceToolkits: ["gmail", "jira"], userToolkits: ["gmail"] }), + undefined, + ), + ).toEqual([{ userId: "open-tag", toolkits: ["jira"] }]); + }); + + it("returns nothing when de-duplication empties the shared list and there is no actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), undefined), + ).toEqual([]); + }); + + it("drops the personal scope for an actor with an empty id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: "" })).toEqual([]); + }); + + it("drops the personal scope for an actor with no id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), {})).toEqual([]); + }); + + it("drops the personal scope for an actor with a whitespace-only id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: " " })).toEqual([]); + }); + + it("never routes a personal toolkit to the shared identity for an idless actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), { id: "" }), + ).toEqual([]); + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), {}), + ).toEqual([]); + }); +}); + +describe("startupWarnings", () => { + it("is silent on a clean config", () => { + expect(startupWarnings(config({ workspaceToolkits: ["linear"] }), {})).toEqual([]); + }); + + it("warns when a toolkit is in both lists", () => { + const warnings = startupWarnings( + config({ workspaceToolkits: ["linear"], userToolkits: ["linear"] }), + {}, + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("linear"); + expect(warnings[0]).toContain("own account"); + }); + + it("warns when a personal app sits in the shared list", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["gmail"] }), {}); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("COMPOSIO_USER_TOOLKITS"); + }); + + it("warns when a toolkit is also configured over MCP", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["linear"] }), { + LINEAR_API_KEY: "lin_x", + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("LINEAR_API_KEY"); + }); + + it("warns once for a toolkit listed twice in the shared list", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["gmail", "gmail"] }), {}); + expect(warnings).toHaveLength(1); + }); + + it("is silent when only the MCP integration is configured", () => { + expect(startupWarnings(config({ workspaceToolkits: ["jira"] }), { LINEAR_API_KEY: "x" })).toEqual( + [], + ); + }); +}); diff --git a/app/tools/composio/__tests__/search-tool.test.ts b/app/tools/composio/__tests__/search-tool.test.ts new file mode 100644 index 0000000..8d3c6a2 --- /dev/null +++ b/app/tools/composio/__tests__/search-tool.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSearchTool } from "../search-tool.js"; +import type { CachedSession } from "../sessions.js"; + +function cached(searchResult: unknown): CachedSession { + return { + session: { sessionId: "trs_1", search: vi.fn(async () => searchResult) }, + effects: new Map(), + toolkits: ["gmail"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +/** A scope whose Composio session is broken. */ +function failing(): CachedSession { + return { + session: { + sessionId: "trs_dead", + search: vi.fn(async () => { + throw new Error("session expired"); + }), + }, + effects: new Map(), + toolkits: ["linear"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +/** A one-result scope offering `slugs`, each with a trivial input schema. */ +function scopeOffering(...slugs: string[]): CachedSession { + return cached({ + results: [{ primaryToolSlugs: slugs, relatedToolSlugs: [] }], + toolSchemas: Object.fromEntries( + slugs.map((s) => [s, { description: "d", inputSchema: { type: "object" } }]), + ), + toolkitConnectionStatuses: [], + }); +} + +const ctx = { actor: { id: "U1" } } as never; + +describe("search_my_tools", () => { + it("returns slugs with their schemas", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["GMAIL_SEND_EMAIL"], relatedToolSlugs: [] }], + toolSchemas: { + GMAIL_SEND_EMAIL: { description: "Sends an email.", inputSchema: { type: "object" } }, + }, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: true }], + }), + ]); + const result = (await tool.handler({ query: "send email" }, ctx)) as { + tools: Array<{ slug: string; inputSchema: unknown }>; + }; + expect(result.tools[0]?.slug).toBe("GMAIL_SEND_EMAIL"); + expect(result.tools[0]?.inputSchema).toEqual({ type: "object" }); + }); + + it("merges results across scopes", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["LINEAR_LIST_ISSUES"], relatedToolSlugs: [] }], + toolSchemas: { LINEAR_LIST_ISSUES: { description: "d", inputSchema: {} } }, + toolkitConnectionStatuses: [], + }), + cached({ + results: [{ primaryToolSlugs: ["GMAIL_SEND_EMAIL"], relatedToolSlugs: [] }], + toolSchemas: { GMAIL_SEND_EMAIL: { description: "d", inputSchema: {} } }, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["LINEAR_LIST_ISSUES", "GMAIL_SEND_EMAIL"]); + }); + + it("reports toolkits that still need connecting", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: false }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual(["gmail"]); + }); + + it("caps results at five", async () => { + const slugs = Array.from({ length: 12 }, (_, i) => `T_${i}`); + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: slugs, relatedToolSlugs: [] }], + toolSchemas: Object.fromEntries( + slugs.map((s) => [s, { description: "d", inputSchema: {} }]), + ), + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: unknown[] }; + expect(result.tools).toHaveLength(5); + }); + + it("returns an error string when nothing resolves", async () => { + const tool = createSearchTool(async () => []); + expect(await tool.handler({ query: "x" }, ctx)).toContain("not configured"); + }); + + it("gives every scope a share of the cap instead of letting the first fill it", async () => { + // The shared workspace scope alone could fill all five slots. If it did, + // the person's own calendar would be unreachable. + const tool = createSearchTool(async () => [ + scopeOffering("W_0", "W_1", "W_2", "W_3", "W_4", "W_5", "W_6"), + scopeOffering("P_0", "P_1", "P_2", "P_3", "P_4", "P_5", "P_6"), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + const slugs = result.tools.map((t) => t.slug); + + expect(slugs).toHaveLength(5); + expect(slugs.some((s) => s.startsWith("W_"))).toBe(true); + expect(slugs.some((s) => s.startsWith("P_"))).toBe(true); + // Round-robin, so the two scopes alternate by rank. + expect(slugs).toEqual(["W_0", "P_0", "W_1", "P_1", "W_2"]); + }); + + it("keeps each scope's own ranking: primary slugs before related ones", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["A_PRIMARY"], relatedToolSlugs: ["A_RELATED"] }], + toolSchemas: { + A_PRIMARY: { description: "d", inputSchema: {} }, + A_RELATED: { description: "d", inputSchema: {} }, + }, + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["A_PRIMARY", "A_RELATED"]); + }); + + it("still answers from the healthy scope when another scope's search rejects", async () => { + const tool = createSearchTool(async () => [failing(), scopeOffering("GMAIL_SEND_EMAIL")]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["GMAIL_SEND_EMAIL"]); + }); + + it("returns an empty result rather than throwing when every scope rejects", async () => { + const tool = createSearchTool(async () => [failing(), failing()]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result.tools).toEqual([]); + expect(result.needsConnection).toEqual([]); + }); + + it("searches the scopes in parallel", async () => { + // The first scope only finishes once the second has started, so a + // sequential implementation deadlocks and this test times out. + let releaseFirst!: () => void; + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = cached(undefined) as unknown as { session: { search: () => Promise } }; + first.session.search = async () => { + await secondStarted; + return { + results: [{ primaryToolSlugs: ["A"] }], + toolSchemas: { A: { inputSchema: {} } }, + }; + }; + const second = cached(undefined) as unknown as { session: { search: () => Promise } }; + second.session.search = async () => { + releaseFirst(); + return { + results: [{ primaryToolSlugs: ["B"] }], + toolSchemas: { B: { inputSchema: {} } }, + }; + }; + + const tool = createSearchTool(async () => [ + first as unknown as CachedSession, + second as unknown as CachedSession, + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["A", "B"]); + }); + + it("does not treat a connected toolkit as needing connection", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: true }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual([]); + }); + + it("does not treat an absent hasActiveConnection as needing connection", async () => { + // Silence is not a negative answer. Reporting it would prompt the user to + // reconnect an app that is already working. + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail" }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual([]); + }); + + it("reports a disconnected toolkit once even when several scopes see it", async () => { + const disconnected = { + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: false }], + }; + const tool = createSearchTool(async () => [cached(disconnected), cached(disconnected)]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual(["gmail"]); + }); + + it("returns a slug missing from toolSchemas with a null inputSchema", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["GMAIL_MYSTERY"], relatedToolSlugs: [] }], + toolSchemas: {}, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: Array<{ slug: string; description: string; inputSchema: unknown }>; + }; + expect(result.tools).toEqual([ + { slug: "GMAIL_MYSTERY", description: "", inputSchema: null }, + ]); + }); + + it("never lets an uncallable candidate displace a callable one", async () => { + const schemaless = Array.from({ length: 5 }, (_, i) => `NO_SCHEMA_${i}`); + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: [...schemaless, "HAS_SCHEMA"], relatedToolSlugs: [] }], + toolSchemas: { HAS_SCHEMA: { description: "d", inputSchema: { type: "object" } } }, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + // Ranked last by the scope, but the only one the model can actually call. + expect(result.tools[0]?.slug).toBe("HAS_SCHEMA"); + expect(result.tools).toHaveLength(5); + }); + + it("returns one entry when two scopes offer the same slug", async () => { + const tool = createSearchTool(async () => [ + scopeOffering("GMAIL_SEND_EMAIL"), + scopeOffering("GMAIL_SEND_EMAIL"), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["GMAIL_SEND_EMAIL"]); + }); + + it("tolerates a response with every field absent", async () => { + const tool = createSearchTool(async () => [cached({})]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); + + it("tolerates a result whose slug arrays are absent", async () => { + const tool = createSearchTool(async () => [cached({ results: [{}] })]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: unknown[] }; + expect(result.tools).toEqual([]); + }); + + it("tolerates a null response", async () => { + const tool = createSearchTool(async () => [cached(null)]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); + + it("tolerates malformed field types", async () => { + const tool = createSearchTool(async () => [ + cached({ results: "nope", toolSchemas: "nope", toolkitConnectionStatuses: "nope" }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); +}); diff --git a/app/tools/composio/__tests__/sessions.test.ts b/app/tools/composio/__tests__/sessions.test.ts new file mode 100644 index 0000000..e412abf --- /dev/null +++ b/app/tools/composio/__tests__/sessions.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearSessionCache, getSession, invalidateSession } from "../sessions.js"; + +function makeSdk() { + // The parameters are declared even though the fake ignores them: without them + // vitest infers an empty argument tuple and `mock.calls` cannot be indexed. + const create = vi.fn(async (_userId: string, _options: Record) => ({ + sessionId: "trs_1", + search: vi.fn(), + execute: vi.fn(), + authorize: vi.fn(), + toolkits: vi.fn(), + })); + const getRawComposioTools = vi.fn(async () => [ + { slug: "LINEAR_LIST_ISSUES", tags: ["readOnlyHint"] }, + { slug: "LINEAR_CREATE_ISSUE", tags: ["createHint"] }, + { slug: "LINEAR_DELETE_ISSUE", tags: ["destructiveHint"] }, + ]); + return { sessions: { create }, tools: { getRawComposioTools } }; +} + +beforeEach(() => clearSessionCache()); + +describe("getSession", () => { + it("always disables the remote workbench, on every session it creates", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + await getSession(sdk, "U2", ["gmail"]); + + const calls = sdk.sessions.create.mock.calls; + expect(calls).toHaveLength(2); + // Not `toHaveBeenCalledWith`, which passes when any single call matches. A + // session without this flag exposes remote bash and a Python sandbox, so the + // constraint has to hold for every call site, not just one of them. + for (const [, options] of calls) { + expect(options).toMatchObject({ workbench: { enable: false } }); + } + }); + + it("passes the requested toolkits", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledWith( + "open-tag", + expect.objectContaining({ toolkits: ["linear"] }), + ); + }); + + it("records whose identity the session acts as", async () => { + // The approval gate reads this to decide who may approve a call routed to + // this scope, so a session that does not know whose it is cannot be gated. + const sdk = makeSdk(); + expect((await getSession(sdk, "open-tag", ["linear"])).userId).toBe("open-tag"); + expect((await getSession(sdk, "U_ALICE", ["gmail"])).userId).toBe("U_ALICE"); + }); + + it("builds an effect map from tool tags", async () => { + const sdk = makeSdk(); + const cached = await getSession(sdk, "open-tag", ["linear"]); + expect(cached.effects.get("LINEAR_LIST_ISSUES")).toBe("read"); + expect(cached.effects.get("LINEAR_CREATE_ISSUE")).toBe("write"); + expect(cached.effects.get("LINEAR_DELETE_ISSUE")).toBe("destructive"); + }); + + it("reuses a cached session for the same user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + await getSession(sdk, "open-tag", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + }); + + it("keeps separate sessions per user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + await getSession(sdk, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("keeps separate cache entries per toolkit set for one user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + await getSession(sdk, "U1", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("rebuilds after invalidation", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + invalidateSession("U1"); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + // The TTL is 10 minutes and the module reads the clock with `Date.now()`, which + // vitest's fake timers replace. The pair matters: the "before" case proves the + // cache is really caching, so the "after" case is evidence of expiry rather + // than of a cache that never hit in the first place. + it("still reuses the session just before the TTL lapses", async () => { + vi.useFakeTimers(); + try { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + vi.advanceTimersByTime(9 * 60 * 1000); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("rebuilds the session after the TTL lapses", async () => { + vi.useFakeTimers(); + try { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + vi.advanceTimersByTime(11 * 60 * 1000); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/tools/composio/classify.ts b/app/tools/composio/classify.ts new file mode 100644 index 0000000..920377d --- /dev/null +++ b/app/tools/composio/classify.ts @@ -0,0 +1,24 @@ +/** + * Effect classification from Composio's MCP behavior tags. + * + * `ToolSchema.tags` is a generic `string[]` defaulting to empty, so an empty + * array cannot be distinguished from "nobody classified this". Anything not + * positively marked read-only is treated as a write — it fails safe, matching + * `agent/internal_sources.py:139`. + */ +import type { ApprovalMode } from "./config.js"; + +export type Effect = "read" | "write" | "destructive"; + +export function effectOf(tags: string[] | undefined): Effect { + const set = new Set(tags ?? []); + if (set.has("destructiveHint")) return "destructive"; + if (set.has("readOnlyHint")) return "read"; + return "write"; +} + +export function needsApproval(effect: Effect, mode: ApprovalMode): boolean { + if (mode === "off") return false; + if (mode === "destructive") return effect === "destructive"; + return effect !== "read"; +} diff --git a/app/tools/composio/client.ts b/app/tools/composio/client.ts new file mode 100644 index 0000000..1d058b7 --- /dev/null +++ b/app/tools/composio/client.ts @@ -0,0 +1,29 @@ +/** + * The Composio SDK client, constructed lazily. Nothing here runs unless + * `readComposioConfig` returned a config, so an unconfigured deployment never + * instantiates the SDK. + */ +import { Composio } from "@composio/core"; +import type { ComposioConfig } from "./config.js"; +import type { ComposioSdk } from "./sessions.js"; + +let instance: ComposioSdk | null = null; + +export function resetComposioClient(): void { + instance = null; +} + +export function composioClient(config: ComposioConfig): ComposioSdk { + if (!instance) { + instance = new Composio({ + apiKey: config.apiKey, + // Default telemetry registers its own SIGINT/SIGTERM handlers that + // re-raise the signal (Telemetry.ts:349), which truncates the graceful + // shutdown in server.ts:125. Also: this is a self-hosted product whose + // operator never opted into third-party analytics. + allowTracking: false, + disableVersionCheck: true, + }) as unknown as ComposioSdk; + } + return instance; +} diff --git a/app/tools/composio/config.ts b/app/tools/composio/config.ts new file mode 100644 index 0000000..e7e07bc --- /dev/null +++ b/app/tools/composio/config.ts @@ -0,0 +1,86 @@ +/** + * Composio configuration. Absent `COMPOSIO_API_KEY` returns null and the feature + * is never constructed — absent, not disabled, so the agent has no tool it can + * see but must not call. + */ + +export type ApprovalMode = "off" | "destructive" | "writes"; + +const APPROVAL_MODES: ApprovalMode[] = ["off", "destructive", "writes"]; + +export interface ComposioConfig { + apiKey: string; + workspaceToolkits: string[]; + userToolkits: string[]; + approvals: ApprovalMode; + workspaceUserId: string; + /** + * Rarely needed, and read only by `scripts/composio-connect.ts` — no runtime + * path consumes it. `session.authorize()` takes no auth config id and + * resolves one from the project itself, so a personal toolkit with several + * cannot be pinned; this pins the choice when the operator connects a shared + * toolkit. + */ + authConfigs: Record; +} + +function slugList(raw: string | undefined): string[] { + return (raw ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Empty or whitespace-only means unset, matching `readEnvironment`'s + * `?.trim() ||` idiom — `COMPOSIO_APPROVALS=` is routine in `.env` files and + * compose passthrough, and must not take the runtime down at boot. + */ +function approvalMode(raw: string | undefined): ApprovalMode { + const value = raw?.trim().toLowerCase() || "destructive"; + if (!APPROVAL_MODES.includes(value as ApprovalMode)) { + throw new Error( + `Invalid COMPOSIO_APPROVALS: "${raw}" — expected one of ${APPROVAL_MODES.join(", ")}`, + ); + } + return value as ApprovalMode; +} + +/** + * Parses `toolkit:auth_config_id` pairs. Toolkit keys are lowercased to match + * the toolkit lists, but ids are preserved verbatim — real auth config ids are + * mixed case (`ac_ExAmPle1-aB`), so lowercasing them would not resolve. Splits + * on the first colon only, so an id containing one is not truncated. + */ +function authConfigMap(raw: string | undefined): Record { + const pairs: Record = {}; + for (const entry of (raw ?? "").split(",")) { + const separator = entry.indexOf(":"); + if (separator === -1) continue; + const toolkit = entry.slice(0, separator).trim().toLowerCase(); + const id = entry.slice(separator + 1).trim(); + if (toolkit && id) pairs[toolkit] = id; + } + return pairs; +} + +export function readComposioConfig( + env: NodeJS.ProcessEnv, + defaultUserId: string, +): ComposioConfig | null { + const apiKey = env.COMPOSIO_API_KEY?.trim(); + if (!apiKey) return null; + + const workspaceToolkits = slugList(env.COMPOSIO_TOOLKITS); + const userToolkits = slugList(env.COMPOSIO_USER_TOOLKITS); + if (workspaceToolkits.length === 0 && userToolkits.length === 0) return null; + + return { + apiKey, + workspaceToolkits, + userToolkits, + approvals: approvalMode(env.COMPOSIO_APPROVALS), + workspaceUserId: env.COMPOSIO_WORKSPACE_USER_ID?.trim() || defaultUserId, + authConfigs: authConfigMap(env.COMPOSIO_AUTH_CONFIGS), + }; +} diff --git a/app/tools/composio/connect-link.ts b/app/tools/composio/connect-link.ts new file mode 100644 index 0000000..c9f22f7 --- /dev/null +++ b/app/tools/composio/connect-link.ts @@ -0,0 +1,120 @@ +/** + * The decisions behind `scripts/composio-connect.ts`, kept out of the script so + * they can be tested without a network. + * + * Shared toolkits have no in-Slack connect path — `connect_my_app` refuses them + * on purpose, because a connection made by a clicker binds to the clicker's own + * Composio `user_id` while every shared call resolves under + * `workspaceUserId`. The operator script is the only correct path, and these + * two functions are the whole of what it has to get right before it touches the + * API. + */ +import type { ComposioConfig } from "./config.js"; + +/** + * Structural subset of an `authConfigs.list()` item. Declared here rather than + * imported so a test can build one, matching how `sessions.ts` treats the SDK. + */ +export interface AuthConfigSummary { + id: string; + toolkit?: { slug?: string }; + isComposioManaged?: boolean; +} + +/** Either a decision, or the exact sentence the operator should read. */ +export type Decision = { ok: true; value: T } | { ok: false; message: string }; + +export const DASHBOARD_URL = "https://app.composio.dev"; + +/** + * Is this a slug the script may mint a shared link for? + * + * A personal toolkit is refused rather than quietly handled: minting a + * workspace-identity link for `gmail` when `gmail` is in + * `COMPOSIO_USER_TOOLKITS` would connect one mailbox that every personal-scope + * call then ignores, which is the same broken end state this script exists to + * prevent. + */ +export function resolveSharedToolkit( + config: Pick, + requested: string | undefined, +): Decision { + const slug = requested?.trim().toLowerCase(); + if (!slug) { + return { + ok: false, + message: + "Usage: pnpm composio:connect \n" + + `Shared toolkits on this deployment: ${describeList(config.workspaceToolkits)}`, + }; + } + + if (config.workspaceToolkits.includes(slug)) return { ok: true, value: slug }; + + if (config.userToolkits.includes(slug)) { + return { + ok: false, + message: + `"${slug}" is a personal toolkit (COMPOSIO_USER_TOOLKITS). Each person connects ` + + "their own from inside a thread — ask the agent for it and click the Connect " + + "card. This script only connects shared toolkits.", + }; + } + + return { + ok: false, + message: + `"${slug}" is not in COMPOSIO_TOOLKITS. Add it there (and at ${DASHBOARD_URL}) ` + + `and restart the runtime first.\n` + + `Shared toolkits on this deployment: ${describeList(config.workspaceToolkits)}`, + }; +} + +function describeList(slugs: string[]): string { + return slugs.length > 0 ? slugs.join(", ") : "(none configured)"; +} + +/** + * Which auth config the Connect Link should be minted against. + * + * A pin wins outright and is never cross-checked against the listing: pinning + * is how an operator overrides a bad guess, so second-guessing it would defeat + * the point. Otherwise exactly one candidate is required — with several, the + * script asks for a pin instead of picking, because the wrong auth config + * produces a connection that looks fine and grants the wrong scopes. + */ +export function selectAuthConfig( + toolkit: string, + pinned: string | undefined, + candidates: AuthConfigSummary[], +): Decision { + const pin = pinned?.trim(); + if (pin) return { ok: true, value: pin }; + + const matches = candidates.filter( + (candidate) => candidate.toolkit?.slug?.trim().toLowerCase() === toolkit, + ); + + if (matches.length === 0) { + return { + ok: false, + message: + `No auth config exists for "${toolkit}". Add the toolkit at ${DASHBOARD_URL} — ` + + "that is what creates its auth config — then run this again.", + }; + } + + const only = matches[0]; + if (matches.length === 1 && only) return { ok: true, value: only.id }; + + const listed = matches + .map((match) => ` ${match.id}${match.isComposioManaged ? " (Composio-managed)" : ""}`) + .join("\n"); + return { + ok: false, + message: + `"${toolkit}" has ${matches.length} auth configs and none is pinned, so I will not ` + + `guess which one to connect:\n${listed}\n` + + `Pin one with COMPOSIO_AUTH_CONFIGS=${toolkit}: and run this again.`, + }; +} diff --git a/app/tools/composio/connect-tool.tsx b/app/tools/composio/connect-tool.tsx new file mode 100644 index 0000000..cdf3b6f --- /dev/null +++ b/app/tools/composio/connect-tool.tsx @@ -0,0 +1,273 @@ +/** + * `connect_my_app` — posts the connect card and ends the turn. + * + * It does not wait for the OAuth round trip. Slack cuts the live update after + * about a minute (see `userFacingRunError` in app/channel-helpers.ts), and a + * browser authorization routinely exceeds that. + */ +import { z } from "zod"; +import { + defineChannelTool, + type ChannelTool, + type InteractionContext, +} from "@copilotkit/channels"; +import { composioClient } from "./client.js"; +import { readComposioConfig, type ComposioConfig } from "./config.js"; +import { invalidateSession, type Authorization, type ComposioSdk } from "./sessions.js"; +import { ConnectAccount, type ConnectRequest } from "../../human-in-the-loop/index.js"; + +/** + * How long to keep waiting for the browser flow, in ms. + * + * Composio's own default is about a minute, which is shorter than a person + * spends reading a Google consent screen — a naive wait would therefore reject + * on almost every real authorization, right when it was meant to help. + */ +const CONNECT_TIMEOUT_MS = 10 * 60 * 1000; + +/** Whether one person actually received a private message, and how. */ +type PrivateDelivery = { ok: true; usedFallback: boolean } | { ok: false }; + +/** + * Send `text` where only `clicker` can read it, and report whether that worked. + * + * Three ways it does not: `null` when the surface has no ephemeral path and no + * DM fallback was possible, `{ ok: false }` from `channels-core` when the + * adapter implements no `postEphemeral` at all (Teams, today), and a throw when + * the transport fails. All three mean nobody read it, and the caller has to + * know — an ignored result is a click that silently does nothing. + */ +async function postPrivately( + thread: InteractionContext["thread"], + clicker: InteractionContext["actor"], + text: string, +): Promise { + try { + const result = await thread.postEphemeral(clicker, text, { fallbackToDM: true }); + if (!result?.ok) return { ok: false }; + return { ok: true, usedFallback: result.usedFallback === true }; + } catch { + // Swallowed on purpose: a throw here escapes the click handler, the + // dispatcher rethrows it, and the clicker is left with a dead button and no + // explanation. The caller reports the failure instead. Nothing about the + // error is logged — a transport error message can carry the request URL, + // and this function's whole job is handling links nobody else may see. + return { ok: false }; + } +} + +/** + * Drop the clicker's cached sessions again the moment the browser flow + * completes. + * + * Deliberately not awaited: the turn has to end now (Slack cuts the live update + * after about a minute). The invalidation at mint time is the one that runs + * before this returns; this one exists because that one is too early — the user + * is told to ask again, and asking again refills the cache with a session that + * still predates the connection. + * + * The `.catch` is not optional. An abandoned authorization rejects on timeout, + * and Node 22 terminates the process on an unhandled rejection — a user who + * closes the browser tab would take the bot down with them. + */ +function invalidateOnConnection(authorization: Authorization, userId: string): void { + try { + void authorization + .waitForConnection?.(CONNECT_TIMEOUT_MS) + .then(() => invalidateSession(userId)) + .catch(() => {}); + } catch { + // A wait that cannot even start leaves the mint-time invalidation in place, + // which is exactly where this stood before the chain existed. + } +} + +/** + * Tell the clicker, and only the clicker, that the connection could not be + * started — falling back to the thread when there is no private channel, since + * a click that reports nothing is indistinguishable from a broken button. + * + * The provider's own error text is private: it is addressed to one person and + * can quote the request. The public fallback says less on purpose. + * + * `nextStep` is a parameter because the two callers are not the same kind of + * failure. A provider error may well clear on its own; a deployment with no + * `COMPOSIO_API_KEY` never will, and telling that clicker to try again in a + * moment sends them into a loop that cannot end and hides the one fact they + * could act on — that this is the operator's to fix, not theirs. + */ +async function reportStartFailure( + thread: InteractionContext["thread"], + clicker: InteractionContext["actor"], + toolkit: string, + failure: string | undefined, + nextStep = "Try again in a moment.", +): Promise { + const told = await postPrivately( + thread, + clicker, + failure + ? `I could not start the ${toolkit} connection — ${failure}. ${nextStep}` + : `I could not start the ${toolkit} connection. ${nextStep}`, + ); + if (told.ok) return; + await thread.post( + `I could not start the ${toolkit} connection, and I could not message you privately either.`, + ); +} + +/** + * The SDK for this process, re-derived from the environment rather than + * captured. + * + * `composioClient` memoizes, so this is the same instance every other Composio + * path uses; the point is that the click needs nothing carried over from the + * turn that posted the card. `null` means the deployment is no longer + * configured — an operator pulled the key while a card was live. + * + * The `""` default user id is never read here: it only fills + * `config.workspaceUserId`, and connecting always acts as the clicker. + */ +function sdkFromEnv(): ComposioSdk | null { + const config = readComposioConfig(process.env, ""); + return config ? composioClient(config) : null; +} + +/** + * The Connect click. + * + * Lifted out of the JSX for the same reason `handleToolRunDecision` is: this, + * not the tool handler, is where the security decision lives, and a closure + * inside a card is unreachable from a test. + * + * Everything here is derived from `interaction` and the environment — the + * clicker's id, the clicker's thread, and the process-wide SDK. Nothing is + * captured from the turn that posted the card: not the SDK, and above all not + * whoever triggered it, because a link minted for the triggerer and handed to a + * clicker is exactly the account-takeover this whole flow exists to prevent. + * That is also what lets the card be re-rendered from stored props and clicked + * long after a restart. + */ +export async function handleConnectClick( + toolkit: string, + interaction: InteractionContext, +): Promise { + const clicker = interaction.actor; + // An unattributable click cannot be bound to anyone, and guessing would bind + // this account to the wrong person. Nothing is posted: there is no one to + // post it to privately, and the thread is not an option. + if (!clicker?.id) return; + + const thread = interaction.thread; + const sdk = sdkFromEnv(); + if (!sdk) { + await reportStartFailure( + thread, + clicker, + toolkit, + "connected apps are not configured on this deployment (no COMPOSIO_API_KEY)", + "Retrying will not help. Ask whoever runs this deployment to set it.", + ); + return; + } + + let authorization: Authorization | undefined; + let failure: string | undefined; + try { + const session = await sdk.sessions.create(clicker.id, { + toolkits: [toolkit], + // Mandatory. A default session hands the holder remote bash and a Python + // sandbox, which nobody asked for by clicking "Connect Gmail". + workbench: { enable: false }, + }); + authorization = await session.authorize(toolkit); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + // One line per click. The id and the outcome, and deliberately not the link: + // a redirect URL is a bearer capability, so a log line carrying one is a + // credential at rest. + console.log( + `[composio] connect ${toolkit} ` + + `${authorization?.redirectUrl ? "link issued" : "failed"} user=${clicker.id}`, + ); + + if (!authorization?.redirectUrl) { + await reportStartFailure(thread, clicker, toolkit, failure); + return; + } + + // Ephemeral, never `post`. The link binds whoever completes it, so the one + // person who may see it is the one it was minted for. + const delivered = await postPrivately( + thread, + clicker, + `Authorize ${toolkit} here: ${authorization.redirectUrl}`, + ); + + if (!delivered.ok) { + // No private channel, so there is nowhere the link may go. Posting it here + // would let anyone else in the thread open it and bind their own account to + // the clicker's id, which is the one outcome this flow exists to prevent. + await thread.post( + `I could not send you a private message on ${interaction.platform}, and a connect ` + + `link must not be posted where someone else could open it — so I cannot share it ` + + `here. Ask whoever runs this deployment to enable private messages for me.`, + ); + return; + } + + if (delivered.usedFallback) { + await thread.post("📬 I sent you the link as a direct message (only you can open it)."); + } + + // Dropped now rather than on completion, because nothing here waits for the + // round trip. A cached session predating the connect would keep reporting the + // toolkit as unconnected until its TTL expired. + invalidateSession(clicker.id); + // ...and dropped again when the flow actually completes, since the line above + // runs while the user still has the consent screen open. + invalidateOnConnection(authorization, clicker.id); +} + +/** + * No SDK parameter: the card carries no handler to capture one, and the click + * re-derives it from the environment. Only `config` is needed, and only to + * decide which toolkits a person is allowed to connect. + */ +export function createConnectTool(config: ComposioConfig): ChannelTool { + return defineChannelTool({ + name: "connect_my_app", + description: + "Ask the user to connect one of their accounts. Use when search_my_tools reports " + + "a toolkit needs connecting.", + parameters: z.object({ + toolkit: z.string().describe("Toolkit slug, e.g. gmail"), + }), + async handler({ toolkit }, ctx) { + const slug = toolkit.trim().toLowerCase(); + + // Personal toolkits only. A workspace toolkit would connect under the + // clicker's own id while every workspace-scope call resolves under + // `workspaceUserId`, so the account would be created and never used — + // the user authorizes, comes back, and nothing works. + if (config.workspaceToolkits.includes(slug) && !config.userToolkits.includes(slug)) { + return ( + `"${slug}" is a shared team app. Whoever runs this deployment connects it once ` + + `by running \`pnpm composio:connect ${slug}\` on the server; individual users ` + + "do not connect it here." + ); + } + if (!config.userToolkits.includes(slug)) { + return `"${slug}" is not configured on this deployment.`; + } + if (!ctx.actor?.id) { + return "I can't tell who you are on this platform, so I can't connect an account for you."; + } + + await ctx.thread.post(); + + return `Posted a Connect ${slug} card. Stop here — the user connects, then asks again.`; + }, + }); +} diff --git a/app/tools/composio/index.ts b/app/tools/composio/index.ts new file mode 100644 index 0000000..39de626 --- /dev/null +++ b/app/tools/composio/index.ts @@ -0,0 +1,85 @@ +/** + * The Composio feature's single entry point: every tool the agent should see + * for this deployment, or none at all. + * + * Unconfigured means absent, not disabled. Without `COMPOSIO_API_KEY` this + * returns `[]` before anything is constructed — no SDK client, no session + * cache, and no tool the model can see but must not call. + * + * Called once while the Channel is built, so the SDK client and the startup + * warnings happen once per process rather than once per turn. Only `resolve` + * runs per turn, and it is the one thing that depends on who is speaking. + */ +import type { ChannelTool, ChannelToolContext } from "@copilotkit/channels"; +import { composioClient } from "./client.js"; +import { readComposioConfig } from "./config.js"; +import { createConnectTool } from "./connect-tool.js"; +import { createRunTool } from "./run-tool.js"; +import { resolveScopes, startupWarnings } from "./scopes.js"; +import { createSearchTool } from "./search-tool.js"; +import { getSession, type CachedSession } from "./sessions.js"; + +export function composioTools( + env: NodeJS.ProcessEnv, + defaultUserId: string, +): ChannelTool[] { + const config = readComposioConfig(env, defaultUserId); + if (!config) return []; + + for (const warning of startupWarnings(config, env)) console.warn(warning); + + const sdk = composioClient(config); + + /** + * The identities this turn may act as. Resolved per turn because the personal + * scope depends on the actor, and cached per identity by `getSession`, so a + * warm turn costs nothing. + */ + const resolve = async (ctx: ChannelToolContext): Promise => { + const scopes = resolveScopes(config, ctx.actor); + + // `allSettled`, not `all`. A rejected `sessions.create` under `all` loses + // every other scope with it — one unreachable personal account would take + // the team's Linear down for that turn — and rejects `resolve` itself, so + // the turn runs with no Composio tools and no explanation anywhere. + // `search-tool.ts` already draws this line one layer down. + const settled = await Promise.allSettled( + scopes.map((scope) => getSession(sdk, scope.userId, scope.toolkits)), + ); + + const sessions: CachedSession[] = []; + settled.forEach((outcome, index) => { + if (outcome.status === "fulfilled") { + sessions.push(outcome.value); + return; + } + // The scope, so an operator can tell whose account went missing, and the + // provider's reason, so they can tell why. Neither carries a credential: + // the api key never leaves `client.ts`, and unlike a connect link a + // `sessions.create` failure is not itself a bearer capability. + const scope = scopes[index]!; + const reason = + outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); + console.warn( + `[composio] no session for user=${scope.userId} ` + + `toolkits=${scope.toolkits.join(",")} — running the turn without it: ${reason}`, + ); + }); + + return sessions; + }; + + const tools = [ + createSearchTool(resolve), + createRunTool(resolve, config.approvals, config.workspaceUserId), + ]; + + // Connecting is a personal act. With no personal toolkits there is nothing a + // user could connect, and offering the tool would only invite the agent to + // tell someone to connect a shared account it does not own. + if (config.userToolkits.length > 0) { + tools.push(createConnectTool(config)); + } + + return tools; +} diff --git a/app/tools/composio/pending.ts b/app/tools/composio/pending.ts new file mode 100644 index 0000000..f7fb1b3 --- /dev/null +++ b/app/tools/composio/pending.ts @@ -0,0 +1,89 @@ +/** + * Calls awaiting approval. + * + * The card carries only a token. Arguments stay here rather than in the Slack + * action value, which has a size limit and should not carry user data. A token + * missing after a restart is reported as expired rather than guessed at. + */ +import { randomUUID } from "node:crypto"; +import type { Effect } from "./classify.js"; +import type { RawSession } from "./sessions.js"; + +export interface PendingCall { + session: RawSession; + slug: string; + args: Record; + effect: Effect; + /** The identity this call was composed for. */ + userId: string; + workspaceUserId: string; + /** Human-readable label already computed for the card. */ + action: string; +} + +/** + * How many approvals may be outstanding at once. Matching + * `agent/write_confirmation.py`'s `_MAX_TRACKED_FAILURES`, and bounded for the + * same reason: this map outlives every conversation, and each entry pins a live + * `RawSession` plus the call's arguments — which is where the mail bodies are. + * An abandoned card is not worth holding either of those forever. + */ +const MAX_PENDING = 64; + +/** Insertion-ordered by construction, which is what makes eviction oldest-first. */ +const pending = new Map(); + +export function clearPending(): void { + pending.clear(); +} + +/** + * A token is a capability: for a workspace-scope call, holding it is the whole + * of the authorization. So it comes from the CSPRNG — `Math.random()` is + * seeded, unseeded-predictable and worth about 41 bits, none of which is a + * budget to defend a delete with. + */ +export function registerPending(call: PendingCall): string { + const token = `ctr_${randomUUID()}`; + pending.set(token, call); + // Oldest-first, and after the insert so a fresh call is never the casualty. + while (pending.size > MAX_PENDING) { + const oldest = pending.keys().next(); + if (oldest.done) break; + pending.delete(oldest.value); + } + return token; +} + +/** Read and consume. A card cannot be clicked twice into two executions. */ +export function takePending(token: string): PendingCall | undefined { + const call = pending.get(token); + if (call) pending.delete(token); + return call; +} + +/** + * Undo one `takePending`, under the token the posted card already carries. + * + * Only for a read that turned out not to be a decision the reader was allowed + * to make. `registerPending` would mint a fresh token and strand the call: the + * card in the thread still holds the original, so the rightful approver's click + * would read "expired" and the delete they asked for could never run. + * + * Safe against replay because the caller restores before it awaits anything — + * no second click can be dispatched into the window between the take and this. + */ +export function restorePending(token: string, call: PendingCall): void { + pending.set(token, call); +} + +/** + * A workspace-scope call runs against a shared identity, so anyone in the thread + * may approve it. A personal-scope call may only be approved by the person it + * was composed for — otherwise approving someone else's delete would execute + * against the approver's own account. + */ +export function mayApprove(call: PendingCall, clickerId: string | undefined): boolean { + if (call.userId === call.workspaceUserId) return true; + return Boolean(clickerId) && clickerId === call.userId; +} diff --git a/app/tools/composio/run-tool.tsx b/app/tools/composio/run-tool.tsx new file mode 100644 index 0000000..0a45d3b --- /dev/null +++ b/app/tools/composio/run-tool.tsx @@ -0,0 +1,244 @@ +/** + * `run_my_tool` — the execution half of the router, and the approval gate. + * + * Composio's hosted executor would send the call from the model straight to + * Composio, bypassing this file and therefore bypassing approval. That is the + * whole reason this wrapper exists. + * + * Managed Channels cannot block on a choice, so a gated call posts a card and + * ends the turn; the button handler executes and rewrites the card. The model + * therefore never sees a gated call's result — acceptable while `destructive` is + * the default, since the gated case is deletes. + */ +import { z } from "zod"; +import { + defineChannelTool, + type ChannelTool, + type InteractionContext, +} from "@copilotkit/channels"; +import type { ApprovalMode } from "./config.js"; +import { needsApproval, type Effect } from "./classify.js"; +import type { ScopeResolver } from "./search-tool.js"; +import type { CachedSession } from "./sessions.js"; +import { mayApprove, registerPending, restorePending, takePending } from "./pending.js"; +import { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, + type ConfirmDecision, +} from "../../human-in-the-loop/index.js"; + +/** + * What a slug the effect map has never heard of is treated as. + * + * Not `"write"`. The map is filled from `getRawComposioTools({ limit: 300 })`, + * so a real slug past that cap is unmapped through no fault of the model — and + * a hallucinated or prompt-injected slug is unmapped too. `needsApproval( + * "write", "destructive")` is `false`, so calling any of those a write would + * run them unapproved in the mode that ships by default. Gated everywhere but + * `off` is the only reading of "unrecognised" that is actually fail-safe. + * + * A slug that IS classified and merely isn't read-only stays a write. + */ +const UNMAPPED_EFFECT: Effect = "destructive"; + +/** + * Does this scope own the toolkit the slug belongs to? + * + * Composio slugs are `TOOLKIT_REST_OF_NAME` with the toolkit uppercased — + * `GMAIL_SEND_EMAIL`, `GOOGLECALENDAR_EVENTS_LIST` — so the prefix is the only + * thing needed to place a slug the effect map never saw. Which is the case that + * matters: the map is filled with `limit: 300`, and a real slug past that cap + * would otherwise fall to `scopes[0]`, the shared account, which does not carry + * the toolkit at all. + */ +function ownsSlug(scope: CachedSession, slug: string): boolean { + const upper = slug.toUpperCase(); + return scope.toolkits.some((toolkit) => upper.startsWith(`${toolkit.toUpperCase()}_`)); +} + +/** `GMAIL_SEND_EMAIL` -> `Gmail send email`. */ +function actionLabel(slug: string): string { + const words = slug.toLowerCase().split("_").filter(Boolean); + if (words.length === 0) return slug; + return [words[0]![0]!.toUpperCase() + words[0]!.slice(1), ...words.slice(1)].join(" "); +} + +/** + * One line per Composio call. Slug, effect, resolved identity and Composio's own + * `logId` — enough to correlate with their dashboard, and deliberately nothing + * from `args`, which is where the credentials and the mail bodies are. + */ +function logLine( + slug: string, + effect: Effect, + userId: string, + logId: string | undefined, + ok: boolean, +) { + console.log( + `[composio] ${slug} ${ok ? "ok" : "failed"} effect=${effect} user=${userId} log=${logId ?? "none"}`, + ); +} + +/** + * The Approve/Cancel click on a `ConfirmToolRun` card. + * + * Lifted out of the JSX because this, not the tool handler, is where the + * security decisions live: an expired token, the wrong person clicking, and a + * provider failure that arrives as a resolved value rather than a throw. A + * closure inside the card is unreachable from a test; an exported function is + * not. + * + * `cardAction` is the label the card was posted with. It is used only when no + * pending call can be recovered — every other branch reports `call.action`, the + * label of the call actually being decided. + */ +export async function handleToolRunDecision( + interaction: InteractionContext, + cardAction: string, +): Promise { + const decision = interaction.action.value; + // No value at all is not this card's button; there is nothing to report. + if (!decision) return; + + // An empty token names no call, so it gets the same answer as a token whose + // call is gone. Returning silently would leave a live card and no feedback. + const call = decision.token ? takePending(decision.token) : undefined; + if (!call) { + await interaction.thread.update( + interaction.message.ref, + , + ); + return; + } + + if (!mayApprove(call, interaction.actor?.id)) { + // The wrong person clicked; the request still stands. Restored under the + // same token, before any await, so the live card keeps working and no + // second click can slip into the gap. The card is not rewritten either — + // that would strip the buttons the rightful approver needs. + restorePending(decision.token, call); + await interaction.thread.post(`Only the person who asked can approve **${call.action}**.`); + return; + } + + // Anything other than an explicit approval is a decline. The token is already + // consumed, so a declined card cannot be clicked into a run. + if (decision.approved !== true) { + await interaction.thread.update( + interaction.message.ref, + , + ); + return; + } + + // Two ways this fails. `execute` resolves with an `error` when the tool + // itself rejects the call, and throws when the transport does — a timeout, a + // dead session. The token is already consumed either way, so a throw that + // escaped here would leave the card frozen with no result and no way back. + let logId: string | undefined; + let failure: string | undefined; + try { + const result = await call.session.execute(call.slug, call.args); + logId = result?.logId; + failure = result?.error ?? undefined; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + logLine(call.slug, call.effect, call.userId, logId, !failure); + + await interaction.thread.update( + interaction.message.ref, + , + ); +} + +export function createRunTool( + resolve: ScopeResolver, + mode: ApprovalMode, + workspaceUserId: string, +): ChannelTool { + return defineChannelTool({ + name: "run_my_tool", + description: + "Run one action found with search_my_tools. Pass the exact slug and arguments " + + "matching that tool's input schema.", + parameters: z.object({ + slug: z.string().describe("Exact tool slug, e.g. GMAIL_SEND_EMAIL"), + args: z.record(z.unknown()).describe("Arguments matching the tool's input schema"), + }), + async handler({ slug, args }, ctx) { + const scopes = await resolve(ctx); + if (scopes.length === 0) return "Connected apps are not configured for you."; + + // Route to the scope that knows this slug, then to the scope that owns + // its toolkit, and only then to the first. The middle step is not a + // nicety: scopes arrive workspace-first, so without it an unmapped + // personal slug runs against the shared session — which cannot execute it + // — and its approval card binds to `workspaceUserId`, making one person's + // action approvable by anyone in the thread. The final fallback stands so + // a slug belonging to nothing still produces a real provider error rather + // than silence. + const scope = + scopes.find((s) => s.effects.has(slug)) ?? + scopes.find((s) => ownsSlug(s, slug)) ?? + scopes[0]!; + const effect: Effect = scope.effects.get(slug) ?? UNMAPPED_EFFECT; + const action = actionLabel(slug); + // The identity of the scope the slug routed to, not the actor's. They + // differ whenever a person's message runs through the shared workspace + // account, and it is the account being touched that decides who may + // approve — a shared one belongs to the thread, a personal one to its + // owner. + const userId = scope.userId; + + if (needsApproval(effect, mode)) { + const token = registerPending({ + session: scope.session, + slug, + args, + effect, + userId, + workspaceUserId, + action, + }); + + await ctx.thread.post( + , + ); + + return ( + `Posted an approval card for ${action}. Stop here — do not retry or ` + + "explain. The result is reported on the card." + ); + } + + const result = await scope.session.execute(slug, args); + + // execute() does NOT throw on tool failure. A try/catch alone would treat + // every failed write as a success. + if (result?.error) { + logLine(slug, effect, userId, result.logId, false); + return `Tool ${slug} failed: ${result.error}`; + } + + logLine(slug, effect, userId, result?.logId, true); + return result?.data ?? null; + }, + }); +} diff --git a/app/tools/composio/scopes.ts b/app/tools/composio/scopes.ts new file mode 100644 index 0000000..93da6df --- /dev/null +++ b/app/tools/composio/scopes.ts @@ -0,0 +1,93 @@ +/** + * Which Composio identities a turn acts as, and the misconfigurations worth + * saying out loud at startup. + */ +import type { ComposioConfig } from "./config.js"; + +export interface ResolvedScope { + userId: string; + toolkits: string[]; +} + +/** Apps whose data is one person's, not a team's. */ +const PERSONAL_TOOLKITS = new Set(["gmail", "googlecalendar", "outlook", "googledrive"]); + +/** Composio toolkit slug -> the env var enabling the same app over MCP. */ +const MCP_EQUIVALENTS: Record = { + linear: "LINEAR_API_KEY", + notion: "NOTION_MCP_AUTH_TOKEN", + posthog: "POSTHOG_PERSONAL_API_KEY", + github: "GITHUB_PERSONAL_ACCESS_TOKEN", +}; + +/** + * Every applicable scope, not the first match. + * + * A toolkit named in both lists resolves to the personal scope only: routing by + * slug is ambiguous when a slug lives in two sessions, and picking whichever + * loaded first would attribute an issue to a person or to a shared account + * depending on restart order. + * + * That de-duplication is unconditional — it does not depend on the personal + * scope actually resolving. Naming a toolkit in `userToolkits` is the operator + * saying it must run as the person, so an unidentified turn gets no access to + * it rather than falling through to the shared account. + */ +export function resolveScopes( + config: ComposioConfig, + actor: { id?: string } | undefined, +): ResolvedScope[] { + const scopes: ResolvedScope[] = []; + + /** + * The single place a personal identity is admitted. Blank is not an identity: + * `{ id: "" }` and `{}` are as unverified as no actor at all, and matching + * `readEnvironment`'s `?.trim() ||` idiom keeps a whitespace-only id out too. + */ + const actorId = actor?.id?.trim() || undefined; + + const workspaceToolkits = config.workspaceToolkits.filter( + (slug) => !config.userToolkits.includes(slug), + ); + + if (workspaceToolkits.length > 0) { + scopes.push({ userId: config.workspaceUserId, toolkits: workspaceToolkits }); + } + if (actorId !== undefined && config.userToolkits.length > 0) { + scopes.push({ userId: actorId, toolkits: config.userToolkits }); + } + return scopes; +} + +export function startupWarnings(config: ComposioConfig, env: NodeJS.ProcessEnv): string[] { + const warnings: string[] = []; + + for (const slug of new Set(config.workspaceToolkits)) { + if (config.userToolkits.includes(slug)) { + warnings.push( + `[composio] "${slug}" is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS. ` + + "Using each person's own account; the shared one is ignored for this app.", + ); + continue; + } + if (PERSONAL_TOOLKITS.has(slug)) { + warnings.push( + `[composio] "${slug}" is in COMPOSIO_TOOLKITS (shared). Every Slack user will act ` + + "through ONE account. If you meant each person to use their own, move it to " + + "COMPOSIO_USER_TOOLKITS.", + ); + } + } + + for (const slug of new Set([...config.workspaceToolkits, ...config.userToolkits])) { + const mcpVar = MCP_EQUIVALENTS[slug]; + if (!mcpVar || !env[mcpVar]?.trim()) continue; + warnings.push( + `[composio] "${slug}" is configured twice: via Composio and via ${mcpVar}. ` + + "The agent will see two sets of tools for it and may pick either, so whether " + + "an action asks for approval will vary. Remove one to make this predictable.", + ); + } + + return warnings; +} diff --git a/app/tools/composio/search-tool.ts b/app/tools/composio/search-tool.ts new file mode 100644 index 0000000..5fa9ad7 --- /dev/null +++ b/app/tools/composio/search-tool.ts @@ -0,0 +1,161 @@ +/** + * `search_my_tools` — the discovery half of the router. + * + * Binding every tool of every connected toolkit is not viable: gmail alone is + * 63 tools, linear 47, googlecalendar 49. So the model searches instead, and + * gets back a handful of candidates. + * + * Composio's search returns schemas in the same response, so this collapses + * their search -> get_schemas -> execute into search -> execute, and the model + * fills a real JSON schema rather than reconstructing one from prose. + */ +import { z } from "zod"; +import { defineChannelTool, type ChannelTool, type ChannelToolContext } from "@copilotkit/channels"; +import type { CachedSession } from "./sessions.js"; + +/** How many candidates the model sees. Tunable; not a principle. */ +const MAX_RESULTS = 5; + +/** + * Which Composio sessions this turn may act through. An array, because one turn + * can be both the shared team identity and the person who sent the message. + */ +export type ScopeResolver = (ctx: ChannelToolContext) => Promise; + +/** One candidate handed to the model. */ +interface Candidate { + slug: string; + description: string; + /** `null` when the response carried no schema — the model cannot call it. */ + inputSchema: unknown; +} + +/** + * The parts of the search response we read. Every field is optional in + * practice, and the response itself may not be an object at all, so nothing + * here is assumed present or well-typed. + */ +interface SearchResponse { + results?: unknown; + toolSchemas?: unknown; + toolkitConnectionStatuses?: unknown; +} + +interface ToolSchema { + description?: unknown; + inputSchema?: unknown; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asStrings(value: unknown): string[] { + return asArray(value).filter((item): item is string => typeof item === "string"); +} + +/** Every candidate one scope offers, in the order that scope ranked them. */ +function candidatesOf(response: SearchResponse): Candidate[] { + const schemas = asRecord(response.toolSchemas); + const candidates: Candidate[] = []; + + for (const entry of asArray(response.results)) { + const result = asRecord(entry); + // Primary slugs before related ones: that is the scope's own ranking. + const slugs = [...asStrings(result.primaryToolSlugs), ...asStrings(result.relatedToolSlugs)]; + for (const slug of slugs) { + const schema = asRecord(schemas[slug]) as ToolSchema; + candidates.push({ + slug, + description: typeof schema.description === "string" ? schema.description : "", + inputSchema: schema.inputSchema ?? null, + }); + } + } + + return candidates; +} + +/** + * Round-robin across the scopes rather than concatenating them. + * + * Scopes arrive workspace-first, and the cap is global. Concatenating would let + * a chatty shared scope fill all five slots and make the requesting person's own + * apps unreachable — "what's on my calendar" answering with only Linear tools. + * Taking one candidate from each scope in turn keeps every scope represented. + * + * Deduplicated by slug, first occurrence wins. Linear scan on purpose: n is a + * handful, and a Set would buy nothing here. + */ +function interleave(perScope: Candidate[][]): Candidate[] { + const merged: Candidate[] = []; + const deepest = perScope.reduce((max, list) => Math.max(max, list.length), 0); + + for (let rank = 0; rank < deepest; rank++) { + for (const list of perScope) { + const candidate = list[rank]; + if (!candidate) continue; + if (merged.some((t) => t.slug === candidate.slug)) continue; + merged.push(candidate); + } + } + + return merged; +} + +export function createSearchTool(resolve: ScopeResolver): ChannelTool { + return defineChannelTool({ + name: "search_my_tools", + description: + "Find actions available in the connected apps. Call this before run_my_tool. " + + "Returns tool slugs with their input schemas.", + parameters: z.object({ + query: z.string().describe("What you want to do, in plain words, e.g. 'send an email'"), + }), + async handler({ query }, ctx) { + const scopes = await resolve(ctx); + if (scopes.length === 0) return "Connected apps are not configured for you."; + + // One round trip per scope, in parallel. `allSettled` so a scope whose + // session is broken costs only its own candidates, not everyone else's. + const settled = await Promise.allSettled( + scopes.map((scope) => scope.session.search({ query })), + ); + + const perScope: Candidate[][] = []; + const needsConnection: string[] = []; + + for (const outcome of settled) { + if (outcome.status !== "fulfilled") continue; + const response = asRecord(outcome.value) as SearchResponse; + + perScope.push(candidatesOf(response)); + + // Only an explicit `false` means "not connected" — an absent status is + // silence, not something to prompt the user about. + for (const entry of asArray(response.toolkitConnectionStatuses)) { + const status = asRecord(entry); + if (status.hasActiveConnection !== false) continue; + if (typeof status.toolkit !== "string") continue; + if (!needsConnection.includes(status.toolkit)) needsConnection.push(status.toolkit); + } + } + + // A schema-less candidate is uncallable, so it must never displace a + // callable one — but it still ships, so the model can see the tool exists. + const merged = interleave(perScope); + const tools = [ + ...merged.filter((t) => t.inputSchema !== null), + ...merged.filter((t) => t.inputSchema === null), + ]; + + return { tools: tools.slice(0, MAX_RESULTS), needsConnection }; + }, + }); +} diff --git a/app/tools/composio/sessions.ts b/app/tools/composio/sessions.ts new file mode 100644 index 0000000..7f47c41 --- /dev/null +++ b/app/tools/composio/sessions.ts @@ -0,0 +1,114 @@ +/** + * Per-user Composio sessions, cached in process. + * + * Composio stores connected accounts server-side keyed by user id, so a restart + * empties this cache but never requires anyone to re-authenticate. The first + * message from each person after a restart pays one cache miss (~1s measured). + */ +import { effectOf, type Effect } from "./classify.js"; + +/** + * What `session.authorize(toolkit)` hands back. `id` and `status` are carried + * for completeness; the two fields anything reads are the link and the wait. + */ +export interface Authorization { + /** The connect link. A bearer capability — see `connect-tool.tsx`. */ + redirectUrl: string; + id?: string; + status?: string; + /** + * Resolves once the person finishes the browser flow. Optional because a + * session from an older SDK may not offer it, and a caller that only wants + * the link must not depend on it. + * + * Composio's own default wait is roughly a minute — shorter than a person + * spends on a consent screen — so every caller passes its own timeout in ms. + */ + waitForConnection?(timeoutMs?: number): Promise; +} + +/** Structural subset of `@composio/core` we depend on, so tests can fake it. */ +export interface RawSession { + sessionId: string; + search(params: { query: string }): Promise; + execute( + slug: string, + args: Record, + ): Promise<{ data?: unknown; error?: string | null; logId?: string }>; + authorize(toolkit: string): Promise; + toolkits(): Promise<{ + items: Array<{ slug: string; connection?: { isActive?: boolean } }>; + }>; +} + +export interface ComposioSdk { + sessions: { + create(userId: string, options: Record): Promise; + }; + tools: { + getRawComposioTools(params: { + toolkits: string[]; + limit: number; + }): Promise>; + }; +} + +export interface CachedSession { + session: RawSession; + /** + * The Composio identity this session acts as — the shared workspace id, or + * one person's. Carried on the entry because a caller that routed a slug to + * this scope cannot otherwise tell whose account it is about to touch, and + * approval depends on exactly that. + */ + userId: string; + /** tool slug -> effect, derived once per cache fill. */ + effects: Map; + toolkits: string[]; + filledAt: number; +} + +/** Long enough that the hot path is free, short enough that a new app appears. */ +const TTL_MS = 10 * 60 * 1000; + +const cache = new Map(); + +/** One user can hold both a workspace and a personal session, so key on both. */ +function cacheKey(userId: string, toolkits: string[]): string { + return `${userId}::${[...toolkits].sort().join(",")}`; +} + +export function clearSessionCache(): void { + cache.clear(); +} + +export function invalidateSession(userId: string): void { + for (const key of [...cache.keys()]) { + if (key.startsWith(`${userId}::`)) cache.delete(key); + } +} + +export async function getSession( + sdk: ComposioSdk, + userId: string, + toolkits: string[], +): Promise { + const key = cacheKey(userId, toolkits); + const existing = cache.get(key); + if (existing && Date.now() - existing.filledAt <= TTL_MS) return existing; + + // workbench:false is mandatory. A default session exposes remote bash and a + // Python sandbox, which nobody asked for by naming a toolkit. + const session = await sdk.sessions.create(userId, { + toolkits, + workbench: { enable: false }, + }); + + const raw = await sdk.tools.getRawComposioTools({ toolkits, limit: 300 }); + const effects = new Map(); + for (const tool of raw) effects.set(tool.slug, effectOf(tool.tags)); + + const entry: CachedSession = { session, userId, effects, toolkits, filledAt: Date.now() }; + cache.set(key, entry); + return entry; +} diff --git a/package.json b/package.json index 9a04d60..7672242 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "private": true, "description": "OpenTag — an open-source, self-hosted knowledge-work agent with connected tools and generative UI for Slack and Microsoft Teams.", "license": "MIT", + "engines": { + "node": ">=22" + }, "type": "module", "scripts": { "setup:dev": "uv sync --project agent --locked && pnpm exec playwright install chromium", @@ -14,12 +17,14 @@ "start": "tsx server.ts", "runtime": "tsx server.ts", "agent": "cd agent && uv run python main.py", + "composio:connect": "tsx scripts/composio-connect.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", "e2e": "tsx e2e/run.ts" }, "dependencies": { "@ag-ui/client": "^0.0.57", + "@composio/core": "^0.17.0", "@copilotkit/channels": "0.9.0", "@copilotkit/runtime": "1.68.1", "dotenv": "^16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d88b1..79ff470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,15 @@ importers: '@ag-ui/client': specifier: ^0.0.57 version: 0.0.57 + '@composio/core': + specifier: ^0.17.0 + version: 0.17.0(ws@8.21.1)(zod@3.25.76) '@copilotkit/channels': specifier: 0.9.0 version: 0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/runtime': specifier: 1.68.1 - version: 1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + version: 1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -203,6 +206,19 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@composio/client@0.1.0-alpha.76': + resolution: {integrity: sha512-MXC5JGRVdiQ4EgLricy9o/mqBa1+1T7wHFZ6Q4ZJkrjzZqOMvxTgy21Zlb5J/1oGkB2bg9UDzpH8PkXCp/D4zA==} + + '@composio/core@0.17.0': + resolution: {integrity: sha512-HJzpKWgise3piltWorEHt07nrjUR3gPZnBmrFgnJXx5+6AiKn79YGBYLytdtuMscElLt/7pHOIxfcaYpiGkJ7A==} + peerDependencies: + zod: '>=3.25.76 <5' + + '@composio/json-schema-to-zod@0.3.0': + resolution: {integrity: sha512-Lgz5QlclPsd9mo8FGn/12h3cwTpN6A9aYvucO1umjnuvyetb6nC//nHEu9TDU+BN5Sa0n+qmUUv4Tzd4wI4xeA==} + peerDependencies: + zod: '>=3.25.76 <5' + '@copilotkit/channels-core@0.9.0': resolution: {integrity: sha512-bCWLb/jb9j8O+JvOvf/jkJ/Nb8B09U/tAdTCQ221mE2AEHS2dn5PMlQih9gqyF6kbJFO0Nmf+IDTritWswmfaA==} peerDependencies: @@ -1508,6 +1524,9 @@ packages: is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-fs-case-sensitive@2.0.0: + resolution: {integrity: sha512-JoCsyGITdYPM+pUbeMQ4IiEuQ4wjPdeWORlG7n644isewbcxiQIr+9gmF5k7UabTP/jLBRkbgydEamR7JZBKHA==} + is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -1828,8 +1847,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openai@6.47.0: - resolution: {integrity: sha512-xYr+R9woSzWxVxeiqkkNbHhv89tZDEI6eBMbrdPnv3poh+mijHvbhS35a+3o6xHa411/ns8j5ENY3So9DCXWYw==} + openai@7.5.0: + resolution: {integrity: sha512-ZbDBz8FSB8Mv8fFYIUvzTFMdV5vl93/octp1MdtK2lfYepSpfv/ewmeugpKz/cwGtFSx+YuUM4NwpZ2P55YiPA==} + engines: {node: '>=22.0.0'} peerDependencies: '@aws-sdk/credential-provider-node': '>=3.972.0 <4' '@smithy/hash-node': '>=4.3.0 <5' @@ -1956,6 +1976,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pusher-js@8.6.0: + resolution: {integrity: sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -2171,6 +2194,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-graphql@2.0.0-rc.1: resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==} engines: {node: '>= 18.12.0'} @@ -2429,14 +2455,14 @@ snapshots: '@ag-ui/core': 0.0.57 '@ag-ui/proto': 0.0.57 - '@ag-ui/langgraph@0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': + '@ag-ui/langgraph@0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': dependencies: '@ag-ui/a2ui-toolkit': 0.0.4 '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) partial-json: 0.1.7 rxjs: 7.8.2 transitivePeerDependencies: @@ -2597,6 +2623,30 @@ snapshots: '@cfworker/json-schema@4.1.1': {} + '@composio/client@0.1.0-alpha.76': {} + + '@composio/core@0.17.0(ws@8.21.1)(zod@3.25.76)': + dependencies: + '@composio/client': 0.1.0-alpha.76 + '@composio/json-schema-to-zod': 0.3.0(zod@3.25.76) + '@types/json-schema': 7.0.15 + is-fs-case-sensitive: 2.0.0 + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) + picocolors: 1.1.1 + pusher-js: 8.6.0 + semver: 7.8.5 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + + '@composio/json-schema-to-zod@0.3.0(zod@3.25.76)': + dependencies: + zod: 3.25.76 + '@copilotkit/channels-core@0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 @@ -2759,13 +2809,13 @@ snapshots: '@copilotkit/license-verifier@0.5.0': {} - '@copilotkit/runtime@1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/runtime@1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2) '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 '@ag-ui/encoder': 0.0.57 - '@ag-ui/langgraph': 0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@ag-ui/langgraph': 0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@ag-ui/mcp-middleware': 0.0.1(@cfworker/json-schema@4.1.1)(rxjs@7.8.2)(zod@3.25.76) '@ai-sdk/anthropic': 3.0.97(zod@3.25.76) @@ -2779,7 +2829,7 @@ snapshots: '@copilotkit/shared': 1.68.1(@ag-ui/core@0.0.57) '@graphql-yoga/plugin-defer-stream': 3.21.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@hono/node-server': 1.19.14(hono@4.12.30) - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@remix-run/node-fetch-server': 0.13.3 '@scarf/scarf': 1.4.0 @@ -2805,9 +2855,9 @@ snapshots: ws: 8.21.1 zod: 3.25.76 optionalDependencies: - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@opentelemetry/api' @@ -3093,12 +3143,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': + '@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) mustache: 4.2.0 p-queue: 6.6.2 zod: 3.25.76 @@ -3109,23 +3159,23 @@ snapshots: - openai - ws - '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': + '@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@langchain/protocol': 0.0.18 '@types/json-schema': 7.0.15 p-queue: 9.3.1 p-retry: 7.1.1 - '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76)': + '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76)': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) '@langchain/protocol': 0.0.18 '@standard-schema/spec': 1.1.0 zod: 3.25.76 @@ -4178,6 +4228,8 @@ snapshots: is-electron@2.2.2: {} + is-fs-case-sensitive@2.0.0: {} + is-network-error@1.3.2: {} is-promise@4.0.0: {} @@ -4240,12 +4292,12 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): + langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph': 1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph': 1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) zod: 3.25.76 transitivePeerDependencies: - '@opentelemetry/api' @@ -4258,12 +4310,12 @@ snapshots: - vue - ws - langsmith@0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): + langsmith@0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): dependencies: p-queue: 6.6.2 optionalDependencies: '@opentelemetry/api': 1.9.1 - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) ws: 8.21.1 libphonenumber-js@1.13.8: {} @@ -4424,11 +4476,10 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.47.0(ws@8.21.1)(zod@3.25.76): + openai@7.5.0(ws@8.21.1)(zod@3.25.76): optionalDependencies: ws: 8.21.1 zod: 3.25.76 - optional: true p-finally@1.0.0: {} @@ -4544,6 +4595,10 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pusher-js@8.6.0: + dependencies: + tweetnacl: 1.0.3 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -4797,6 +4852,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@1.0.3: {} + type-graphql@2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.14.2))(graphql@16.14.2): dependencies: '@graphql-yoga/subscription': 5.0.5 diff --git a/scripts/composio-connect.ts b/scripts/composio-connect.ts new file mode 100644 index 0000000..0d3c73c --- /dev/null +++ b/scripts/composio-connect.ts @@ -0,0 +1,141 @@ +/** + * Connect a shared team toolkit — the operator-side half of the Composio + * integration. + * + * Composio scopes every connected account to a `user_id`, and shared-scope + * calls resolve under `COMPOSIO_WORKSPACE_USER_ID` (defaulting to + * `INTELLIGENCE_CHANNEL_NAME`). The dashboard's "connect my account" button + * binds to the dashboard's own user id, which OpenTag never passes, so an + * account created that way is invisible to the bot forever. This script mints a + * Connect Link against the workspace identity instead, which is the only way a + * shared toolkit becomes usable. + * + * Personal toolkits are not connected here: those go through the Connect card + * in a thread, minted per person. + * + * Runs standalone — no Slack runtime, no agent, no server. + * + * pnpm composio:connect linear + */ +import "dotenv/config"; +import { Composio } from "@composio/core"; +import { DEFAULT_INTELLIGENCE_CHANNEL_NAME } from "../app/env.js"; +import { readComposioConfig } from "../app/tools/composio/config.js"; +import { + resolveSharedToolkit, + selectAuthConfig, + type AuthConfigSummary, +} from "../app/tools/composio/connect-link.js"; + +/** Every exit from here is a failure with a fix in it. */ +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +/** + * Walk `authConfigs.list()` to the end. + * + * The listing is paginated and a project with many toolkits overflows one page, + * so stopping at page one would report "no auth config" for a toolkit that has + * one — the single most misleading thing this script could say. + */ +async function listAuthConfigs( + composio: Composio, + toolkit: string, +): Promise { + const collected: AuthConfigSummary[] = []; + let cursor: string | undefined; + + do { + const page = await composio.authConfigs.list({ toolkit, ...(cursor ? { cursor } : {}) }); + collected.push(...page.items); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + return collected; +} + +async function main(): Promise { + // Same reader the runtime uses, so the defaults and validation an operator is + // debugging are the ones that actually apply at boot. + const config = readComposioConfig( + process.env, + process.env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, + ); + if (!config) { + fail( + "Composio is not configured. Set COMPOSIO_API_KEY and at least one of " + + "COMPOSIO_TOOLKITS / COMPOSIO_USER_TOOLKITS in your .env, then run this again.", + ); + } + + const toolkit = resolveSharedToolkit(config, process.argv[2]); + if (!toolkit.ok) fail(toolkit.message); + const slug = toolkit.value; + + const composio = new Composio({ + apiKey: config.apiKey, + allowTracking: false, + disableVersionCheck: true, + }); + + let candidates: AuthConfigSummary[]; + try { + candidates = await listAuthConfigs(composio, slug); + } catch (error) { + // The message may name the endpoint but never the key — the SDK does not + // echo it, and nothing here adds it. + fail( + `Could not list auth configs for "${slug}": ${describe(error)}\n` + + "Check COMPOSIO_API_KEY is valid for the project you expect.", + ); + } + + const authConfig = selectAuthConfig(slug, config.authConfigs[slug], candidates); + if (!authConfig.ok) fail(authConfig.message); + + let redirectUrl: string | undefined; + try { + const request = await composio.connectedAccounts.link(config.workspaceUserId, authConfig.value); + redirectUrl = request.redirectUrl ?? undefined; + } catch (error) { + fail(`Could not create a Connect Link for "${slug}": ${describe(error)}`); + } + + if (!redirectUrl) { + fail( + `Composio returned no redirect URL for "${slug}" (auth config ${authConfig.value}). ` + + `That auth config may not use a browser flow — check it at https://app.composio.dev.`, + ); + } + + // Printing the link is this script's whole purpose and it goes to the + // operator's own terminal, but it is a bearer capability: whoever opens it + // binds *their* account to the shared identity below. + console.log( + [ + `Connect ${slug} for the shared workspace identity.`, + ``, + ` toolkit: ${slug}`, + ` auth config: ${authConfig.value}`, + ` binds to: ${config.workspaceUserId} (COMPOSIO_WORKSPACE_USER_ID)`, + ``, + `Open this link yourself, in a browser signed in to the account the whole team`, + `should act through. Do not forward it — whoever completes it is the account`, + `every shared ${slug} call will run as.`, + ``, + redirectUrl, + ``, + `Then restart the runtime and ask the agent for ${slug}.`, + ].join("\n"), + ); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +main().catch((error: unknown) => { + fail(describe(error)); +}); diff --git a/setup.md b/setup.md index 7826442..66967d8 100644 --- a/setup.md +++ b/setup.md @@ -47,6 +47,11 @@ uv sync cd .. ``` +Node 22 is a floor, not a preference. `package.json` declares +`engines: { node: ">=22" }` because `@composio/core` pulls `openai@7`, which +requires it. On Node 20 you are stopped at install rather than at runtime, which +is the point. + `@copilotkit/channels` and `@copilotkit/runtime` are intentionally pinned. [`package.json`](./package.json) is the single source of truth for both versions; this file does not restate them, because a hand-copied pin drifts on @@ -146,6 +151,12 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `PORT` | No | Channel HTTP port; defaults to `3000` | | `LOG_LEVEL` | No | Defaults to `error`; use `debug` to see Channel lifecycle breadcrumbs | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | +| `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | +| `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | +| `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message | +| `COMPOSIO_APPROVALS` | No | `off`, `destructive` (default), or `writes`. An unrecognized value fails startup | +| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as; defaults to `INTELLIGENCE_CHANNEL_NAME` | +| `COMPOSIO_AUTH_CONFIGS` | No | **Read only by `pnpm composio:connect`, never by the runtime.** `toolkit:auth_config_id` pairs, ids case-sensitive; pins which auth config a *shared* toolkit connects against when it has several | The API key selects a project; the Channel name selects a Channel inside it. When `INTELLIGENCE_LEARNING_CONTAINER_ID` is set, it must name an existing @@ -279,6 +290,9 @@ Before a Linear or Notion mutation reaches MCP, a Python interceptor emits graph with the user's decision. The MCP handler runs only after approval. Reads and UI rendering are never gated. +Composio calls are gated separately and differently — a second card, its own +`COMPOSIO_APPROVALS` dial, and no graph to resume. See [Composio](#composio). + ## Optional sources ### Tavily @@ -333,6 +347,230 @@ Notion is optional and remote-only, not a separate Railway service. Set both discovers the tools. If either value is absent OpenTag skips Notion without blocking startup. +### Composio + +Composio adds a toolkit — Gmail, Linear, Jira, Google Calendar, Salesforce — +without a new MCP block, a `preserve()` line, or a matching test assertion. It +lives in the Node runtime, not the Python agent, because a verified user +identity exists only there: `ChannelToolContext` carries a resolved actor, and +`Thread.runAgent()` has no way to hand a structured user id to the agent. + +Setup is **two steps per app**, not one: + +1. Add the toolkit at . That creates its auth config. +2. Add its slug to `COMPOSIO_TOOLKITS` or `COMPOSIO_USER_TOOLKITS`. For a + **shared** toolkit, also run `pnpm composio:connect ` once and open the + link it prints — that needs no running runtime, so do it before you restart. + Nobody in Slack can do it for you, and the dashboard cannot either; see + [Shared team accounts versus personal + ones](#shared-team-accounts-versus-personal-ones). Personal toolkits skip it + — each user connects their own from a thread. +3. Restart the runtime, once. + +**The slug is the tricky part.** It is Composio's own, lowercase and unspaced: +Google Calendar is `googlecalendar`, not `google-calendar` or `gcal`. Take it +from the toolkit's page URL at (`/toolkit/gmail`), or +from the Toolkits list in their docs. A typo is **silent** — OpenTag does not +validate slugs against Composio at startup, so a misspelled toolkit is simply a +configured toolkit that never appears: the agent has no tools for it and +`search_my_tools` never mentions it. If an app you configured seems absent, +check the spelling before anything else. + +Adding Salesforce six months later is those same steps — no code, no test +change, no edit to `.railway/railway.ts` or the CDK stack. It is not zero-touch: +step 1 is a person in a dashboard and step 3 is a restart. + +`COMPOSIO_API_KEY` is the master switch. Without it nothing is constructed — no +SDK client, no session, no tool the model can see but must not call. A key with +both toolkit lists empty is equally inert. + +Composio is deliberately local-first. Its variables are not declared in +[`.railway/railway.ts`](./.railway/railway.ts) or in +[`deployment/aws/`](./deployment/aws), so a value set by hand on the Railway +`runtime` service is not carried across an IaC apply. Add a `preserve()` line +before you rely on it in a deployment. + +#### Shared team accounts versus personal ones + +`COMPOSIO_TOOLKITS` runs every Slack user through **one** connection, under the +Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` (defaulting to +`INTELLIGENCE_CHANNEL_NAME`). That is right for the team's Linear or Jira. + +`COMPOSIO_USER_TOOLKITS` scopes to the person speaking, keyed by their verified +platform actor id. You ask about "my calendar" and get yours; your colleague +gets theirs. A turn with no resolvable actor gets no personal tools at all and +never falls back to the shared identity. + +Both lists may be set at once, and one turn can use both. + +How an account actually gets connected differs by list, and this is where the +surprises are: + +- **Personal.** The agent posts a public **Connect** card carrying no link. + Whoever clicks it receives a one-time link privately, minted for them; someone + else clicking the same card connects their own account. A pre-minted link + posted in a channel would let whoever opens it bind their own mailbox to + another person's identity, so the link never appears in the thread. Where + there is no private channel — the Teams adapter implements no ephemeral post — + OpenTag says it cannot deliver the link rather than posting it publicly. +- **Shared.** There is no in-Slack path, by design. `connect_my_app` refuses a + shared toolkit, because the account would be created under the clicker's own + id while every shared call resolves under `COMPOSIO_WORKSPACE_USER_ID` — the + user would authorize, come back, and find nothing works. The operator + connects it once, from a terminal: + + ```bash + pnpm composio:connect linear + ``` + + It reads your local `.env`, mints a Composio Connect Link bound to the + resulting `COMPOSIO_WORKSPACE_USER_ID`, and prints it. Open that link + yourself, in a browser signed in to the account the whole team should act + through. The link is a bearer capability: whoever completes it *is* the + account every shared call runs as, so do not forward it. + + **The identity must match the deployed one.** `COMPOSIO_WORKSPACE_USER_ID` + defaults to `INTELLIGENCE_CHANNEL_NAME`, and Composio variables are set by + hand on the Railway `runtime` service (above) — so a local `.env` with a + different Channel name binds the connection to a `user_id` production never + looks up. Nothing errors: you authorize successfully, deploy, and the bot + still says the toolkit needs connecting. Set `COMPOSIO_WORKSPACE_USER_ID` + explicitly to the same value in both places, and confirm the `binds to:` line + the script prints is the one production uses. + + The script needs no running runtime — no `pnpm dev`, no agent — and never + guesses which auth config to use. A slug that is not in `COMPOSIO_TOOLKITS`, a + toolkit with no auth config yet, and a toolkit with several auth configs and + no pin each exit non-zero naming the fix. In the last case, pin one with + `COMPOSIO_AUTH_CONFIGS=linear:ac_...` and run it again; a pin is taken as + given and is not checked against the project, so a wrong id fails at Composio + rather than here. + +**"Connect my account" in the Composio dashboard is a test button.** It binds +the connection to the dashboard's own user id, which OpenTag never passes, so +the bot cannot see it and nobody on your team needs to touch it. Use +`pnpm composio:connect` instead — a shared toolkit is connected only when a +connected account exists under the exact value of `COMPOSIO_WORKSPACE_USER_ID`, +and that script is the only thing that creates one. + +Per-user isolation was verified against the live API, not assumed: with two +active connected accounts in a project, sessions created for four other ids all +reported every toolkit unconnected. Connections do not leak across identities. + +#### Approvals + +`COMPOSIO_APPROVALS` decides what stops for a human: + +| Mode | Behavior | +| --- | --- | +| `off` | Never asks. | +| `destructive` | **Default.** Asks only before tools Composio tags `destructiveHint`. | +| `writes` | Asks before anything not tagged `readOnlyHint` — how the Linear and Notion MCP integrations already behave. | + +Measured coverage: gmail exposes 63 tools of which 9 are destructive, linear 47 +of which 3, googlecalendar 49 of which 8. So `destructive` gates deletes and +their neighbours and leaves everything else silent. A slug OpenTag cannot +classify — past the 300-tool listing cap, or invented by the model — is treated +as destructive, so it is gated in every mode but `off`. + +A gated call runs in **two turns**, and that is worth understanding before you +set `writes`. The managed Intelligence adapter reports +`supportsBlockingChoice: false`, so nothing can block waiting for a click; the +tool posts a card and returns "stop here", and the Approve button executes and +rewrites the card in place. The consequence: **the model never sees the result +of a gated call** and cannot summarize or chain off it. Fine for a delete, where +"Done." on the card is the entire answer. On `writes`, every write becomes a +conversational dead end. + +**An approval card shows the arguments, in the channel.** Not the tool name — +the values. Every non-empty argument becomes a labelled row: recipients, the +subject, the body, up to 300 characters per row and 12 rows before the rest is +counted and elided. So gating "email that supplier from my Gmail" posts the +draft where everyone in the thread can read it, and the same is true of a +calendar invite's guest list or an issue's description. + +That is deliberate and it is not going to change: an approver who cannot see +what they are approving is a rubber stamp, and the card is the only place the +arguments are ever shown. But it is worth weighing before you put personal +toolkits behind approvals, because the person whose mailbox it is may not expect +the thread to see the draft. The dial is `COMPOSIO_APPROVALS`, and it is +all-or-nothing: on the `destructive` default only deletes are carded, so a sent +mail posts nothing; on `writes` every write is carded, arguments and all. There +is no per-toolkit or per-channel setting. Arguments are never written to logs +and never travel in the Slack button payload — the card is the only place they +appear. + +Each gated call posts its own card; a batch is not one card. A personal-scope +call may be approved only by the person it was composed for — otherwise Bob +approving Alice's delete would run against Bob's account — while a shared-scope +call may be approved by anyone in the thread. Pending calls live in memory, so a +card clicked after a restart answers `This approval expired — the bot restarted. +Ask again.` instead of executing or hanging. + +#### The three startup warnings + +OpenTag prints these once at boot and keeps running: + +- A personal-shaped app (`gmail`, `googlecalendar`, `outlook`, `googledrive`) in + `COMPOSIO_TOOLKITS`. Every Slack user will act through one mailbox. + Occasionally correct — a shared `support@` — usually a mistake. +- The same slug in both lists. The personal account wins and the shared entry is + dropped for that toolkit; on a turn with no resolvable actor, neither scope + offers it. +- A toolkit also configured over MCP — `LINEAR_API_KEY`, + `NOTION_MCP_AUTH_TOKEN`, `POSTHOG_PERSONAL_API_KEY`, or + `GITHUB_PERSONAL_ACCESS_TOKEN`. The agent then sees two complete tool sets for + that app with different approval behavior and may pick either, so whether an + action asks for approval varies per turn. Remove one. + +#### What Google shows your users + +Consent screens say **Composio**, not OpenTag. The Connect card says so up +front, because the alternative is a user deciding the bot is phishing them. + +Composio-managed auth is the default and the right choice. Self-branding is +**worse** until Google verification is finished: without it users get "Google +hasn't verified this app → Advanced → (unsafe)", which is a scarier screen than +a correctly named third party. Verification for restricted Gmail scopes is a +security assessment, not a form. + +Two consequences of managed auth: + +- All of one person's Composio connections share **one** Google OAuth grant. + Scopes accumulate across toolkits and the consent screen shows the union — + observed live as "Composio already has access to 12 capabilities". Revoking + Composio in Google account settings disconnects **every** app at once. +- Managed auth requests Composio's default scopes. You do not pick them. + +A Google Workspace that allowlists third-party apps can block the flow before it +ever reaches OpenTag. That is an admin action in Google, not in Composio, and +the symptom is a user who never gets past the consent screen. + +#### Restarts, caching, and logs + +OpenTag stores no credentials. Composio holds connected accounts server-side +keyed by `user_id`, so **a restart asks nobody to reconnect** — it empties an +in-process cache and nothing else. A cold session costs roughly a second +(session creation plus the tool listing); afterwards it is free. A cached +session carries a 10-minute TTL, and connecting an account drops that person's +entry outright — session, tool listing, and classification together — so the +next turn sees the new connection instead of waiting out the TTL. + +Every Composio call logs one line: slug, effect, the resolved `user_id`, and +Composio's own `logId` for correlating against their dashboard. Never the +arguments — that is where the mail bodies are. `execute()` does not throw on +tool failure, so a failed write returns its error verbatim rather than reading +as a success. + +Composio's remote sandbox — the tools slugged `COMPOSIO_REMOTE_BASH_TOOL` and +`COMPOSIO_REMOTE_WORKBENCH`, remote shell and Python; not environment variables +— is disabled on every +session without exception. A default session hands both out with no opt-in, and +OpenTag already has a sandbox in `agent/coding/` behind its own credentials. + +[`docs/composio-tools-design.md`](./docs/composio-tools-design.md) records the +design and the API findings verified against the live service. + ## Railway The IaC file declares exactly: @@ -347,7 +585,8 @@ Production Intelligence URLs are literal configuration, the API key is preserved, and the Channel name is `open-tag`. `AGENT_DISPLAY_NAME` is preserved independently on both services and must match when overridden. `OPENAI_API_KEY` is required on `agent`; Tavily, Daytona/coder, GitHub, PostHog, Linear, and the -paired remote Notion variables are optional preserved settings. +paired remote Notion variables are optional preserved settings. The `COMPOSIO_*` +variables are deliberately absent — see [Composio](#composio). Evaluate the configuration locally without applying it: