mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
feat(apps): add Composio and Gmail connectors (#12342)
## 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>
This commit is contained in:
1 parent
20ccf3f476
commit
6244e4cf32
54 files changed
+5110
-91
No files matched your search
+87
-12
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
@@ -154,9 +154,14 @@ if (bindMode === "custom" && !bindHost) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Managed HTTPS runtimes serve the built UI bundle: the Vite dev middleware's
|
||||
// unbundled module waterfall stalls behind the Tailscale HTTPS proxy and the
|
||||
// first page load in a fresh browser profile stays blank forever (PAP-18043).
|
||||
const explicitUiDevMiddleware = process.env.PAPERCLIP_UI_DEV_MIDDLEWARE;
|
||||
const serveBuiltUiForManagedRuntime = managedRuntimeExposure && explicitUiDevMiddleware === undefined;
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"),
|
||||
};
|
||||
|
||||
if (mode === "dev") {
|
||||
@@ -399,7 +404,7 @@ async function runPnpm(args: string[], options: {
|
||||
|
||||
async function getMigrationStatusPayload() {
|
||||
const status = await runPnpm(
|
||||
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
["--silent", "--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
@@ -411,16 +416,26 @@ async function getMigrationStatusPayload() {
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw toError(error, "Unable to parse migration-status JSON output");
|
||||
// pnpm can interleave its own reporter lines (e.g. "Unsupported engine"
|
||||
// warnings) into stdout, so parse the last line that is a JSON object
|
||||
// instead of trusting the whole stream.
|
||||
const jsonLines = status.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("{"));
|
||||
for (let index = jsonLines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
return JSON.parse(jsonLines[index]) as { status?: string; pendingMigrations?: string[] };
|
||||
} catch {
|
||||
// keep scanning earlier JSON-looking lines
|
||||
}
|
||||
}
|
||||
process.stderr.write(
|
||||
status.stderr ||
|
||||
status.stdout ||
|
||||
"[paperclip] migration-status returned invalid JSON payload\n",
|
||||
);
|
||||
throw new Error("Unable to parse migration-status JSON output");
|
||||
}
|
||||
|
||||
async function refreshPendingMigrations() {
|
||||
@@ -507,6 +522,53 @@ async function buildPluginSdk() {
|
||||
}
|
||||
}
|
||||
|
||||
function newestMtimeMs(target: string): number {
|
||||
const stat = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stat) return 0;
|
||||
if (!stat.isDirectory()) return stat.mtimeMs;
|
||||
let newest = stat.mtimeMs;
|
||||
for (const entry of readdirSync(target)) {
|
||||
if (entry === "node_modules" || entry === ".git" || entry === "dist") continue;
|
||||
const childNewest = newestMtimeMs(path.join(target, entry));
|
||||
if (childNewest > newest) newest = childNewest;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
function uiBundleIsFresh(): boolean {
|
||||
const distIndex = path.join(repoRoot, "ui", "dist", "index.html");
|
||||
const distStat = statSync(distIndex, { throwIfNoEntry: false });
|
||||
if (!distStat) return false;
|
||||
const sources = [
|
||||
path.join(repoRoot, "ui", "src"),
|
||||
path.join(repoRoot, "ui", "public"),
|
||||
path.join(repoRoot, "ui", "index.html"),
|
||||
path.join(repoRoot, "ui", "package.json"),
|
||||
path.join(repoRoot, "ui", "vite.config.ts"),
|
||||
path.join(repoRoot, "packages", "shared", "src"),
|
||||
];
|
||||
return sources.every((source) => newestMtimeMs(source) <= distStat.mtimeMs);
|
||||
}
|
||||
|
||||
async function buildUiBundleForManagedRuntime(): Promise<boolean> {
|
||||
console.log("[paperclip] managed runtime: building the UI bundle for static serving...");
|
||||
const result = await runPnpm(
|
||||
["--filter", "@paperclipai/ui", "build"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.signal) {
|
||||
exitForSignal(result.signal);
|
||||
return false;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
console.error(
|
||||
"[paperclip] UI bundle build failed; falling back to the Vite dev middleware (the page may load slowly or stay blank over HTTPS)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function markChildAsCurrent() {
|
||||
previousSnapshot = collectWatchedSnapshot();
|
||||
dirtyPaths = new Set();
|
||||
@@ -705,7 +767,20 @@ process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
// The managed runtime readiness window is tight, so reuse a fresh bundle
|
||||
// when possible and overlap a needed rebuild with the migration preflight.
|
||||
let uiBundleBuild: Promise<boolean> | null = null;
|
||||
if (serveBuiltUiForManagedRuntime) {
|
||||
if (uiBundleIsFresh()) {
|
||||
console.log("[paperclip] managed runtime: reusing the up-to-date UI bundle in ui/dist");
|
||||
} else {
|
||||
uiBundleBuild = buildUiBundleForManagedRuntime();
|
||||
}
|
||||
}
|
||||
await maybePreflightMigrations();
|
||||
if (uiBundleBuild) {
|
||||
env.PAPERCLIP_UI_DEV_MIDDLEWARE = (await uiBundleBuild) ? "false" : "true";
|
||||
}
|
||||
await startServerChild();
|
||||
installDevIntervals();
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@ const apps=[
|
||||
["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})],
|
||||
["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})],
|
||||
["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")],
|
||||
["composio","Composio","Connect Composio so Paperclip can discover and manage the toolkits in your project.","productivity","composio.dev",["https://backend.composio.dev/*"],method("api-key","rest_api","api_key",{serviceHost:"backend.composio.dev"},"S3","Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.",{whenToUse:"Use a project API key from the Composio project that owns the toolkits and connected accounts.",credentialFields:[field("apiKey","Composio project API key","Paste the Composio API key")],keyPlacement:{location:"header",name:"x-api-key"},consoleLinks:{keys:"https://app.composio.dev/",settings:"https://app.composio.dev/",docs:"https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions"}}),{featured:true}],
|
||||
["oauth-generic","OAuth app","Connect a provider using your own OAuth client.","other","oauth.net",[],method("oauth","rest_api","oauth",{},"S3","Register an OAuth client with the provider and add Paperclip's redirect URI.",{credentialFields:[{...field("clientId","Client ID","Paste the client ID"),type:"text",secret:false},field("clientSecret","Client secret","Paste the client secret")]})],
|
||||
["api-key-generic","API key app","Connect an API using a key from your provider.","other","openapis.org",[],method("api-key","rest_api","api_key",{},"S3","Create a restricted API key and paste it here.",{credentialFields:[field("apiKey","API key","Paste the API key")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})],
|
||||
["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})],
|
||||
["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})],
|
||||
["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})],
|
||||
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","posthog","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:Array.isArray(m)?m:[m],...extra}));
|
||||
apps.push({schemaVersion:1,slug:"gmail",name:"Gmail",description:"Search and read Gmail messages and create drafts without enabling mail sending.",categories:["communication","productivity"],featured:true,branding:{logoUrl:favicon("gmail.com")},urlPatterns:["https://gmailmcp.googleapis.com/*"],docsUrl:"https://developers.google.com/workspace/guides/configure-mcp-servers",redirectConstraints:"https-or-loopback-http",methods:[{key:"paperclip-id-oauth",label:"Connect Gmail",transport:"mcp_remote",auth:"oauth",oauthStrategy:"paperclip_id_connector",grantKinds:["user"],ownershipModes:["customer"],whenToUse:"Use Paperclip ID for a personal Gmail connection with centrally registered Google OAuth.",defaults:{serverUrl:"https://gmailmcp.googleapis.com/mcp/v1",scopesHint:["https://www.googleapis.com/auth/gmail.readonly","https://www.googleapis.com/auth/gmail.compose"]},guidanceMd:"Connect your Gmail identity. Paperclip can search and read mail and create drafts. Sending mail is not enabled.",warnings:["This connection is personal. Agents need an explicit install, profile, and delegation before they can use it."],riskTier:"S3"}]});
|
||||
const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim());
|
||||
const parseCapture=(fileName)=>{
|
||||
const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8");
|
||||
|
||||
Reference in new issue
Block a user