@frockbot/plugin-composio 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/frockbot.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "id": "composio",
4
+ "displayName": "Composio",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "contributions": {
8
+ "backend": [
9
+ { "entry": "./backend", "host": "gateway" },
10
+ { "entry": "./user-configuration", "host": "user" }
11
+ ],
12
+ "runtime": { "entry": "./agent" }
13
+ },
14
+ "permissions": ["connections:manage", "tools:execute"],
15
+ "configuration": {
16
+ "settings": [],
17
+ "connectionTypes": [
18
+ {
19
+ "id": "gmail",
20
+ "displayName": "Gmail",
21
+ "allowMultiple": true,
22
+ "authorization": { "kind": "grant", "driverId": "composio" },
23
+ "capabilities": ["gmail-tools"]
24
+ }
25
+ ],
26
+ "capabilities": [
27
+ {
28
+ "id": "gmail-tools",
29
+ "kind": "tool",
30
+ "connectionTypes": ["gmail"]
31
+ }
32
+ ]
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,14 +1,45 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-composio",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./client": "./src/composio-client.ts",
9
+ "./backend": "./src/backend.ts",
10
+ "./backend-contracts": "./src/backend-contracts.ts",
11
+ "./user-configuration": "./src/user-configuration.ts",
12
+ "./agent": "./src/agent.ts",
13
+ "./manifest": "./src/manifest.ts",
14
+ "./frockbot.json": "./frockbot.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "frockbot": {
18
+ "manifest": "./frockbot.json"
19
+ },
20
+ "scripts": {
21
+ "test": "bun test src",
22
+ "typecheck": "tsc --noEmit -p tsconfig.json"
23
+ },
24
+ "dependencies": {
25
+ "@frockbot/configuration-core": "0.1.1",
26
+ "@frockbot/connection-core": "0.1.1",
27
+ "@frockbot/kernel-contracts": "0.1.1",
28
+ "@frockbot/plugin-settings": "0.1.1",
29
+ "@frockbot/plugin-tools": "0.1.1",
30
+ "cordis": "4.0.0-rc.8"
31
+ },
32
+ "devDependencies": {
33
+ "@cloudflare/workers-types": "latest",
34
+ "@types/bun": "1.3.6",
35
+ "typescript": "^7.0.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
6
40
  "repository": {
7
41
  "type": "git",
8
42
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
43
  "directory": "packages/plugin-composio"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
44
  }
14
45
  }
@@ -0,0 +1,207 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { type SessionEvent, SessionStore } from "@frockbot/kernel-contracts";
3
+ import { ToolRegistry } from "@frockbot/plugin-tools";
4
+ import { Context } from "cordis";
5
+ import { createComposioRouterPlugin } from "./agent.js";
6
+ import { ComposioClient } from "./composio-client.js";
7
+
8
+ const roots: Context[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
12
+ });
13
+
14
+ describe("Composio router Plugin", () => {
15
+ test("refuses invented slugs until search returns the exact tool", async () => {
16
+ const calls: string[] = [];
17
+ let authorizationCalls = 0;
18
+ const client = new ComposioClient({
19
+ apiKey: "secret",
20
+ fetch: (input) => {
21
+ const url = String(input);
22
+ calls.push(url);
23
+ if (url.includes("/tools?")) {
24
+ return Promise.resolve(
25
+ Response.json({
26
+ items: [
27
+ {
28
+ slug: "GMAIL_FETCH_EMAILS",
29
+ name: "Fetch emails",
30
+ description: "Fetch Gmail messages",
31
+ },
32
+ ],
33
+ }),
34
+ );
35
+ }
36
+ return Promise.resolve(Response.json({ data: { messages: [] } }));
37
+ },
38
+ });
39
+ const root = new Context();
40
+ roots.push(root);
41
+ await root.plugin(SessionStore);
42
+ await root.plugin(ToolRegistry);
43
+ await root.plugin(
44
+ createComposioRouterPlugin({
45
+ client,
46
+ userId: "user-1",
47
+ toolkitSlug: "gmail",
48
+ authorizeEffect: () => {
49
+ authorizationCalls += 1;
50
+ return Promise.resolve({
51
+ connectedAccountId: "ca_123",
52
+ toolkitSlug: "gmail",
53
+ });
54
+ },
55
+ }),
56
+ );
57
+ const context = {
58
+ botId: "primary",
59
+ agentId: "primary",
60
+ compositionGenerationId: "bootstrap",
61
+ turnType: "chat" as const,
62
+ sessionId: "user-1:primary",
63
+ effectId: "tool:1:1:0",
64
+ signal: new AbortController().signal,
65
+ };
66
+
67
+ const invented = await root.tools.prepare(
68
+ {
69
+ id: "invented",
70
+ name: "composio_execute_tool",
71
+ input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
72
+ },
73
+ context,
74
+ );
75
+ if (invented.kind !== "ready") throw new Error("execute tool was denied");
76
+ expect(await root.tools.executePrepared(invented, context)).toMatchObject({
77
+ isError: true,
78
+ });
79
+ expect(calls).toEqual([]);
80
+
81
+ const search = await root.tools.prepare(
82
+ {
83
+ id: "search",
84
+ name: "composio_search_tools",
85
+ input: { query: "fetch emails" },
86
+ },
87
+ context,
88
+ );
89
+ if (search.kind !== "ready") throw new Error("search tool was denied");
90
+ expect(await root.tools.executePrepared(search, context)).toMatchObject({
91
+ isError: false,
92
+ });
93
+
94
+ const exact = await root.tools.prepare(
95
+ {
96
+ id: "exact",
97
+ name: "composio_execute_tool",
98
+ input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
99
+ },
100
+ context,
101
+ );
102
+ if (exact.kind !== "ready") throw new Error("execute tool was denied");
103
+ expect(await root.tools.executePrepared(exact, context)).toMatchObject({
104
+ isError: false,
105
+ });
106
+ expect(calls).toHaveLength(2);
107
+ expect(authorizationCalls).toBe(2);
108
+ });
109
+
110
+ test("restores searched slug authorization from the durable session", async () => {
111
+ const timestamp = "2026-08-28T00:00:00.000Z";
112
+ const durableEvents = [
113
+ {
114
+ type: "session/created",
115
+ createdAt: timestamp,
116
+ },
117
+ {
118
+ type: "assistant/message",
119
+ turn: 1,
120
+ step: 1,
121
+ requestId: "search-request",
122
+ text: "",
123
+ toolCalls: [
124
+ {
125
+ id: "search-before-eviction",
126
+ name: "composio_search_tools",
127
+ input: {},
128
+ },
129
+ ],
130
+ },
131
+ {
132
+ type: "tool/call",
133
+ turn: 1,
134
+ step: 1,
135
+ occurrenceId: "tool:1:1:0",
136
+ name: "composio_search_tools",
137
+ input: {},
138
+ },
139
+ {
140
+ type: "tool/result",
141
+ turn: 1,
142
+ step: 1,
143
+ occurrenceId: "tool:1:1:0",
144
+ name: "composio_search_tools",
145
+ content: JSON.stringify([
146
+ {
147
+ slug: "GMAIL_FETCH_EMAILS",
148
+ name: "Fetch emails",
149
+ description: "Fetch Gmail messages",
150
+ },
151
+ ]),
152
+ isError: false,
153
+ status: "completed",
154
+ },
155
+ ].map((event, seq) => ({ ...event, seq, timestamp })) as SessionEvent[];
156
+ const calls: string[] = [];
157
+ const client = new ComposioClient({
158
+ apiKey: "secret",
159
+ fetch: (input) => {
160
+ calls.push(String(input));
161
+ return Promise.resolve(Response.json({ data: { messages: [] } }));
162
+ },
163
+ });
164
+ const root = new Context();
165
+ roots.push(root);
166
+ await root.plugin(SessionStore, {
167
+ initialSessions: { "resumed-session": durableEvents },
168
+ });
169
+ root.sessions.create("resumed-session");
170
+ await root.plugin(ToolRegistry);
171
+ await root.plugin(
172
+ createComposioRouterPlugin({
173
+ client,
174
+ userId: "user-1",
175
+ toolkitSlug: "gmail",
176
+ authorizeEffect: () =>
177
+ Promise.resolve({
178
+ connectedAccountId: "ca_123",
179
+ toolkitSlug: "gmail",
180
+ }),
181
+ }),
182
+ );
183
+ const context = {
184
+ botId: "primary",
185
+ agentId: "primary",
186
+ compositionGenerationId: "bootstrap",
187
+ turnType: "chat" as const,
188
+ sessionId: "resumed-session",
189
+ effectId: "tool:1:1:1",
190
+ signal: new AbortController().signal,
191
+ };
192
+ const execution = await root.tools.prepare(
193
+ {
194
+ id: "execute-after-eviction",
195
+ name: "composio_execute_tool",
196
+ input: { toolSlug: "GMAIL_FETCH_EMAILS", arguments: {} },
197
+ },
198
+ context,
199
+ );
200
+ if (execution.kind !== "ready") throw new Error("execute tool was denied");
201
+
202
+ await expect(
203
+ root.tools.executePrepared(execution, context),
204
+ ).resolves.toMatchObject({ isError: false });
205
+ expect(calls).toHaveLength(1);
206
+ });
207
+ });
package/src/agent.ts ADDED
@@ -0,0 +1,227 @@
1
+ import { type ToolDefinition } from "@frockbot/kernel-contracts";
2
+ import type { Context, Plugin } from "cordis";
3
+ import { ComposioClient } from "./composio-client.js";
4
+
5
+ export interface ComposioToolDeclaration {
6
+ slug: string;
7
+ name: string;
8
+ description: string;
9
+ inputSchema: Record<string, unknown>;
10
+ version?: string;
11
+ }
12
+
13
+ export interface ComposioPluginConfig {
14
+ client: ComposioClient;
15
+ userId: string;
16
+ connectedAccountId: string;
17
+ tools: ComposioToolDeclaration[];
18
+ }
19
+
20
+ function isObject(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ export interface ComposioRouterPluginConfig {
25
+ client: ComposioClient;
26
+ userId: string;
27
+ toolkitSlug: string;
28
+ authorizeEffect(): Promise<{
29
+ connectedAccountId: string;
30
+ toolkitSlug: string;
31
+ }>;
32
+ }
33
+
34
+ export function createConfiguredComposioRuntimeContribution(config: {
35
+ assignment: {
36
+ packageId: string;
37
+ capabilityId: string;
38
+ connectionId?: string;
39
+ state: string;
40
+ };
41
+ userId: string;
42
+ readSecret(name: string): string | undefined;
43
+ authorizeConnection(): Promise<{ safeMetadata: Record<string, unknown> }>;
44
+ }): Plugin.Function | undefined {
45
+ if (
46
+ config.assignment.packageId !== "composio" ||
47
+ config.assignment.capabilityId !== "gmail-tools" ||
48
+ config.assignment.state !== "enabled" ||
49
+ !config.assignment.connectionId
50
+ ) {
51
+ return undefined;
52
+ }
53
+ const apiKey = config.readSecret("COMPOSIO_API_KEY");
54
+ if (!apiKey) throw new Error("Assigned Composio Connection is misconfigured");
55
+ const authorizeEffect = async () => {
56
+ const connection = await config.authorizeConnection();
57
+ const connectedAccountId = connection.safeMetadata.connectedAccountId;
58
+ const toolkitSlug = connection.safeMetadata.toolkitSlug;
59
+ if (
60
+ typeof connectedAccountId !== "string" ||
61
+ typeof toolkitSlug !== "string"
62
+ ) {
63
+ throw new Error("Composio effect is no longer authorized");
64
+ }
65
+ return { connectedAccountId, toolkitSlug };
66
+ };
67
+ return createComposioRouterPlugin({
68
+ client: new ComposioClient({ apiKey }),
69
+ userId: config.userId,
70
+ toolkitSlug: "gmail",
71
+ authorizeEffect,
72
+ });
73
+ }
74
+
75
+ export function createComposioRouterPlugin(
76
+ config: ComposioRouterPluginConfig,
77
+ ): Plugin.Function {
78
+ const allowedToolSlugs = new Set<string>();
79
+ let runtimeContext: Context | undefined;
80
+ const search: ToolDefinition = {
81
+ name: "composio_search_tools",
82
+ // A general work tool: the full toolset an `executor` subagent gets, and
83
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
84
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
85
+ admission: { subagentRoles: ["executor"] },
86
+ description:
87
+ "Search the connected toolkit for exact Composio tool slugs before executing one.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: { query: { type: "string" } },
91
+ },
92
+ validate: isObject,
93
+ execute: async (input: unknown) => {
94
+ const query =
95
+ isObject(input) && typeof input.query === "string"
96
+ ? input.query
97
+ : undefined;
98
+ const grant = await config.authorizeEffect();
99
+ if (grant.toolkitSlug !== config.toolkitSlug) {
100
+ throw new Error("Composio effect grant changed toolkit");
101
+ }
102
+ const result = await config.client.searchTools(grant.toolkitSlug, query);
103
+ for (const tool of result) allowedToolSlugs.add(tool.slug);
104
+ return { content: JSON.stringify(result), isError: false };
105
+ },
106
+ };
107
+ const execute: ToolDefinition = {
108
+ name: "composio_execute_tool",
109
+ // A general work tool: the full toolset an `executor` subagent gets, and
110
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
111
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
112
+ admission: { subagentRoles: ["executor"] },
113
+ description:
114
+ "Execute an exact Composio tool slug returned by composio_search_tools.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ toolSlug: { type: "string" },
119
+ arguments: { type: "object" },
120
+ },
121
+ required: ["toolSlug", "arguments"],
122
+ },
123
+ validate: (input: unknown) =>
124
+ isObject(input) &&
125
+ typeof input.toolSlug === "string" &&
126
+ isObject(input.arguments),
127
+ execute: async (input: unknown, context) => {
128
+ if (
129
+ !isObject(input) ||
130
+ typeof input.toolSlug !== "string" ||
131
+ !isObject(input.arguments)
132
+ ) {
133
+ return { content: "Invalid Composio tool input", isError: true };
134
+ }
135
+ const wasDurablySearched = runtimeContext?.sessions
136
+ .get(context.sessionId)
137
+ ?.events.some((event) => {
138
+ if (
139
+ event.type !== "tool/result" ||
140
+ event.name !== "composio_search_tools" ||
141
+ event.isError
142
+ ) {
143
+ return false;
144
+ }
145
+ try {
146
+ const result: unknown = JSON.parse(event.content);
147
+ return (
148
+ Array.isArray(result) &&
149
+ result.some(
150
+ (candidate) =>
151
+ isObject(candidate) && candidate.slug === input.toolSlug,
152
+ )
153
+ );
154
+ } catch {
155
+ return false;
156
+ }
157
+ });
158
+ if (!allowedToolSlugs.has(input.toolSlug) && !wasDurablySearched) {
159
+ return {
160
+ content:
161
+ "Tool slug was not returned by composio_search_tools in this runtime.",
162
+ isError: true,
163
+ };
164
+ }
165
+ const grant = await config.authorizeEffect();
166
+ if (grant.toolkitSlug !== config.toolkitSlug) {
167
+ return {
168
+ content: "Composio effect grant changed toolkit",
169
+ isError: true,
170
+ };
171
+ }
172
+ const result = await config.client.executeTool({
173
+ toolSlug: input.toolSlug,
174
+ userId: config.userId,
175
+ connectedAccountId: grant.connectedAccountId,
176
+ arguments: input.arguments,
177
+ });
178
+ return { content: JSON.stringify(result), isError: false };
179
+ },
180
+ };
181
+ const plugin: Plugin.Function = (ctx: Context) => {
182
+ runtimeContext = ctx;
183
+ const removeSearch = ctx.tools.register(search);
184
+ const removeExecute = ctx.tools.register(execute);
185
+ return () => {
186
+ runtimeContext = undefined;
187
+ removeExecute();
188
+ removeSearch();
189
+ };
190
+ };
191
+ plugin.inject = ["tools", "sessions"];
192
+ return plugin;
193
+ }
194
+
195
+ export function createComposioPlugin(
196
+ config: ComposioPluginConfig,
197
+ ): Plugin.Function {
198
+ const plugin: Plugin.Function = (ctx: Context) => {
199
+ const disposers = config.tools.map((declaration) => {
200
+ const tool: ToolDefinition = {
201
+ name: declaration.name,
202
+ description: declaration.description,
203
+ inputSchema: declaration.inputSchema,
204
+ validate: isObject,
205
+ execute: async (input: unknown) => {
206
+ const result = await config.client.executeTool({
207
+ toolSlug: declaration.slug,
208
+ userId: config.userId,
209
+ connectedAccountId: config.connectedAccountId,
210
+ version: declaration.version,
211
+ arguments: input as Record<string, unknown>,
212
+ });
213
+ return {
214
+ content: JSON.stringify(result),
215
+ isError: false,
216
+ };
217
+ },
218
+ };
219
+ return ctx.tools.register(tool);
220
+ });
221
+ return () => {
222
+ for (const dispose of disposers.toReversed()) dispose();
223
+ };
224
+ };
225
+ plugin.inject = ["tools"];
226
+ return plugin;
227
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ decodeRevokeConnectionResultV1,
3
+ decodeStartConnectionResultV1,
4
+ type ConnectionCompletionResult,
5
+ type RevokeConnectionResult,
6
+ type StartConnectionResult,
7
+ // pi-lens-ignore: ts:2307
8
+ } from "@frockbot/connection-core";