mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02: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>
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { ComposioApiError, createComposioClient } from "./composio.js";
|
|
|
|
type Fixture = {
|
|
baseUrl: string;
|
|
requests: Array<{ method: string; url: string; apiKey: string | undefined }>;
|
|
close(): Promise<void>;
|
|
};
|
|
|
|
async function startFixture(
|
|
handle: (request: IncomingMessage, response: ServerResponse) => void,
|
|
): Promise<Fixture> {
|
|
const requests: Fixture["requests"] = [];
|
|
const server = createServer((request, response) => {
|
|
requests.push({
|
|
method: request.method ?? "GET",
|
|
url: request.url ?? "/",
|
|
apiKey: Array.isArray(request.headers["x-api-key"])
|
|
? request.headers["x-api-key"]?.[0]
|
|
: request.headers["x-api-key"],
|
|
});
|
|
handle(request, response);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Fixture did not bind a TCP port");
|
|
return {
|
|
baseUrl: `http://127.0.0.1:${address.port}/api/v3.1`,
|
|
requests,
|
|
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
};
|
|
}
|
|
|
|
const fixtures: Fixture[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(fixtures.splice(0).map((fixture) => fixture.close()));
|
|
});
|
|
|
|
describe("Composio REST client", () => {
|
|
it("serializes every supported REST operation", async () => {
|
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
|
const fetchMock = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
requests.push({
|
|
url: String(input),
|
|
method: init?.method ?? "GET",
|
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
});
|
|
return new Response(JSON.stringify({ items: [], session_id: "session-1", mcp: { url: "https://mcp.test" } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}) as typeof fetch;
|
|
const client = createComposioClient({
|
|
apiKey: "ak_fixture",
|
|
baseUrl: "https://composio.test/api/v3.1",
|
|
fetch: fetchMock,
|
|
});
|
|
|
|
await client.listAuthConfigs({
|
|
cursor: "auth-page",
|
|
limit: 25,
|
|
toolkitSlugs: ["github", "slack"],
|
|
showDisabled: true,
|
|
});
|
|
await client.createConnectLink({
|
|
authConfigId: "ac_1",
|
|
userId: "user-1",
|
|
alias: "primary",
|
|
callbackUrl: "https://paperclip.test/callback",
|
|
});
|
|
await client.listConnectedAccounts({
|
|
cursor: "account-page",
|
|
limit: 10,
|
|
toolkitSlugs: ["github", "slack"],
|
|
statuses: ["ACTIVE", "EXPIRED"],
|
|
userIds: ["user-1", "user-2"],
|
|
authConfigIds: ["ac_1", "ac_2"],
|
|
});
|
|
await client.deleteConnectedAccount("ca/with spaces");
|
|
await client.createSession("user-1", {
|
|
mcp: true,
|
|
toolkits: ["github"],
|
|
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
|
|
authConfigs: { github: "ac_1" },
|
|
connectedAccounts: { github: ["ca_1"] },
|
|
});
|
|
await client.resumeSession("session/with spaces", { mcp: true });
|
|
|
|
expect(requests).toEqual([
|
|
{
|
|
url: "https://composio.test/api/v3.1/auth_configs?cursor=auth-page&limit=25&show_disabled=true&toolkit_slug=github%2Cslack",
|
|
method: "GET",
|
|
body: undefined,
|
|
},
|
|
{
|
|
url: "https://composio.test/api/v3.1/connected_accounts/link",
|
|
method: "POST",
|
|
body: {
|
|
auth_config_id: "ac_1",
|
|
user_id: "user-1",
|
|
alias: "primary",
|
|
callback_url: "https://paperclip.test/callback",
|
|
},
|
|
},
|
|
{
|
|
url: "https://composio.test/api/v3.1/connected_accounts?cursor=account-page&limit=10&toolkit_slugs=github&toolkit_slugs=slack&statuses=ACTIVE&statuses=EXPIRED&user_ids=user-1&user_ids=user-2&auth_config_ids=ac_1&auth_config_ids=ac_2",
|
|
method: "GET",
|
|
body: undefined,
|
|
},
|
|
{
|
|
url: "https://composio.test/api/v3.1/connected_accounts/ca%2Fwith%20spaces",
|
|
method: "DELETE",
|
|
body: undefined,
|
|
},
|
|
{
|
|
url: "https://composio.test/api/v3.1/tool_router/session",
|
|
method: "POST",
|
|
body: {
|
|
user_id: "user-1",
|
|
mcp: true,
|
|
toolkits: { enabled: ["github"] },
|
|
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
|
|
auth_configs: { github: "ac_1" },
|
|
connected_accounts: { github: ["ca_1"] },
|
|
},
|
|
},
|
|
{
|
|
url: "https://composio.test/api/v3.1/tool_router/session/session%2Fwith%20spaces/attach",
|
|
method: "POST",
|
|
body: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("validates an API key with the cheap toolkit-list call", async () => {
|
|
const fixture = await startFixture((_request, response) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ items: [] }));
|
|
});
|
|
fixtures.push(fixture);
|
|
|
|
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
|
|
await expect(client.validateApiKey()).resolves.toBeUndefined();
|
|
|
|
expect(fixture.requests).toEqual([{
|
|
method: "GET",
|
|
url: "/api/v3.1/toolkits?limit=1",
|
|
apiKey: "ak_fixture",
|
|
}]);
|
|
});
|
|
|
|
it("rejects an invalid key without reflecting the provider response body", async () => {
|
|
const fixture = await startFixture((_request, response) => {
|
|
response.writeHead(401, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ message: "provider-controlled secret detail" }));
|
|
});
|
|
fixtures.push(fixture);
|
|
|
|
const client = createComposioClient({ apiKey: "bad_fixture", baseUrl: fixture.baseUrl });
|
|
const error = await client.validateApiKey().catch((caught) => caught);
|
|
|
|
expect(error).toBeInstanceOf(ComposioApiError);
|
|
expect(error).toMatchObject({ status: 401, message: "Composio rejected the API key." });
|
|
expect(String(error)).not.toContain("provider-controlled");
|
|
});
|
|
|
|
it("lists typed toolkits from the project behind the key", async () => {
|
|
const fixture = await startFixture((_request, response) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({
|
|
items: [{
|
|
slug: "github",
|
|
name: "GitHub",
|
|
auth_schemes: ["oauth2"],
|
|
meta: { tools_count: 42, logo: "https://example.test/github.png" },
|
|
}],
|
|
next_cursor: "next-page",
|
|
}));
|
|
});
|
|
fixtures.push(fixture);
|
|
|
|
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
|
|
const result = await client.listToolkits({ limit: 10, cursor: "page-1" });
|
|
|
|
expect(result).toEqual({
|
|
items: [{
|
|
slug: "github",
|
|
name: "GitHub",
|
|
auth_schemes: ["oauth2"],
|
|
meta: { tools_count: 42, logo: "https://example.test/github.png" },
|
|
}],
|
|
next_cursor: "next-page",
|
|
});
|
|
expect(fixture.requests[0]?.url).toBe("/api/v3.1/toolkits?cursor=page-1&limit=10");
|
|
});
|
|
});
|