import type { AdapterContext, ConnectorEvent, ConnectorTool } from "@rakazo/adapter-kit"; import { createLogger, createTestSink, installLogger } from "@rakazo/logging"; import { describe, expect, it, vi } from "vitest"; import { composioToolkitDirectory } from "./composio-catalog-cache.js"; import { asConnectorTools, ComposioConnector, CompositeConnector, collectLogIds, collectPages, executeSessionKey, filterCatalog, isComposioEnabled, isNoAuthToolkitError, mergeConnectedPlugins, needsLivePluginSync, planLiveConnectionSync, sanitizeComposioError, } from "./composio-connector.js"; import { DestinationEmulator } from "./destination-emulator.js"; const composioSdkState = vi.hoisted(() => ({ created: [] as Array<{ userId: string; config: Record }>, directoryFails: false, executions: [] as Array<{ tool: string; args: Record }>, connectedAccounts: { list: async (_query?: Record) => ({ items: [] as Array<{ id: string }> }), waitForConnection: async (id: string, _timeout?: number) => ({ id: `resolved-${id}` }), get: async (id: string) => ({ id, userId: "user-1" }), delete: async (_id: string) => undefined, }, sessions: new Map< string, { sessionId: string; toolkits?: () => Promise<{ items: Array<{ slug: string; name: string; logo: string | null; isNoAuth: boolean; connection?: { isActive?: boolean; connectedAccount?: { id: string } }; }>; cursor?: string; }>; tools?: () => Promise; execute?: ( tool: string, args: Record, ) => Promise<{ data: Record; error: null; logId: string; }>; } >(), })); vi.mock("@composio/core", () => ({ Composio: class { readonly sessions = { use: async (sessionId: string) => { const session = composioSdkState.sessions.get(sessionId); if (!session) throw new Error(`unknown session ${sessionId}`); return session; }, }; readonly connectedAccounts = { list: (query?: Record) => composioSdkState.connectedAccounts.list(query), waitForConnection: (id: string, timeout?: number) => composioSdkState.connectedAccounts.waitForConnection(id, timeout), get: (id: string) => composioSdkState.connectedAccounts.get(id), delete: (id: string) => composioSdkState.connectedAccounts.delete(id), }; async create(userId: string, config: Record) { composioSdkState.created.push({ userId, config }); const toolkits = Array.isArray(config.toolkits) ? config.toolkits : []; if (toolkits.length === 0) { const session = { sessionId: "catalog-session", toolkits: async () => { if (composioSdkState.directoryFails) throw new Error("directory unavailable"); return { items: [ { slug: "GITHUB", name: "GitHub", logo: null, isNoAuth: false, connection: { isActive: true, connectedAccount: { id: "ca-github" } }, }, ], }; }, }; composioSdkState.sessions.set(session.sessionId, session); return session; } const scopedToCanonicalGithub = toolkits.includes("GITHUB"); const session = { sessionId: scopedToCanonicalGithub ? "github-session" : "unscoped-session", tools: async () => scopedToCanonicalGithub ? [ { type: "function", function: { name: "GITHUB_GET_REPOS", description: "List GitHub repositories", parameters: { type: "object", properties: {} }, }, }, ] : [], execute: async (tool: string, args: Record) => { composioSdkState.executions.push({ tool, args }); return { data: { ok: true }, error: null, logId: "log-github" }; }, }; composioSdkState.sessions.set(session.sessionId, session); return session; } }, })); describe("composio tool mapping", () => { it("maps OpenAI-style session tools and raw slugs", () => { const tools = asConnectorTools([ { type: "function", function: { name: "COMPOSIO_SEARCH_TOOLS", description: "Search tools", parameters: { type: "object", properties: { query: { type: "string" } } }, }, }, { slug: "HACKERNEWS_GET_USER", description: "Look up a public HN profile", inputParameters: { type: "object", properties: { username: { type: "string" } } }, }, ]); expect(tools.map((tool) => tool.name)).toEqual([ "COMPOSIO_SEARCH_TOOLS", "HACKERNEWS_GET_USER", ]); expect(tools[1]?.inputSchema).toMatchObject({ properties: { username: { type: "string" } } }); }); it("retains provider route metadata independently of the tool name", async () => { const destination = new DestinationEmulator(); const events: ConnectorEvent[] = []; const composio = { describe: () => ({ ...destination.describe(), id: "composio" }), discoverTools: async () => [ { name: "destination.write", description: "shadow", inputSchema: {}, route: { connectorId: "composio", toolName: "destination.write" }, } satisfies ConnectorTool, ], execute: async function* () { yield { type: "result", data: { provider: "composio" } } as ConnectorEvent; }, } as never; const connector = new CompositeConnector(destination, [composio]); const context = { userId: "u" } as AdapterContext; for await (const event of connector.execute( { tool: "destination.write", args: {}, executionId: "x", route: { connectorId: "composio", toolName: "destination.write" }, }, context, )) events.push(event); expect(events).toEqual([{ type: "result", data: { provider: "composio" } }]); }); it("logs sanitized connector discovery failures", async () => { const destination = new DestinationEmulator(); const failing = { describe: () => ({ ...destination.describe(), id: "failing" }), discoverTools: async () => { throw new Error("denied ak_secretvaluehere"); }, execute: async function* () {}, } as never; const connector = new CompositeConnector(destination, [failing]); const sink = createTestSink(); installLogger(createLogger({ service: "rakazo-api", sinks: [sink] })); try { await expect(connector.discoverTools({ userId: "u" } as AdapterContext)).resolves.toEqual([ expect.objectContaining({ name: "destination.write" }), ]); expect(sink.events[0]).toMatchObject({ message: "connector discovery failed", "connector.id": "failing", }); const logged = JSON.stringify(sink.events); expect(logged).toContain("[redacted]"); expect(logged).not.toContain("ak_secretvaluehere"); } finally { installLogger(createLogger({ service: "rakazo-api", level: "off", sinks: [] })); } }); it("redacts project keys from errors", () => { expect(sanitizeComposioError("denied ak_secretvaluehere")).toContain("[redacted]"); expect(sanitizeComposioError("denied ak_secretvaluehere")).not.toContain("ak_secret"); expect(sanitizeComposioError("COMPOSIO_API_KEY=ak_shouldnotleak")).not.toContain( "ak_shouldnotleak", ); }); it("paginates until the cursor ends", async () => { const pages = [ { items: ["gmail", "github"], cursor: "page-2" }, { items: ["slack"], cursor: undefined }, ]; const items = await collectPages(async (cursor) => { if (!cursor) return pages[0]!; return pages[1]!; }); expect(items).toEqual(["gmail", "github", "slack"]); }); it("treats Composio no-auth toolkit errors as in-app connect", () => { expect( isNoAuthToolkitError( new Error( '400 {"error":{"message":"Toolkit hackernews does not require authentication.","slug":"ToolRouterV2_ToolkitsIsNoAuth"}}', ), ), ).toBe(true); expect(isNoAuthToolkitError(new Error("redirect required"))).toBe(false); }); it("collects nested Composio log ids", () => { expect( collectLogIds({ logId: "", data: { results: [{ log_id: "log_abc123", slug: "HACKERNEWS_GET_USER" }] }, }), ).toEqual(["log_abc123"]); }); it("keys execute sessions by sorted unique toolkits", () => { expect(executeSessionKey(["hackernews", "gmail", "hackernews"])).toBe("gmail,hackernews"); expect(executeSessionKey(["github", "GITHUB"])).toBe("github"); expect(executeSessionKey([])).toBe(""); expect(executeSessionKey(["GMAIL"], { GMAIL: ["ca-work", "ca-personal", "ca-work"] })).toBe( "GMAIL|GMAIL:ca-personal,ca-work", ); }); it("pins every connected account into multi-account execute sessions", async () => { composioSdkState.created.length = 0; composioSdkState.sessions.clear(); composioToolkitDirectory.invalidate(); const connector = new ComposioConnector(); await connector.discoverTools({ operationId: "composio-multi-account", traceId: "composio-multi-account", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, connectedConnections: [ { id: "connection-personal", connectorId: "composio", externalId: "github", displayName: "Personal", providerRef: "ca-personal", }, { id: "connection-work", connectorId: "composio", externalId: "GITHUB", displayName: "Work", providerRef: "ca-work", }, { id: "connection-personal-dup", connectorId: "composio", externalId: "GitHub", displayName: "Personal again", providerRef: "ca-personal", }, { id: "connection-noauth", connectorId: "composio", externalId: "GITHUB", displayName: "Legacy", providerRef: "github", }, ], }); expect(composioSdkState.created.at(-1)).toEqual({ userId: "user-1", config: { manageConnections: false, sandbox: { enable: false }, toolkits: ["GITHUB"], connectedAccounts: { GITHUB: ["ca-personal", "ca-work"] }, multiAccount: { enable: true, maxAccountsPerToolkit: 10, requireExplicitSelection: true, }, }, }); }); it("pins a single concrete account without enabling multi-account mode", async () => { composioSdkState.created.length = 0; composioSdkState.sessions.clear(); composioToolkitDirectory.invalidate(); const connector = new ComposioConnector(); await connector.discoverTools({ operationId: "composio-single-account", traceId: "composio-single-account", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, connectedConnections: [ { id: "connection-work", connectorId: "composio", externalId: "gmail", displayName: "Work", providerRef: "ca-work", }, ], }); expect(composioSdkState.created.at(-1)).toEqual({ userId: "user-1", config: { manageConnections: false, sandbox: { enable: false }, toolkits: ["GMAIL"], connectedAccounts: { GMAIL: ["ca-work"] }, }, }); }); it("omits connectedAccounts and multiAccount for legacy slug-only refs", async () => { composioSdkState.created.length = 0; composioSdkState.sessions.clear(); composioToolkitDirectory.invalidate(); const connector = new ComposioConnector(); await connector.discoverTools({ operationId: "composio-legacy-only", traceId: "composio-legacy-only", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, connectedConnections: [ { id: "connection-legacy", connectorId: "composio", externalId: "GITHUB", displayName: "Legacy", providerRef: "github", }, { id: "connection-hackernews", connectorId: "composio", externalId: "hackernews", displayName: "Hacker News", providerRef: "HACKERNEWS", }, ], }); expect(composioSdkState.created.at(-1)).toEqual({ userId: "user-1", config: { manageConnections: false, sandbox: { enable: false }, toolkits: ["GITHUB", "HACKERNEWS"], }, }); }); it("uses catalog-canonical toolkit slugs without preloading every tool", async () => { composioSdkState.created.length = 0; composioSdkState.executions.length = 0; composioSdkState.sessions.clear(); composioToolkitDirectory.invalidate(); const connector = new ComposioConnector(); const context: AdapterContext = { operationId: "composio-canonical-slug", traceId: "composio-canonical-slug", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, connectedConnections: [ { id: "connection-github", connectorId: "composio", externalId: "github", displayName: "GitHub", }, ], }; await expect(connector.discoverTools(context)).resolves.toContainEqual( expect.objectContaining({ name: "GITHUB_GET_REPOS" }), ); expect( composioSdkState.created.map(({ userId, config }) => ({ userId, toolkits: config.toolkits, sessionPreset: config.sessionPreset, })), ).toEqual([ { userId: "__rakazo_catalog__", toolkits: undefined, sessionPreset: undefined }, { userId: "user-1", toolkits: ["GITHUB"], sessionPreset: undefined }, ]); const events: ConnectorEvent[] = []; for await (const event of connector.execute( { tool: "GITHUB_GET_REPOS", args: { owner: "composio" }, executionId: "composio-canonical-execution", }, context, )) { events.push(event); } expect(events).toContainEqual(expect.objectContaining({ type: "result" })); expect(composioSdkState.executions).toEqual([ { tool: "GITHUB_GET_REPOS", args: { owner: "composio" } }, ]); await expect(connector.connectionReady(context, "github")).resolves.toBe(true); await expect(connector.connectedAccountId("user-1", "github")).resolves.toBe("ca-github"); }); it("resolves connection-request ids to connected-account ids and skips sibling refs", async () => { composioSdkState.created.length = 0; composioSdkState.sessions.clear(); composioToolkitDirectory.invalidate(); composioSdkState.connectedAccounts.list = async () => ({ items: [{ id: "ca-personal" }, { id: "ca-work" }], }); composioSdkState.connectedAccounts.waitForConnection = async (id: string) => { if (id === "req-work") return { id: "ca-work" }; if (id === "req-missing") throw new Error("timeout"); return { id: `resolved-${id}` }; }; const connector = new ComposioConnector(); await expect( connector.resolveConnectedAccountId("user-1", "gmail", "req-work", ["ca-personal"]), ).resolves.toBe("ca-work"); await expect( connector.resolveConnectedAccountId("user-1", "gmail", "gmail", ["ca-personal"]), ).resolves.toBe("ca-work"); await expect( connector.resolveConnectedAccountId("user-1", "gmail", "req-missing", [ "ca-personal", "ca-work", ]), ).resolves.toBeUndefined(); }); it("cancels pending authorization requests by request id without waiting", async () => { const deleted: string[] = []; const waited: string[] = []; composioSdkState.connectedAccounts.waitForConnection = async (id: string) => { waited.push(id); throw new Error(`should not wait for ${id}`); }; composioSdkState.connectedAccounts.delete = async (id: string) => { deleted.push(id); }; const connector = new ComposioConnector(); await connector.cancelAuthorizationRequest("req-pending-1", { operationId: "test", traceId: "test", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, }); expect(waited).toEqual([]); expect(deleted).toEqual(["req-pending-1"]); }); it.each([{ status: 404 }, { statusCode: 404 }])( "ignores missing authorization requests during cancellation (%o)", async (notFound) => { composioSdkState.connectedAccounts.delete = async () => { throw Object.assign(new Error("not found"), notFound); }; const connector = new ComposioConnector(); await expect( connector.cancelAuthorizationRequest("req-missing", { operationId: "test", traceId: "test", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, }), ).resolves.toBeUndefined(); }, ); it("surfaces authorization cancellation failures for router fallback", async () => { composioSdkState.connectedAccounts.delete = async () => { throw Object.assign(new Error("service unavailable"), { status: 503 }); }; const connector = new ComposioConnector(); await expect( connector.cancelAuthorizationRequest("req-pending-1", { operationId: "test", traceId: "test", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, }), ).rejects.toThrow("service unavailable"); }); it("resolves authorization-request ids before deleting on revoke", async () => { const deleted: string[] = []; const waited: string[] = []; composioSdkState.connectedAccounts.list = async () => ({ items: [] }); composioSdkState.connectedAccounts.waitForConnection = async (id: string) => { waited.push(id); return { id: `ca-from-${id}` }; }; composioSdkState.connectedAccounts.delete = async (id: string) => { deleted.push(id); }; const connector = new ComposioConnector(); await connector.revoke("req-oauth-1", { operationId: "test", traceId: "test", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, }); expect(waited).toEqual(["req-oauth-1"]); expect(deleted).toEqual(["ca-from-req-oauth-1"]); }); it("uses Composio slug casing when the toolkit directory is unavailable", async () => { composioSdkState.created.length = 0; composioSdkState.sessions.clear(); composioSdkState.directoryFails = true; composioToolkitDirectory.invalidate(); try { const connector = new ComposioConnector(); const context = { operationId: "composio-directory-fallback", traceId: "composio-directory-fallback", spaceId: "workspace", userId: "user-1", signal: new AbortController().signal, connectedConnections: [ { id: "connection-github", connectorId: "composio", externalId: "github", displayName: "GitHub", }, ], } satisfies AdapterContext; await expect(connector.discoverTools(context)).resolves.toContainEqual( expect.objectContaining({ name: "GITHUB_GET_REPOS" }), ); expect(composioSdkState.created.at(-1)?.config.toolkits).toEqual(["GITHUB"]); } finally { composioSdkState.directoryFails = false; composioToolkitDirectory.invalidate(); } }); it("merges live Composio slugs onto pending DB plugin rows", () => { const merged = mergeConnectedPlugins( [ { provider: "github", displayName: "GitHub", status: "connected" }, { provider: "gmail", displayName: "Gmail", status: "pending" }, { provider: "linear", displayName: "Linear", status: "revoked" }, ], ["gmail", "github", "notion"], ); expect(merged).toEqual([ { provider: "github", displayName: "GitHub" }, { provider: "gmail", displayName: "Gmail" }, ]); }); it("reconciles Composio slugs without case sensitivity", () => { expect( mergeConnectedPlugins( [{ provider: "github", displayName: "GitHub", status: "pending" }], ["GITHUB"], ), ).toEqual([{ provider: "github", displayName: "GitHub" }]); expect( planLiveConnectionSync( [{ id: "row-gh", provider: "github", status: "pending", displayName: "GitHub" }], ["GITHUB"], ), ).toEqual({ connectIds: ["row-gh"], revokeIds: [] }); }); it("only fetches live Composio slugs when a Rakazo row is still pending or errored", () => { expect(needsLivePluginSync([{ status: "connected" }, { status: "revoked" }])).toBe(false); expect(needsLivePluginSync([{ status: "pending" }])).toBe(true); expect(needsLivePluginSync([{ status: "error" }])).toBe(true); }); it("keeps DB-connected plugins when live Composio listing is empty", () => { expect( mergeConnectedPlugins( [{ provider: "github", displayName: "GitHub", status: "connected" }], [], ), ).toEqual([{ provider: "github", displayName: "GitHub" }]); }); it("plans DB sync when Composio is connected but Rakazo is still pending", () => { expect( planLiveConnectionSync( [ { id: "row-gmail", provider: "gmail", status: "pending", displayName: "Gmail" }, { id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" }, ], ["gmail", "slack"], ), ).toEqual({ connectIds: ["row-gmail"], revokeIds: [], }); }); it("does not create connection rows for live slugs that have no workspace row", () => { expect( planLiveConnectionSync( [{ id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" }], ["github", "slack"], ), ).toEqual({ connectIds: [], revokeIds: [] }); }); it("reconnects existing error or revoked rows instead of inserting duplicates", () => { expect( planLiveConnectionSync( [ { id: "row-err", provider: "gmail", status: "error", displayName: "Gmail" }, { id: "row-old", provider: "slack", status: "revoked", displayName: "Slack" }, { id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" }, ], ["gmail", "slack", "github", "slack"], ), ).toEqual({ connectIds: ["row-err", "row-old"], revokeIds: [], }); }); it("revokes abandoned pending or error rows after a successful live listing", () => { expect( planLiveConnectionSync( [ { id: "row-gmail", provider: "gmail", status: "pending", displayName: "Gmail" }, { id: "row-dup", provider: "gmail", status: "pending", displayName: "Gmail" }, { id: "row-err", provider: "slack", status: "error", displayName: "Slack" }, { id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" }, ], ["gmail"], ), ).toEqual({ connectIds: ["row-gmail"], revokeIds: ["row-dup", "row-err"], }); }); it("clears failed additional-account error rows while keeping in-flight pendings", () => { expect( planLiveConnectionSync( [ { id: "row-gmail", provider: "gmail", status: "connected", displayName: "Personal" }, { id: "row-work", provider: "gmail", status: "pending", displayName: "Work" }, { id: "row-fail", provider: "gmail", status: "error", displayName: "Gmail" }, { id: "row-err", provider: "slack", status: "error", displayName: "Slack" }, ], ["gmail"], ), ).toEqual({ connectIds: [], revokeIds: ["row-fail", "row-err"], }); }); it("keeps an in-flight additional account when the provider is already connected", () => { expect( planLiveConnectionSync( [ { id: "row-gmail", provider: "gmail", status: "connected", displayName: "Personal" }, { id: "row-work", provider: "gmail", status: "pending", displayName: "Work" }, { id: "row-err", provider: "slack", status: "error", displayName: "Slack" }, ], ["gmail"], ), ).toEqual({ connectIds: [], revokeIds: ["row-err"], }); }); it("filters the catalog by name or slug", () => { const items = [ { slug: "github", name: "GitHub", logo: null, connected: false, noAuth: false }, { slug: "hackernews", name: "Hacker News", logo: null, connected: false, noAuth: true }, ]; expect(filterCatalog(items, "hacker").map((item) => item.slug)).toEqual(["hackernews"]); }); }); describe("Composio during pnpm test", () => { it("does not construct a live Platform client under Vitest", () => { expect(process.env.VITEST).toBeTruthy(); expect(isComposioEnabled("ck_must_not_call_live")).toBe(false); }); it.each(["0", "false"])("does not treat VITEST=%s as an active test runner", (value) => { vi.stubEnv("VITEST", value); expect(isComposioEnabled("ck_configured")).toBe(true); vi.unstubAllEnvs(); }); });