evOo-local/packages/adapters/src/composio-connector.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

758 lines
26 KiB
TypeScript

import { Composio } from "@composio/core";
import type {
AdapterContext,
ConnectedConnector,
ConnectorCall,
ConnectorCatalogItem,
ConnectorEvent,
ConnectorProvider,
ConnectorTool,
ManagedConnectorProvider,
} from "@rakazo/adapter-kit";
import { getLogger } from "@rakazo/logging";
import {
composioToolkitDirectory,
mergeCatalogWithConnected,
type ToolkitDirectoryEntry,
} from "./composio-catalog-cache.js";
import { DestinationEmulator } from "./destination-emulator.js";
import { isVitestRuntime } from "./test-runtime.js";
type ComposioSession = Awaited<ReturnType<Composio["create"]>>;
export function isComposioEnabled(apiKey: string | undefined): boolean {
return Boolean(apiKey) && !isVitestRuntime();
}
export function asConnectorTools(input: unknown): ConnectorTool[] {
const items = Array.isArray(input)
? input
: input && typeof input === "object" && Array.isArray((input as { items?: unknown }).items)
? ((input as { items: unknown[] }).items ?? [])
: [];
const tools: ConnectorTool[] = [];
for (const item of items) {
const mapped = mapOneTool(item);
if (mapped) tools.push(mapped);
}
return tools;
}
function mapOneTool(item: unknown): ConnectorTool | undefined {
if (!item || typeof item !== "object") return undefined;
const raw = item as Record<string, unknown>;
if (raw.type === "function" && raw.function && typeof raw.function === "object") {
const fn = raw.function as Record<string, unknown>;
const name = String(fn.name ?? "");
if (!name) return undefined;
return {
name,
description: String(fn.description ?? name),
inputSchema: asObject(fn.parameters) ?? { type: "object", properties: {} },
route: { connectorId: "composio", toolName: name },
};
}
const name = String(raw.slug ?? raw.name ?? "");
if (!name) return undefined;
return {
name,
description: String(raw.description ?? name),
inputSchema: asObject(raw.inputParameters) ??
asObject(raw.inputSchema) ??
asObject(raw.parameters) ?? { type: "object", properties: {} },
route: { connectorId: "composio", toolName: name },
};
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export type ComposioCatalogItem = Omit<ConnectorCatalogItem, "connectorId">;
export interface ComposioProvider extends ManagedConnectorProvider {
warmDirectory(): Promise<void>;
listConnectedSlugs(userId: string): Promise<string[]>;
}
export function filterCatalog<T extends Pick<ComposioCatalogItem, "name" | "slug">>(
items: T[],
query: string,
): T[] {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter(
(item) => item.name.toLowerCase().includes(needle) || item.slug.toLowerCase().includes(needle),
);
}
export async function collectPages<T>(
fetchPage: (cursor?: string) => Promise<{ items: T[]; cursor?: string }>,
maxPages = 200,
): Promise<T[]> {
const items: T[] = [];
let cursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
const result = await fetchPage(cursor);
items.push(...result.items);
if (!result.cursor) break;
cursor = result.cursor;
}
return items;
}
function composioSlugKey(slug: string): string {
return slug.trim().toLowerCase();
}
export function executeSessionKey(
toolkits: string[],
connectedAccounts: Record<string, string[]> = {},
): string {
const unique = new Map<string, string>();
for (const slug of toolkits) {
const trimmed = slug.trim();
const key = composioSlugKey(trimmed);
if (key && !unique.has(key)) unique.set(key, trimmed);
}
const toolkitKey = [...unique.values()].sort().join(",");
const accountKey = Object.entries(connectedAccounts)
.sort(([left], [right]) => left.localeCompare(right))
.map(([toolkit, ids]) => `${toolkit}:${[...new Set(ids)].sort().join(",")}`)
.join(";");
return accountKey ? `${toolkitKey}|${accountKey}` : toolkitKey;
}
export type PluginConnectionRow = {
id: string;
provider: string;
status: string;
displayName: string;
};
export function needsLivePluginSync(rows: { status: string }[]): boolean {
return rows.some((row) => row.status === "pending" || row.status === "error");
}
export function mergeConnectedPlugins(
rows: { provider: string; displayName: string; status?: string }[],
liveSlugs: string[],
): { provider: string; displayName: string }[] {
const live = new Set(liveSlugs.map((slug) => composioSlugKey(slug)).filter(Boolean));
const byProvider = new Map<string, { provider: string; displayName: string }>();
for (const row of rows) {
if (!row.provider) continue;
const include =
row.status === "connected" ||
row.status === undefined ||
live.has(composioSlugKey(row.provider));
if (!include) continue;
const current = byProvider.get(composioSlugKey(row.provider));
if (!current || composioSlugKey(current.displayName) === composioSlugKey(current.provider)) {
byProvider.set(composioSlugKey(row.provider), {
provider: row.provider,
displayName: row.displayName,
});
}
}
return [...byProvider.values()];
}
export function planLiveConnectionSync(
rows: PluginConnectionRow[],
liveSlugs: string[],
): { connectIds: string[]; revokeIds: string[] } {
const live = new Set(liveSlugs.map(composioSlugKey).filter(Boolean));
const connectIds: string[] = [];
const connectedProviders = new Set(
rows.filter((row) => row.status === "connected").map((row) => composioSlugKey(row.provider)),
);
for (const slug of live) {
if (connectedProviders.has(slug)) continue;
const matches = rows.filter((row) => composioSlugKey(row.provider) === slug);
const reusable =
matches.find((row) => row.status === "pending" || row.status === "error") ??
matches.find((row) => row.status === "revoked") ??
matches[0];
if (!reusable) continue;
connectIds.push(reusable.id);
connectedProviders.add(slug);
}
const connectIdSet = new Set(connectIds);
// Providers that already have a connected row before this sync. Extra pending
// rows for those providers are additional-account attempts, not abandoned
// first connects — keep them. Concurrent pendings with no connected row yet
// still collapse to one connect + revoke the rest.
const previouslyConnected = new Set(
rows.filter((row) => row.status === "connected").map((row) => composioSlugKey(row.provider)),
);
const revokeIds = rows
.filter((row) => {
if (row.status !== "pending" && row.status !== "error") return false;
if (connectIdSet.has(row.id)) return false;
// Keep only in-flight additional-account pendings; clear abandoned errors
// so live sync does not keep re-listing forever after a failed attempt.
if (row.status === "pending" && previouslyConnected.has(composioSlugKey(row.provider))) {
return false;
}
return true;
})
.map((row) => row.id);
return { connectIds, revokeIds };
}
export class ComposioConnector implements ComposioProvider {
constructor(private readonly apiKey?: string) {}
private client: Composio | undefined;
private readonly catalogSessions = new Map<string, string>();
private readonly executeSessions = new Map<string, { sessionId: string; key: string }>();
describe() {
return {
id: "composio",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { discover: true, oauth: true, secretsBrokered: true },
};
}
async sessionFor(userId: string): Promise<ComposioSession> {
const composio = this.sdk();
const existing = this.catalogSessions.get(userId);
if (existing) {
try {
return await composio.sessions.use(existing);
} catch {
this.catalogSessions.delete(userId);
}
}
const session = await composio.create(userId, {
manageConnections: false,
sandbox: { enable: false },
});
this.catalogSessions.set(userId, session.sessionId);
return session;
}
async sessionForExecute(
userId: string,
connections: ReturnType<typeof connectedComposioConnections>,
): Promise<ComposioSession> {
const canonicalToolkits = await this.canonicalizeToolkits(
connections.map((connection) => connection.externalId),
);
const canonicalByKey = new Map(
canonicalToolkits.map((toolkit) => [composioSlugKey(toolkit), toolkit]),
);
const accountIdsByToolkit = new Map<string, Set<string>>();
for (const connection of connections) {
const accountId = connection.providerRef?.trim();
if (!accountId) continue;
// No-auth and legacy rows store the toolkit slug rather than a remote
// connected-account id. Only concrete account ids can scope a session.
if (composioSlugKey(accountId) === composioSlugKey(connection.externalId)) continue;
const toolkit = canonicalByKey.get(composioSlugKey(connection.externalId));
if (!toolkit) continue;
const ids = accountIdsByToolkit.get(toolkit) ?? new Set<string>();
ids.add(accountId);
accountIdsByToolkit.set(toolkit, ids);
}
const connectedAccounts: Record<string, string[]> = {};
for (const [toolkit, ids] of accountIdsByToolkit) {
connectedAccounts[toolkit] = [...ids].sort();
}
const key = executeSessionKey(canonicalToolkits, connectedAccounts);
if (!key) return this.sessionFor(userId);
const composio = this.sdk();
const existing = this.executeSessions.get(userId);
if (existing?.key === key) {
try {
return await composio.sessions.use(existing.sessionId);
} catch {
this.executeSessions.delete(userId);
}
}
// Non-multi-account sessions cap connectedAccounts at one id per toolkit.
// requireExplicitSelection would also break single-account / no-auth
// execute paths that do not pass an account parameter.
const needsMultiAccount = Object.values(connectedAccounts).some((ids) => ids.length >= 2);
const session = await composio.create(userId, {
manageConnections: false,
sandbox: { enable: false },
toolkits: canonicalToolkits,
...(Object.keys(connectedAccounts).length > 0 ? { connectedAccounts } : {}),
...(needsMultiAccount
? {
multiAccount: {
enable: true,
maxAccountsPerToolkit: 10,
requireExplicitSelection: true,
},
}
: {}),
});
this.executeSessions.set(userId, { sessionId: session.sessionId, key });
return session;
}
async catalog(context: AdapterContext, query?: string): Promise<ConnectorCatalogItem[]> {
const [directory, connected] = await Promise.all([
this.directory(),
this.listConnectedSlugs(context.userId),
]);
return filterCatalog(mergeCatalogWithConnected(directory, connected), query ?? "").map(
(item) => ({ ...item, connectorId: "composio" }),
);
}
async warmDirectory(): Promise<void> {
await this.directory();
}
private async canonicalizeToolkits(toolkits: string[]): Promise<string[]> {
const directory = await this.directory().catch(() => []);
const canonical = new Map(directory.map((item) => [composioSlugKey(item.slug), item.slug]));
const unique = new Map<string, string>();
for (const toolkit of toolkits) {
const trimmed = toolkit.trim();
const key = composioSlugKey(trimmed);
if (key && !unique.has(key)) {
unique.set(key, canonical.get(key) ?? trimmed.toUpperCase());
}
}
return [...unique.values()].sort();
}
private async directory(): Promise<ToolkitDirectoryEntry[]> {
return composioToolkitDirectory.get(() => this.loadDirectory());
}
private async loadDirectory(): Promise<ToolkitDirectoryEntry[]> {
const session = await this.sessionFor("__rakazo_catalog__");
const toolkits = await collectPages((cursor) => session.toolkits({ limit: 50, cursor }));
return toolkits.map((toolkit) => ({
slug: toolkit.slug,
name: toolkit.name,
logo: toolkit.logo ?? null,
noAuth: Boolean(toolkit.isNoAuth),
}));
}
async listConnectedSlugs(userId: string): Promise<string[]> {
const session = await this.sessionFor(userId);
const connected = await collectPages((cursor) =>
session.toolkits({ isConnected: true, limit: 50, cursor }),
);
return connected.map((toolkit) => toolkit.slug);
}
async listConnectedExternalIds(context: AdapterContext): Promise<string[]> {
return this.listConnectedSlugs(context.userId);
}
async discoverTools(context: AdapterContext): Promise<ConnectorTool[]> {
const connections = connectedComposioConnections(context);
if (connections.length === 0) return [];
const session = await this.sessionForExecute(context.userId, connections);
const raw = await session.tools();
return asConnectorTools(raw);
}
async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable<ConnectorEvent> {
try {
const session = await this.sessionForExecute(
context.userId,
connectedComposioConnections(context),
);
const result = await session.execute(call.tool, call.args ?? {});
if (result.error) {
yield { type: "error", message: sanitizeComposioError(result.error) };
return;
}
const logId = collectLogIds(result)[0] ?? "";
yield {
type: "result",
data: {
data: sanitizePayload(result.data),
logId,
},
};
} catch (error) {
yield { type: "error", message: sanitizeComposioError(error) };
}
}
async begin(
request: { provider: string; redirectUrl: string },
context: AdapterContext,
): Promise<{ authorizationUrl: string | null; state: string }> {
const session = await this.sessionFor(context.userId);
try {
const connectionRequest = await session.authorize(request.provider, {
callbackUrl: request.redirectUrl,
});
if (!connectionRequest.redirectUrl) {
const account = await connectionRequest.waitForConnection(20_000).catch(() => undefined);
return {
authorizationUrl: null,
state: account?.id || connectionRequest.id || request.provider,
};
}
return {
authorizationUrl: connectionRequest.redirectUrl,
state: connectionRequest.id || request.provider,
};
} catch (error) {
if (isNoAuthToolkitError(error)) {
return { authorizationUrl: null, state: request.provider };
}
throw new Error(sanitizeComposioError(error));
}
}
async connectionReady(context: AdapterContext, slug: string): Promise<boolean> {
const session = await this.sessionFor(context.userId);
const page = await session.toolkits({ search: slug, limit: 50 });
const match = page.items.find((item) => composioSlugKey(item.slug) === composioSlugKey(slug));
if (!match) return false;
return Boolean(match.connection?.isActive) || Boolean(match.isNoAuth);
}
async complete(
request: { state: string; code?: string },
_context: AdapterContext,
): Promise<{ connectionRef: string }> {
return { connectionRef: request.state };
}
async revoke(connectionRef: string, context: AdapterContext): Promise<void> {
// Legacy rows may store a toolkit slug. Begin's browser OAuth state may be a
// connection-request id. Resolve either to a concrete connected-account id
// before delete — never pass a raw request id to connectedAccounts.delete.
const requestOptions = { signal: context.signal };
let accountId: string | undefined;
try {
const listed = await this.listConnectedAccountIds(
context.userId,
connectionRef,
requestOptions,
);
accountId = listed[0];
if (!accountId) {
try {
const account = await this.sdk().connectedAccounts.waitForConnection(
connectionRef,
20_000,
);
accountId = account?.id;
} catch {
// Not a resolvable request id; fall through to treat ref as an account id.
}
}
} catch (error) {
// Resolution failed before any DELETE — mark so callers can restore local retry state.
if (error && typeof error === "object") {
(error as { remoteRevokePreDelete?: boolean }).remoteRevokePreDelete = true;
}
throw error;
}
accountId = accountId ?? connectionRef;
await this.sdk().connectedAccounts.delete(accountId, requestOptions);
}
async connectedAccountId(userId: string, slug: string): Promise<string | undefined> {
const ids = await this.listConnectedAccountIds(userId, slug);
return ids[0];
}
async listConnectedAccountIds(
userId: string,
slug: string,
requestOptions?: { signal?: AbortSignal },
): Promise<string[]> {
try {
const listed = await this.sdk().connectedAccounts.list(
{
userIds: [userId],
toolkitSlugs: [slug],
statuses: ["ACTIVE"],
},
requestOptions,
);
const ids = (listed.items ?? [])
.map((item) => item.id)
.filter((id): id is string => Boolean(id));
if (ids.length > 0) return ids;
} catch {
// List may be unavailable; fall through to toolkit metadata.
}
if (requestOptions?.signal?.aborted) {
throw requestOptions.signal.reason instanceof Error
? requestOptions.signal.reason
: new Error("Aborted");
}
const session = await this.sessionFor(userId);
const toolkits = await session.toolkits({ isConnected: true });
const id = toolkits.items.find((item) => composioSlugKey(item.slug) === composioSlugKey(slug))
?.connection?.connectedAccount?.id;
return id ? [id] : [];
}
/**
* Cancel an in-flight browser OAuth authorization by its connection-request id.
* Composio uses the connected-account nanoid as the request id (INITIATED until
* OAuth finishes); deleting it invalidates the authorization URL so a revoke-win
* cannot leave an untracked remote after the user completes the orphaned link.
*/
async cancelAuthorizationRequest(requestId: string, context: AdapterContext): Promise<void> {
const id = requestId.trim();
if (!id) return;
// Connection-request ids are connected-account nanoids. Delete directly so a
// revoke-win begin does not wait for OAuth to finish, and the authorization
// URL cannot later create an untracked remote. Ignore missing ids.
try {
await this.sdk().connectedAccounts.delete(id, { signal: context.signal });
} catch (error) {
if (!isComposioNotFoundError(error)) throw error;
}
}
/**
* Map a begin() state (connection-request id, account id, or provider slug) to
* the connected-account id revoke must delete. Prefer unused remotes so a
* second Gmail connect does not reuse the first account's id.
*/
async resolveConnectedAccountId(
userId: string,
slug: string,
currentRef: string | null | undefined,
excludeIds: string[] = [],
_spaceId?: string,
): Promise<string | undefined> {
const excluded = new Set(excludeIds.filter(Boolean));
const current = currentRef?.trim() || undefined;
const slugKey = composioSlugKey(slug);
if (current && composioSlugKey(current) !== slugKey) {
try {
const account = await this.sdk().connectedAccounts.waitForConnection(current, 20_000);
if (account?.id && !excluded.has(account.id)) return account.id;
} catch {
// Request id could not be resolved. Do not fall back to another connected
// account — that can attach a sibling's remote identity to this row.
}
return undefined;
}
const ids = await this.listConnectedAccountIds(userId, slug);
if (current && ids.includes(current) && !excluded.has(current)) return current;
return ids.find((id) => !excluded.has(id));
}
private sdk(): Composio {
this.client ??= new Composio(this.apiKey ? { apiKey: this.apiKey } : undefined);
return this.client;
}
}
function isComposioNotFoundError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const candidate = error as { status?: unknown; statusCode?: unknown };
return candidate.status === 404 || candidate.statusCode === 404;
}
export class ConnectorRegistry implements ConnectorProvider {
private readonly providers = new Map<string, ConnectorProvider>();
constructor(
readonly destination: DestinationEmulator,
providers: ConnectorProvider[],
) {
this.providers.set("destination", destination);
for (const provider of providers) {
const id = provider.describe().id;
if (this.providers.has(id)) throw new Error(`Duplicate connector id ${id}`);
this.providers.set(id, provider);
}
}
managedProviders(): ManagedConnectorProvider[] {
return [...this.providers.values()].filter(isManagedConnectorProvider);
}
managed(id: string): ManagedConnectorProvider | undefined {
const provider = this.providers.get(id);
return provider && isManagedConnectorProvider(provider) ? provider : undefined;
}
describe() {
return {
id: "connector-registry",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { discover: true, oauth: true, secretsBrokered: true },
};
}
async discoverTools(context: AdapterContext): Promise<ConnectorTool[]> {
const discovered: ConnectorTool[] = [];
const used = new Set<string>();
const providerTools = await Promise.all(
[...this.providers].map(async ([connectorId, provider]) => {
try {
return [connectorId, await provider.discoverTools(context)] as const;
} catch (error) {
getLogger().error("connector discovery failed", sanitizeComposioError(error), {
"connector.id": connectorId,
});
return [connectorId, []] as const;
}
}),
);
for (const [connectorId, tools] of providerTools) {
for (const tool of tools) {
let name = tool.name;
if (used.has(name)) name = `${connectorId}.${name}`;
let suffix = 2;
while (used.has(name)) {
name = `${connectorId}.${tool.name}.${suffix}`;
suffix += 1;
}
used.add(name);
discovered.push({
...tool,
name,
route: tool.route ?? { connectorId, toolName: tool.name },
});
}
}
return discovered;
}
async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable<ConnectorEvent> {
const connectorId =
call.route?.connectorId ?? (call.tool === "destination.write" ? "destination" : "composio");
const provider = this.providers.get(connectorId);
if (!provider) {
yield { type: "error", message: `unknown connector ${connectorId}` };
return;
}
yield* provider.execute({ ...call, tool: call.route?.toolName ?? call.tool }, context);
}
async resolveCall(
call: ConnectorCall,
context: AdapterContext,
): Promise<{ call: ConnectorCall; tool: ConnectorTool } | undefined> {
const connectorId = call.route?.connectorId;
if (!connectorId) return undefined;
const provider = this.providers.get(connectorId);
return provider?.resolveCall?.({ ...call, tool: call.route?.toolName ?? call.tool }, context);
}
}
/** @deprecated Use ConnectorRegistry. */
export const CompositeConnector = ConnectorRegistry;
function isManagedConnectorProvider(
provider: ConnectorProvider,
): provider is ManagedConnectorProvider {
const candidate = provider as Partial<ManagedConnectorProvider>;
return (
typeof candidate.catalog === "function" &&
typeof candidate.begin === "function" &&
typeof candidate.complete === "function" &&
typeof candidate.connectionReady === "function" &&
typeof candidate.listConnectedExternalIds === "function" &&
typeof candidate.revoke === "function"
);
}
function connectedComposioConnections(context: AdapterContext): ConnectedConnector[] {
return (
context.connectedConnections?.filter((connection) => connection.connectorId === "composio") ??
(context.connectedProviders ?? []).map((externalId) => ({
id: `legacy:${externalId}`,
connectorId: "composio",
externalId,
displayName: externalId,
}))
);
}
export function createConnectorStack(
composioEnabled: boolean,
composioOverride?: ComposioProvider,
additionalProviders: ConnectorProvider[] = [],
) {
const destination = new DestinationEmulator();
const composio = composioOverride ?? (composioEnabled ? new ComposioConnector() : undefined);
return {
destination,
composio,
connector: new ConnectorRegistry(destination, [
...(composio ? [composio] : []),
...additionalProviders,
]),
};
}
export function collectLogIds(value: unknown): string[] {
const ids: string[] = [];
const seen = new Set<string>();
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
const record = node as Record<string, unknown>;
for (const [key, nested] of Object.entries(record)) {
if (
(key === "logId" || key === "log_id") &&
typeof nested === "string" &&
nested &&
!seen.has(nested)
) {
seen.add(nested);
ids.push(nested);
} else {
walk(nested);
}
}
};
walk(value);
return ids;
}
export function isNoAuthToolkitError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("ToolkitsIsNoAuth") || message.includes("does not require authentication")
);
}
export function sanitizeComposioError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return redactConnectorText(message);
}
function sanitizePayload(data: unknown): unknown {
try {
return JSON.parse(redactConnectorText(JSON.stringify(data)));
} catch {
return { ok: true };
}
}
function redactConnectorText(value: string): string {
return value
.replace(/COMPOSIO_API_KEY[=:]?\s*\S+/gi, "COMPOSIO_API_KEY=[redacted]")
.replace(/ak_[A-Za-z0-9]+/g, "[redacted]")
.replace(/ck_[A-Za-z0-9]+/g, "[redacted]")
.replace(/sk-or-v1-[A-Za-z0-9]+/g, "[redacted]")
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
}