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:
DottaandPaperclip authored and GitHub committed 2026-08-29 12:08:33 -05:00
1 parent 20ccf3f476
commit 6244e4cf32
54 files changed
+5110 -91

No files matched your search

+87 -12
View File
@@ -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();
+2
View File
@@ -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");