mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - App connections need both direct providers and managed provider hubs. > - The grant layer now defines safe credential ownership. > - Composio needs parent and child connection lifecycle rules, and Gmail needs governed setup. > - This pull request adds both connector families on the grant foundation. > - The benefit is broader app access without weakening credential isolation. ## Linked Issues or Issue Description Refs #11965 This is stack 4 of 11. It depends on stack 3 and replaces another reviewable part of #11965. ## What Changed - Add Composio parent and child connection support. - Add Gmail connection setup and governance. - Preserve credential paths and remove duplicate binding declarations. - Cascade Composio pause and restore actions to child connections. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - Result: 164 tests passed. - `pnpm build` ## Risks - Parent lifecycle changes can affect every Composio child. - The service restores only children whose provider accounts remain active. - Credential binding paths are normalized before secret resolution. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
const DEFAULT_COMPOSIO_API_BASE_URL = "https://backend.composio.dev/api/v3.1";
|
|
|
|
export class ComposioApiError extends Error {
|
|
constructor(message: string, readonly status: number) {
|
|
super(message);
|
|
this.name = "ComposioApiError";
|
|
}
|
|
}
|
|
|
|
export type ComposioPage<T> = {
|
|
items: T[];
|
|
next_cursor?: string | null;
|
|
total_pages?: number;
|
|
current_page?: number;
|
|
total_items?: number;
|
|
};
|
|
|
|
export type ComposioToolkit = {
|
|
slug: string;
|
|
name: string;
|
|
type?: string;
|
|
auth_schemes?: string[];
|
|
composio_managed_auth_schemes?: string[];
|
|
no_auth?: boolean;
|
|
auth_guide_url?: string | null;
|
|
meta?: {
|
|
description?: string | null;
|
|
logo?: string | null;
|
|
app_url?: string | null;
|
|
tools_count?: number;
|
|
triggers_count?: number;
|
|
version?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ComposioAuthConfig = {
|
|
id: string;
|
|
uuid?: string;
|
|
name?: string;
|
|
auth_scheme: string;
|
|
is_composio_managed: boolean;
|
|
status?: string;
|
|
toolkit: { slug: string; logo?: string | null; auth_guide_url?: string | null; auth_hint_url?: string | null };
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ComposioConnectLink = {
|
|
link_token: string;
|
|
redirect_url: string;
|
|
expires_at: string;
|
|
connected_account_id?: string | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ComposioConnectedAccount = {
|
|
id: string;
|
|
user_id: string;
|
|
status: string;
|
|
status_reason?: string | null;
|
|
is_disabled?: boolean;
|
|
toolkit: { slug: string };
|
|
auth_config: { id: string; auth_scheme: string; is_composio_managed: boolean; is_disabled?: boolean };
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ComposioSession = {
|
|
session_id: string;
|
|
mcp: { type?: string; url: string; headers?: Record<string, string> };
|
|
config?: Record<string, unknown>;
|
|
config_version?: number;
|
|
warnings?: Array<{ code: string; message: string }>;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type ComposioListOptions = { cursor?: string; limit?: number };
|
|
export type ComposioSessionOptions = {
|
|
mcp: true;
|
|
toolkits?: string[];
|
|
tools?: Record<string, { enable: string[] }>;
|
|
authConfigs?: Record<string, string>;
|
|
connectedAccounts?: Record<string, string[]>;
|
|
};
|
|
|
|
export interface ComposioClient {
|
|
validateApiKey(): Promise<void>;
|
|
listToolkits(options?: ComposioListOptions): Promise<ComposioPage<ComposioToolkit>>;
|
|
listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise<ComposioPage<ComposioAuthConfig>>;
|
|
createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise<ComposioConnectLink>;
|
|
listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise<ComposioPage<ComposioConnectedAccount>>;
|
|
deleteConnectedAccount(connectedAccountId: string): Promise<void>;
|
|
createSession(userId: string, options: ComposioSessionOptions): Promise<ComposioSession>;
|
|
resumeSession(sessionId: string, options: { mcp: true }): Promise<ComposioSession>;
|
|
}
|
|
|
|
type ComposioClientOptions = { apiKey: string; baseUrl?: string; fetch?: typeof globalThis.fetch };
|
|
|
|
function appendQuery(url: URL, key: string, value: string | number | boolean | undefined) {
|
|
if (value !== undefined) url.searchParams.append(key, String(value));
|
|
}
|
|
|
|
function appendQueryList(url: URL, key: string, values: string[] | undefined) {
|
|
for (const value of values ?? []) url.searchParams.append(key, value);
|
|
}
|
|
|
|
function errorMessage(status: number): string {
|
|
if (status === 401 || status === 403) return "Composio rejected the API key.";
|
|
if (status === 429) return "Composio rate-limited the request. Try again shortly.";
|
|
return `Composio request failed with HTTP ${status}.`;
|
|
}
|
|
|
|
export function createComposioClient(options: ComposioClientOptions): ComposioClient {
|
|
const apiKey = options.apiKey.trim();
|
|
if (!apiKey) throw new Error("A Composio API key is required.");
|
|
const baseUrl = (options.baseUrl ?? DEFAULT_COMPOSIO_API_BASE_URL).replace(/\/+$/, "");
|
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
|
|
async function request<T>(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise<T> {
|
|
const url = new URL(`${baseUrl}/${path.replace(/^\/+/, "")}`);
|
|
query?.(url);
|
|
let response: Response;
|
|
try {
|
|
response = await fetchImpl(url, {
|
|
...init,
|
|
headers: {
|
|
accept: "application/json",
|
|
"x-api-key": apiKey,
|
|
...(init.body ? { "content-type": "application/json" } : {}),
|
|
...init.headers,
|
|
},
|
|
});
|
|
} catch {
|
|
throw new ComposioApiError("Could not reach Composio.", 0);
|
|
}
|
|
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
|
|
try {
|
|
return await response.json() as T;
|
|
} catch {
|
|
throw new ComposioApiError("Composio returned an invalid response.", response.status);
|
|
}
|
|
}
|
|
|
|
return {
|
|
async validateApiKey() {
|
|
await this.listToolkits({ limit: 1 });
|
|
},
|
|
listToolkits(options = {}) {
|
|
return request<ComposioPage<ComposioToolkit>>("toolkits", {}, (url) => {
|
|
appendQuery(url, "cursor", options.cursor);
|
|
appendQuery(url, "limit", options.limit);
|
|
});
|
|
},
|
|
listAuthConfigs(options = {}) {
|
|
return request<ComposioPage<ComposioAuthConfig>>("auth_configs", {}, (url) => {
|
|
appendQuery(url, "cursor", options.cursor);
|
|
appendQuery(url, "limit", options.limit);
|
|
appendQuery(url, "show_disabled", options.showDisabled);
|
|
appendQuery(url, "toolkit_slug", options.toolkitSlugs?.join(","));
|
|
});
|
|
},
|
|
createConnectLink(input) {
|
|
return request<ComposioConnectLink>("connected_accounts/link", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
auth_config_id: input.authConfigId,
|
|
user_id: input.userId,
|
|
...(input.alias ? { alias: input.alias } : {}),
|
|
...(input.callbackUrl ? { callback_url: input.callbackUrl } : {}),
|
|
}),
|
|
});
|
|
},
|
|
listConnectedAccounts(options = {}) {
|
|
return request<ComposioPage<ComposioConnectedAccount>>("connected_accounts", {}, (url) => {
|
|
appendQuery(url, "cursor", options.cursor);
|
|
appendQuery(url, "limit", options.limit);
|
|
appendQueryList(url, "toolkit_slugs", options.toolkitSlugs);
|
|
appendQueryList(url, "statuses", options.statuses);
|
|
appendQueryList(url, "user_ids", options.userIds);
|
|
appendQueryList(url, "auth_config_ids", options.authConfigIds);
|
|
});
|
|
},
|
|
async deleteConnectedAccount(connectedAccountId) {
|
|
const url = new URL(`${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`);
|
|
let response: Response;
|
|
try {
|
|
response = await fetchImpl(url, {
|
|
method: "DELETE",
|
|
headers: { accept: "application/json", "x-api-key": apiKey },
|
|
});
|
|
} catch {
|
|
throw new ComposioApiError("Could not reach Composio.", 0);
|
|
}
|
|
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
|
|
},
|
|
createSession(userId, options) {
|
|
return request<ComposioSession>("tool_router/session", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
user_id: userId,
|
|
mcp: options.mcp,
|
|
...(options.toolkits ? { toolkits: { enabled: options.toolkits } } : {}),
|
|
...(options.tools ? { tools: options.tools } : {}),
|
|
...(options.authConfigs ? { auth_configs: options.authConfigs } : {}),
|
|
...(options.connectedAccounts ? { connected_accounts: options.connectedAccounts } : {}),
|
|
}),
|
|
});
|
|
},
|
|
resumeSession(sessionId, _options) {
|
|
return request<ComposioSession>(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
},
|
|
};
|
|
}
|