From 6244e4cf32bd43b6923f0c0d7b55cc830e91d7fa Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:08:33 -0500 Subject: [PATCH] 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 --- .env.example | 8 + doc/connections/GMAIL.md | 358 ++++++++ doc/connections/README.md | 2 +- packages/db/src/schema/tool_access.ts | 11 +- .../shared/src/app-definitions-url.test.ts | 8 + .../shared/src/app-definitions.generated.ts | 14 +- packages/shared/src/app-definitions.test.ts | 3 +- packages/shared/src/app-definitions.ts | 2 + .../shared/src/app-definitions/composio.json | 45 + .../shared/src/app-definitions/gmail.json | 38 + packages/shared/src/types/app-definition.ts | 4 +- packages/shared/src/types/tool-access.ts | 11 +- .../shared/src/validators/app-definition.ts | 4 +- packages/shared/src/validators/tool-access.ts | 6 + scripts/dev-runner.ts | 99 ++- scripts/ingest-app-definitions.mjs | 2 + .../__tests__/generic-mcp-connection.test.ts | 173 ++++ server/src/__tests__/issues-service.test.ts | 141 ++++ .../src/__tests__/tool-access-service.test.ts | 228 +++++- server/src/__tests__/tool-gateway.test.ts | 139 ++++ server/src/app.ts | 13 + server/src/routes/openapi.ts | 35 + server/src/routes/tool-access.ts | 117 ++- .../src/services/composio-session-manager.ts | 263 ++++++ server/src/services/composio.test.ts | 198 +++++ server/src/services/composio.ts | 216 +++++ .../services/gmail-tool-governance.test.ts | 17 + server/src/services/index.ts | 1 + server/src/services/issues.ts | 22 +- .../paperclip-id-gmail-connector.test.ts | 160 ++++ .../services/paperclip-id-gmail-connector.ts | 340 ++++++++ server/src/services/tool-access.ts | 773 +++++++++++++++++- server/src/services/tool-gateway.ts | 168 +++- ui/src/api/tools.ts | 28 +- ui/src/components/AppConnectionSidebar.tsx | 16 +- ui/src/lib/app-gallery-copy.ts | 4 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/DesignGuide.tsx | 98 +++ ui/src/pages/apps/AppDetail.test.tsx | 6 + ui/src/pages/apps/AppDetail.tsx | 21 +- ui/src/pages/apps/AppsConnect.test.tsx | 27 + ui/src/pages/apps/AppsConnect.tsx | 21 +- ui/src/pages/apps/Browse.test.tsx | 24 +- ui/src/pages/apps/Browse.tsx | 5 +- ui/src/pages/apps/ComposioProvenanceChip.tsx | 54 ++ ui/src/pages/apps/Connections.test.tsx | 54 +- ui/src/pages/apps/Connections.tsx | 26 +- .../app-detail/AdvancedPanel.render.test.tsx | 16 + .../pages/apps/app-detail/AdvancedPanel.tsx | 16 +- .../app-detail/ServicesPanel.render.test.tsx | 244 ++++++ .../pages/apps/app-detail/ServicesPanel.tsx | 435 ++++++++++ ui/src/pages/apps/app-tabs.ts | 23 +- ui/src/pages/apps/composio-services.test.ts | 237 ++++++ ui/src/pages/apps/composio-services.ts | 225 +++++ 54 files changed, 5110 insertions(+), 91 deletions(-) create mode 100644 doc/connections/GMAIL.md create mode 100644 packages/shared/src/app-definitions/composio.json create mode 100644 packages/shared/src/app-definitions/gmail.json create mode 100644 server/src/services/composio-session-manager.ts create mode 100644 server/src/services/composio.test.ts create mode 100644 server/src/services/composio.ts create mode 100644 server/src/services/gmail-tool-governance.test.ts create mode 100644 server/src/services/paperclip-id-gmail-connector.test.ts create mode 100644 server/src/services/paperclip-id-gmail-connector.ts create mode 100644 ui/src/pages/apps/ComposioProvenanceChip.tsx create mode 100644 ui/src/pages/apps/app-detail/ServicesPanel.render.test.tsx create mode 100644 ui/src/pages/apps/app-detail/ServicesPanel.tsx create mode 100644 ui/src/pages/apps/composio-services.test.ts create mode 100644 ui/src/pages/apps/composio-services.ts diff --git a/.env.example b/.env.example index ddd69484ba..d90358bd85 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,14 @@ SERVE_UI=false BETTER_AUTH_SECRET=paperclip-dev-secret PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-change-me +# Optional Paperclip ID Gmail OAuth broker. Enroll the instance first; keep both +# private keys in the deployment secret manager. HTTP base URLs must be loopback. +# PAPERCLIP_ID_CONNECTOR_BASE_URL=http://localhost:3000 +# PAPERCLIP_ID_CONNECTOR_ENVIRONMENT=development +# PAPERCLIP_ID_CONNECTOR_INSTANCE_ID=inst_example +# PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY= +# PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY= + # Process-wide protection for expensive full-tree workspace Git scans. # PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY=2 # PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY=32 diff --git a/doc/connections/GMAIL.md b/doc/connections/GMAIL.md new file mode 100644 index 0000000000..15698db4b8 --- /dev/null +++ b/doc/connections/GMAIL.md @@ -0,0 +1,358 @@ +# Gmail connection + +Paperclip connects to Google's hosted Gmail MCP server at +`https://gmailmcp.googleapis.com/mcp/v1`. Gmail authorization is separate from +Google sign-in: + +- Google sign-in identifies a Paperclip ID user and requests only + `openid email profile`. +- Gmail authorization lets that user's agents search and read mail and create + drafts. It requests only `gmail.readonly` and `gmail.compose`. + +Do not add Gmail scopes to the Google sign-in client. Paperclip ID hosts the +public Gmail OAuth callback, while the originating Paperclip instance remains +the durable owner of the encrypted access and refresh tokens. + +> Google Workspace MCP is a Developer Preview. Enroll the required Workspace +> organization and test accounts in Google's Developer Preview Program before +> relying on the service. + +## Deployment layout + +Use a separate Google Cloud project and OAuth web client for each environment: + +| Environment | Suggested project id | OAuth client name | Authorized redirect URI | +| --- | --- | --- | --- | +| Development | `paperclip-gmail-dev` | `Paperclip Gmail Connection Dev` | `http://localhost:3000/api/connect/oauth/google/callback` | +| Staging | `paperclip-gmail-staging` | `Paperclip Gmail Connection Staging` | `https://id-staging.paperclip.app/api/connect/oauth/google/callback` | +| Production | `paperclip-gmail-prod` | `Paperclip Gmail Connection Production` | `https://id.paperclip.app/api/connect/oauth/google/callback` | + +Replace the development port if the local Paperclip ID service uses another +port. Do not register Tailscale, customer, or other self-hosted Paperclip +instance URLs with Google. The browser always returns to Paperclip ID first; +Paperclip ID then sends an opaque, one-time claim identifier to the exact +originating instance URL that was enrolled before the flow began. + +Keeping projects separate is a Paperclip release policy. It prevents a +development credential or consent-screen change from affecting production and +keeps restricted-scope Gmail verification independent of Google sign-in. + +## Google Cloud setup + +Repeat this procedure in development, staging, and production. Complete and +test development first, then staging. Do not enable production authorization +until Google verification and Paperclip Security review are complete. + +### 1. Create the project + +1. Open [Google Cloud project creation](https://console.cloud.google.com/projectcreate). +2. Select the Paperclip Cloud organization and billing account. +3. Create the environment-specific project from the table above. +4. Limit Owner and Editor access to the smallest operator group. +5. Add a monitored engineering or security contact. +6. Record the project id in the private environment runbook. Do not put a + client secret in the runbook or repository. + +### 2. Enable Gmail and Gmail MCP + +In **APIs & Services → Library**, enable: + +- Gmail API: `gmail.googleapis.com` +- Gmail MCP API: `gmailmcp.googleapis.com` + +The equivalent command is: + +```sh +gcloud services enable \ + gmail.googleapis.com \ + gmailmcp.googleapis.com \ + --project=PROJECT_ID +``` + +Do not enable Drive, Docs, Sheets, Calendar, Chat, or People for the Gmail-only +release. + +### 3. Configure branding + +Open **Google Auth Platform → Branding**. Set: + +- App name: `Paperclip` +- User support email: a monitored support address +- Logo: the approved Paperclip logo +- Homepage: the public Paperclip product page +- Privacy policy: the public policy that describes Gmail data handling +- Terms of service: the public Paperclip terms +- Authorized domain: `paperclip.app` +- Developer contact: a monitored security or engineering group + +The homepage, privacy policy, and terms must be live on the verified domain +before production verification. The privacy policy must explain that the +originating Paperclip instance stores Gmail credentials and that Paperclip ID +performs bounded OAuth exchange, refresh, and revocation without durable +plaintext token storage. + +### 4. Configure the audience + +Open **Google Auth Platform → Audience**. + +- Development: select **External**, keep the app in **Testing**, and add only + developer test accounts. +- Staging: select **External**, keep the app in **Testing**, and add only QA, + security-review, and verification accounts. +- Production: select **External** and move to **In production** only after the + required restricted-scope verification and security work is complete. + +Google limits an external testing app to 100 test users. For non-basic scopes, +testing grants and their offline refresh tokens can expire after seven days. +Treat that expiry as expected test behavior. + +### 5. Add the exact scopes + +Open **Google Auth Platform → Data Access → Add or remove scopes → Manually add +scopes** and add only: + +```text +https://www.googleapis.com/auth/gmail.readonly +https://www.googleapis.com/auth/gmail.compose +``` + +Do not add `mail.google.com`, `gmail.modify`, `gmail.send`, Drive, Calendar, or +profile/sign-in scopes. Gmail read and compose are restricted scopes. Public +production use therefore requires Google's restricted-scope verification and +may require an independent security assessment for server-side handling. + +### 6. Create the OAuth client + +Open **Google Auth Platform → Clients → Create Client**: + +1. Select **Web application**. +2. Enter the environment-specific client name from the table above. +3. Add exactly the matching authorized redirect URI. +4. Leave **Authorized JavaScript origins** empty. This is a server-side flow. +5. Create the client. +6. Copy the client id and newly displayed secret directly into the matching + deployment secret manager. + +Never paste either credential into an issue, document, chat, screenshot, +committed `.env`, build log, or browser-visible configuration. Step 7 lists the +deployment variables that receive them. + +### 7. Configure the Paperclip ID broker deployment + +Set these on the Paperclip ID service that owns the redirect URI above. This is +the broker half of the configuration; the originating Paperclip instance is +configured separately under [Configure each originating Paperclip +instance](#configure-each-originating-paperclip-instance). + +| Variable | Development | Staging | Production | +| --- | --- | --- | --- | +| `GOOGLE_GMAIL_CLIENT_ID` | Dev client id | Staging client id | Production client id | +| `GOOGLE_GMAIL_CLIENT_SECRET` | Dev client secret | Staging client secret | Production client secret | +| `GOOGLE_GMAIL_REDIRECT_URI` | `http://localhost:3000/api/connect/oauth/google/callback` | `https://id-staging.paperclip.app/api/connect/oauth/google/callback` | `https://id.paperclip.app/api/connect/oauth/google/callback` | +| `GOOGLE_GMAIL_CONNECTOR_ENABLED` | `true` once dev testing starts | `true` after dev sign-off | `true` only after Google verification and Security review | +| `CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` | + +The three credential variables must be set together. Setting some but not all +of them fails validation at boot, and enabling the connector without all three +fails as well. + +`GOOGLE_GMAIL_REDIRECT_URI` is also checked at boot: its path must equal +`/api/connect/oauth/google/callback` exactly, or the service refuses to start. +A redirect URI that points at a path this service does not serve is accepted by +Google and then fails on Google's own error page at the moment a user consents, +where no Paperclip log can see it. + +`GOOGLE_GMAIL_CONNECTOR_ENABLED` is the kill switch, and it is off unless it is +set to `true`, `1`, `yes`, or `on` (case-insensitive). While it is off, every +`/api/connect` route answers `503 CONNECTOR_DISABLED` without touching the +database or Google. + +Set `CONNECTOR_ENVIRONMENT` explicitly in every environment. Every signed +connector request declares its own environment, and the broker accepts the +request only when that value matches both this deployment's environment and the +environment recorded on the enrolled instance. That three-way match is what +makes a leaked staging instance key inert against production, so it must equal +the instance's `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT`. + +Do not rely on the fallback. When `CONNECTOR_ENVIRONMENT` is unset, the broker +derives the value from the `BASE_URL` host (`id` to production, `id-staging` to +staging, anything else to development). A staging or production deployment on +any other hostname therefore brokers as `development`, and every signed request +from a correctly configured instance fails the environment check. The value is +never derived from `NODE_ENV`, which is `production` on staging too. + +## Connector request requirements + +The Gmail authorization request must use: + +- the Gmail connector client, not the Google sign-in client; +- `/api/connect/oauth/google/callback` on Paperclip ID; +- `response_type=code`; +- the two exact Gmail scopes above; +- `access_type=offline`; +- `prompt=consent` for every connect and explicit reconnect; +- a random, short-lived, single-use state value; and +- PKCE S256. + +Do not send `include_granted_scopes`. After token exchange, compare the granted +scope set with the two required scopes. If either is missing, leave that +personal connection grant inactive and let the user retry deliberately. + +No access token, refresh token, Google authorization code, client secret, or +token fragment may appear in a browser URL. The browser return from Paperclip +ID to the originating instance contains only an opaque one-time claim id. + +## Token custody and instance enrollment + +The expected flow is: + +```mermaid +sequenceDiagram + actor U as User browser + participant P as Originating Paperclip instance + participant I as Paperclip ID connector + participant G as Google OAuth + participant V as Instance encrypted vault + + U->>P: Apps → Gmail → Connect + P->>I: Signed, environment-bound authorization session + I-->>U: Google authorization URL with state and PKCE + U->>G: Grant Gmail read and draft access + G-->>I: Authorization code + I->>G: Exchange with the Gmail client secret + I-->>U: Opaque one-time claim for the enrolled instance + U->>P: Return to exact enrolled instance URL + P->>I: Signed one-time claim + I-->>P: Instance-encrypted token response + P->>V: Encrypt tokens and bind them to the user's grant +``` + +Before an instance can create a session: + +1. The instance generates an Ed25519 signing key and a separate X25519 seal + key. Both private keys stay local; Ed25519 authenticates requests and + X25519 lets Paperclip ID encrypt token responses that only the instance can + open. +2. An operator signs in to Paperclip ID and enrolls the instance. +3. Paperclip ID binds the account, opaque instance id, both public keys, + deployment environment, and exact allowed browser return origins. +4. Tailscale HTTPS origins are allowed only when explicitly enrolled. Loopback + HTTP is development-only. Other plaintext origins are rejected. +5. Create, claim, refresh, and revoke requests are signed, audience-bound, + timestamped, and protected by a one-time `jti` replay cache. + +Paperclip ID may retain instance-encrypted initial-token ciphertext for at most +five minutes. It deletes the ciphertext on claim or expiry and excludes it from +long-term backups. Refresh and revoke handle plaintext only in memory for one +bounded request. + +### Configure each originating Paperclip instance + +Generate the two long-lived instance keys once. PEM-encoded PKCS#8 keys work +directly with Paperclip: + +```sh +openssl genpkey -algorithm ED25519 -out paperclip-id-signing.pem +openssl genpkey -algorithm X25519 -out paperclip-id-sealing.pem +openssl pkey -in paperclip-id-signing.pem -pubout -out paperclip-id-signing.pub.pem +openssl pkey -in paperclip-id-sealing.pem -pubout -out paperclip-id-sealing.pub.pem +``` + +Keep both private files in the instance secret manager. Enroll only the public +files with Paperclip ID, together with the instance id, the matching environment, +and every exact browser return origin. Then configure the originating Paperclip +deployment: + +| Variable | Development | Staging | Production | +| --- | --- | --- | --- | +| `PAPERCLIP_ID_CONNECTOR_BASE_URL` | Local Paperclip ID URL | `https://id-staging.paperclip.app` | `https://id.paperclip.app` | +| `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` | `development` | `staging` | `production` | +| `PAPERCLIP_ID_CONNECTOR_INSTANCE_ID` | Enrolled development instance id | Enrolled staging instance id | Enrolled production instance id | +| `PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY` | Development Ed25519 private key | Staging Ed25519 private key | Production Ed25519 private key | +| `PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY` | Development X25519 private key | Staging X25519 private key | Production X25519 private key | + +Use separate keypairs and instance enrollments across environments. The +connector is unavailable unless all four identity/key variables are present. +HTTP is accepted only for a loopback Paperclip ID URL; staging and production +must use HTTPS. + +## Paperclip access defaults + +The first Gmail release is personal-only: + +- **Just me** is the only credential ownership choice. +- The disclosure states that Gmail access can search/read mail and create + drafts. Sending mail is not enabled. +- A user grant does not automatically authorize an agent. The user must also + install the connection for that agent, select an access profile, and grant + standing delegation before autonomous use. +- Read, search, get, and list tools may be enabled after explicit profile + review. +- Draft creation and label changes require **Ask first**. +- Trash, spam, destructive label changes, newly discovered tools, nested + execution, and any future send tool remain blocked until separately reviewed. + +## Verification checklist + +### Development + +1. Enable the connector only in development. +2. Confirm the broker's `CONNECTOR_ENVIRONMENT` and the instance's + `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` both read `development`. A mismatch + fails every signed request with an environment error before Google is ever + contacted, which looks nothing like a Google misconfiguration. +3. Use an isolated Gmail test mailbox. +4. Connect from localhost and one explicitly enrolled Tailscale HTTPS origin. +5. From the board Test panel, run `list_labels` and a bounded + `search_threads` query. +6. Install the reviewed profile on one test agent and repeat one read-only call + in a fresh agent run. +7. Create a draft through an Ask-first approval and verify no send action is + exposed. +8. Force access-token expiry and verify refresh changes only the originating + instance's encrypted secret version. +9. Revoke the grant and verify the next call fails closed. +10. Confirm sanitized logs, activity, API payloads, agent context, and browser + history contain no credential or authorization code. + +### Staging + +Repeat development verification, then add negative tests for replayed state, +wrong origin, wrong instance, wrong company, wrong user, wrong environment, +expired claim, missing scope, inactive membership, connector outage, and the +seven-day testing-token expiry. + +### Production + +1. Complete Developer Preview enrollment, restricted-scope verification, any + required security assessment, and Paperclip Security review. +2. Configure only the production project credentials in production secrets. +3. Start with an internal allowlist and read tools. +4. Enable Ask-first draft and label tools only after production telemetry is + clean. +5. Keep destructive and send-email capabilities blocked. +6. Keep the environment-specific connector kill switch available. When it is + off, new authorization and refresh fail with an actionable error and never + fall back to Google sign-in or another user's grant. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| `redirect_uri_mismatch` | The client contains the exact environment callback, including scheme, host, port, path, and no extra slash. | +| Test user cannot consent | The account is listed under the environment project's Audience test users and is enrolled in Workspace Developer Preview. | +| Refresh fails after seven days | The external app is still in Testing. Reauthorize the test user; do not treat this as token-rotation failure. | +| One required capability is missing | Inspect the returned granted scope set. Keep the grant inactive if either exact required scope is absent. | +| Local or Tailscale return is rejected | Enroll the exact origin on Paperclip ID. Only loopback HTTP is allowed; Tailscale must use HTTPS. | +| Every signed request fails on environment | The broker's `CONNECTOR_ENVIRONMENT`, the enrolled instance record, and the instance's `PAPERCLIP_ID_CONNECTOR_ENVIRONMENT` must all agree. An unset broker value is derived from the `BASE_URL` host and silently becomes `development`. | +| Every `/api/connect` route returns 503 | `GOOGLE_GMAIL_CONNECTOR_ENABLED` is not one of `true`, `1`, `yes`, or `on`. The response is `CONNECTOR_DISABLED`; no database or Google call is attempted. | +| Login starts asking for Gmail | Stop the rollout. The login and Gmail clients or route namespaces have been mixed. | +| Connector is unavailable | Keep the grant in `needs_reauthorization` or an actionable unavailable state. Never use a login token or another environment's client. | + +## References + +- [Configure Google Workspace MCP servers](https://developers.google.com/workspace/guides/configure-mcp-servers) +- [OAuth 2.0 for web server applications](https://developers.google.com/identity/protocols/oauth2/web-server) +- [Google OAuth 2.0 policies](https://developers.google.com/identity/protocols/oauth2/policies) +- [Choose Gmail API scopes](https://developers.google.com/workspace/gmail/api/auth/scopes) +- [Google Workspace API user data and developer policy](https://developers.google.com/workspace/workspace-api-user-data-developer-policy) diff --git a/doc/connections/README.md b/doc/connections/README.md index 6e777275fe..c58627ab4f 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -2,7 +2,7 @@ Audience: internal engineers and product contributors working on integrations. -Provider notes: [PostHog](./POSTHOG.md). +Provider notes: [Gmail](./GMAIL.md), [PostHog](./POSTHOG.md). Post-read action: classify a new integration request, pick the right Paperclip layer to change, and avoid creating a parallel connection framework. diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 9db61e1faa..aaa287af5a 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -158,7 +158,16 @@ export const connectionGrants = pgTable( connectionId: uuid("connection_id").notNull(), kind: text("kind").$type().notNull(), subjectUserId: text("subject_user_id"), - providerTenant: jsonb("provider_tenant").$type<{ name?: string; externalId?: string }>(), + providerTenant: jsonb("provider_tenant").$type<{ + name?: string; + externalId?: string; + oauth?: { + strategy?: string; + accessTokenExpiresAt?: string; + scopes?: string[]; + tokenType?: string; + }; + }>(), credentialSecretRefs: jsonb("credential_secret_refs").$type().notNull().default([]), status: text("status").$type().notNull().default("active"), isDefault: boolean("is_default").notNull().default(false), diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 24386db0a7..13fe37be4a 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -9,6 +9,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://mcp.zapier.com/api/mcp")?.slug).toBe("zapier"); expect(getAppDefinitionForUrl("https://api.githubcopilot.com/mcp/")?.slug).toBe("github"); expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); + expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail"); }); it("returns null for invalid or unknown links", () => { @@ -22,6 +23,13 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://mcp.google.com/drive")).toBeNull(); }); + it("lists Composio as a connectable API-key app", () => { + const composio = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio"); + expect(composio?.methods).toEqual([ + expect.objectContaining({ key: "api-key", transport: "rest_api", auth: "api_key" }), + ]); + }); + it("keeps every gallery entry reachable through at least one pattern", () => { for (const app of CONNECTABLE_APP_DEFINITIONS) { const example = app.urlPatterns[0]?.replace("*", "example"); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index dc59cd149b..617d99f25a 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -6,10 +6,12 @@ import a4 from "./app-definitions/posthog.json" with { type: "json" }; import a5 from "./app-definitions/linear.json" with { type: "json" }; import a6 from "./app-definitions/google-sheets.json" with { type: "json" }; import a7 from "./app-definitions/context7.json" with { type: "json" }; -import a8 from "./app-definitions/oauth-generic.json" with { type: "json" }; -import a9 from "./app-definitions/api-key-generic.json" with { type: "json" }; -import a10 from "./app-definitions/sentry.json" with { type: "json" }; -import a11 from "./app-definitions/vercel.json" with { type: "json" }; -import a12 from "./app-definitions/anthropic.json" with { type: "json" }; +import a8 from "./app-definitions/composio.json" with { type: "json" }; +import a9 from "./app-definitions/oauth-generic.json" with { type: "json" }; +import a10 from "./app-definitions/api-key-generic.json" with { type: "json" }; +import a11 from "./app-definitions/sentry.json" with { type: "json" }; +import a12 from "./app-definitions/vercel.json" with { type: "json" }; +import a13 from "./app-definitions/anthropic.json" with { type: "json" }; +import a14 from "./app-definitions/gmail.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index f55d1dd924..1bbec347ec 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -4,7 +4,7 @@ import { recommendedDefaultsForApp } from "./app-definitions.js"; import { appDefinitionsSchema } from "./validators/app-definition.js"; describe("AppDefinition catalog",()=>{ it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); - it("contains thirteen reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","posthog","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"])); + it("contains the reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","posthog","linear","google-sheets","context7","composio","oauth-generic","api-key-generic","sentry","vercel","anthropic","gmail"])); it("uses discovery-first Notion MCP OAuth metadata",()=>{ const notion=APP_DEFINITIONS.find((app)=>app.slug==="notion"); expect(notion?.redirectConstraints).toBe("https-or-loopback-http"); @@ -12,6 +12,7 @@ describe("AppDefinition catalog",()=>{ }); it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"])); it("defaults S2-S4 write and destructive actions to ask-first",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods)expect(recommendedDefaultsForApp(app,method.key)).toEqual({access:"all_agents",askFirstRiskLevels:method.riskTier==="S1"?[]:["write","destructive"]})}); + it("keeps Gmail personal-only and bound to the Paperclip ID broker",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="gmail")?.methods[0]).toMatchObject({oauthStrategy:"paperclip_id_connector",grantKinds:["user"],defaults:{serverUrl:"https://gmailmcp.googleapis.com/mcp/v1",scopesHint:["https://www.googleapis.com/auth/gmail.readonly","https://www.googleapis.com/auth/gmail.compose"]}})); it("offers PostHog OAuth and API-key methods with broad defaults and advanced narrowing",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(false);expect(method.tenantFields?.find((field)=>field.key==="projectId")?.transport).toEqual({location:"header",name:"x-posthog-project-id"});expect(method.tenantFields?.filter((field)=>field.advanced).map((field)=>field.key)).toEqual(["features","tools","mode"]);expect(method.configRequirements).toBeUndefined();expect(method.requiredResourceFilters).toEqual(["project"])}}); it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}}); }); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 7ac34d87dc..bb57e13f8f 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -11,6 +11,8 @@ const CONNECTABLE_APP_SLUGS = new Set([ "linear", "google-sheets", "context7", + "composio", + "gmail", ]); export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) => diff --git a/packages/shared/src/app-definitions/composio.json b/packages/shared/src/app-definitions/composio.json new file mode 100644 index 0000000000..470b0651b5 --- /dev/null +++ b/packages/shared/src/app-definitions/composio.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "slug": "composio", + "name": "Composio", + "description": "Connect Composio so Paperclip can discover and manage the toolkits in your project.", + "categories": ["productivity"], + "featured": true, + "branding": { + "logoUrl": "https://www.google.com/s2/favicons?domain=composio.dev&sz=128" + }, + "urlPatterns": ["https://backend.composio.dev/*"], + "methods": [ + { + "key": "api-key", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": ["customer"], + "whenToUse": "Use a project API key from the Composio project that owns the toolkits and connected accounts.", + "defaults": { + "serviceHost": "backend.composio.dev" + }, + "guidanceMd": "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.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "apiKey", + "label": "Composio project API key", + "type": "password", + "required": true, + "placeholder": "Paste the Composio API key", + "secret": true + } + ], + "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" + } + } + ] +} diff --git a/packages/shared/src/app-definitions/gmail.json b/packages/shared/src/app-definitions/gmail.json new file mode 100644 index 0000000000..21952a44ab --- /dev/null +++ b/packages/shared/src/app-definitions/gmail.json @@ -0,0 +1,38 @@ +{ + "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": "https://www.google.com/s2/favicons?domain=gmail.com&sz=128" + }, + "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" + } + ] +} diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index b1db31ef1d..514e161fca 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -1,6 +1,6 @@ -import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js"; +import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js"; export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } -export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } +export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_id_connector"; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 406a9a73a9..0b461c7d04 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -163,7 +163,16 @@ export interface ConnectionGrant { connectionId: string; kind: ConnectionGrantKind; subjectUserId: string | null; - providerTenant: { name?: string; externalId?: string } | null; + providerTenant: { + name?: string; + externalId?: string; + oauth?: { + strategy?: string; + accessTokenExpiresAt?: string; + scopes?: string[]; + tokenType?: string; + }; + } | null; credentialSecretRefs: ToolCredentialSecretRef[]; status: ConnectionGrantStatus; isDefault: boolean; diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index 7a005f48e8..c4af1bbe15 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js"; +import { connectionGrantKindSchema, toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js"; const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]})}); -export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_id_connector"]).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]})}); export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index ee293f238e..618e3d2fb5 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -189,6 +189,12 @@ export const connectionGrantSchema = z.object({ providerTenant: z.object({ name: z.string().trim().min(1).max(200).optional(), externalId: z.string().trim().min(1).max(400).optional(), + oauth: z.object({ + strategy: z.string().trim().min(1).max(100).optional(), + accessTokenExpiresAt: z.string().datetime().optional(), + scopes: z.array(z.string().trim().min(1).max(500)).max(20).optional(), + tokenType: z.string().trim().min(1).max(100).optional(), + }).optional(), }).nullable(), credentialSecretRefs: z.array(toolCredentialSecretRefSchema), status: connectionGrantStatusSchema, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 54e7198a74..61ac55c38d 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -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 { + 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 | 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(); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 8087143017..22f6e3fc46 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -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"); diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index bde32bf2f6..e3fcf88aea 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -36,6 +36,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { toolAccessService } from "../services/tool-access.js"; +import { ComposioApiError, type ComposioClient } from "../services/composio.js"; +import { createComposioSessionManager } from "../services/composio-session-manager.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; @@ -467,6 +469,177 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.authorization"]); }); + it("stores and validates a Composio API key without returning plaintext", async () => { + const company = await createCompany(db); + const validatedKeys: string[] = []; + const service = toolAccessService(db, { + composioClientFactory: (apiKey) => ({ + validateApiKey: async () => { validatedKeys.push(apiKey); }, + }) as unknown as ComposioClient, + }); + + const result = await service.connectGalleryApp(company.id, { + galleryKey: "composio", + connectionMethodKey: "api-key", + credentialValues: { "credentials.apiKey": "ak_composio_fixture" }, + }); + + expect(validatedKeys).toEqual(["ak_composio_fixture"]); + expect(result.catalog).toEqual([]); + expect(result.actions).toEqual({ readOnly: [], canMakeChanges: [] }); + expect(result.connection).toMatchObject({ + transport: "rest_api", + authKind: "api_key", + healthStatus: "ok", + }); + expect(result.connection.healthMessage).toContain("returned its toolkits"); + + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId)); + expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.apiKey"]); + expect(connection!.credentialRefs).toEqual([ + expect.objectContaining({ placement: "header", key: "x-api-key", prefix: null }), + ]); + expect(JSON.stringify({ result, connection })).not.toContain("ak_composio_fixture"); + }); + + it("rejects an invalid Composio key and removes the draft and secret", async () => { + const company = await createCompany(db); + const service = toolAccessService(db, { + composioClientFactory: () => ({ + validateApiKey: async () => { throw new ComposioApiError("Composio rejected the API key.", 401); }, + }) as unknown as ComposioClient, + }); + + await expect(service.connectGalleryApp(company.id, { + galleryKey: "composio", + connectionMethodKey: "api-key", + credentialValues: { "credentials.apiKey": "bad_composio_fixture" }, + })).rejects.toMatchObject({ + status: 422, + details: { code: "composio_api_key_rejected" }, + }); + + await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); + await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); + await expect(db.select().from(companySecrets)).resolves.toHaveLength(0); + }); + + it("creates, refreshes, and disconnects a Composio toolkit child", async () => { + const company = await createCompany(db); + const connectRequests: unknown[] = []; + const sessionRequests: unknown[] = []; + const deletedAccounts: string[] = []; + const client = { + validateApiKey: async () => undefined, + listToolkits: async () => ({ items: [{ slug: "github", name: "GitHub", meta: { tools_count: 1 } }] }), + listAuthConfigs: async () => ({ + items: [{ + id: "auth-github", + auth_scheme: "OAUTH2", + is_composio_managed: true, + status: "ACTIVE", + toolkit: { slug: "github" }, + }], + }), + createConnectLink: async (input: unknown) => { + connectRequests.push(input); + return { link_token: "link-token", redirect_url: "https://connect.composio.test/github", expires_at: "2026-08-21T20:00:00Z" }; + }, + listConnectedAccounts: async () => ({ + items: [{ + id: "account-github", + user_id: `paperclip:${company.id}`, + status: "ACTIVE", + toolkit: { slug: "github" }, + auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true }, + }], + }), + deleteConnectedAccount: async (accountId: string) => { deletedAccounts.push(accountId); }, + createSession: async (userId: string, options: unknown) => { + sessionRequests.push({ userId, options }); + return { + session_id: "session-github", + mcp: { url: "https://mcp.composio.test/github", headers: { Authorization: "Bearer session-secret" } }, + }; + }, + } as unknown as ComposioClient; + const service = toolAccessService(db, { + composioClientFactory: () => client, + remoteHttpRequest: async (_url, init) => { + expect(new Headers(init.headers).get("authorization")).toBe("Bearer session-secret"); + return jsonResponse({ + jsonrpc: "2.0", + id: "paperclip-catalog-refresh", + result: { tools: [{ name: "GITHUB_LIST_REPOS", description: "List repositories", annotations: { readOnlyHint: true } }] }, + }); + }, + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "composio", + connectionMethodKey: "api-key", + credentialValues: { "credentials.apiKey": "ak_composio_fixture" }, + }); + + const listed = await service.listComposioServices(connected.connectionId); + expect(listed.services).toEqual([ + expect.objectContaining({ + status: "connected", + connectedAccountId: "account-github", + childConnectionId: expect.any(String), + }), + ]); + const childId = listed.services[0]!.childConnectionId!; + const [child] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId)); + expect(child).toMatchObject({ + companyId: company.id, + applicationId: connected.application.id, + transport: "mcp_remote", + status: "active", + enabled: true, + config: { + provider: "composio", + parentConnectionId: connected.connectionId, + toolkitSlug: "github", + connectedAccountId: "account-github", + }, + }); + await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, childId))).resolves.toEqual([ + expect.objectContaining({ toolName: "GITHUB_LIST_REPOS", status: "active" }), + ]); + expect(sessionRequests).toEqual([ + expect.objectContaining({ userId: `paperclip:${company.id}`, options: expect.objectContaining({ toolkits: ["github"], mcp: true }) }), + ]); + const sessionManager = createComposioSessionManager(db, { composioClientFactory: () => client }); + const [readScope, writeScope] = await Promise.all([ + sessionManager.ensureSession(childId, { tools: ["GITHUB_LIST_REPOS"] }), + sessionManager.ensureSession(childId, { tools: ["GITHUB_CREATE_ISSUE"] }), + ]); + expect(readScope.scopeKey).not.toBe(writeScope.scopeKey); + expect(sessionRequests.slice(1)).toEqual([ + expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), + expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_CREATE_ISSUE"] } } }) }), + ]); + + await expect(service.startComposioServiceConnect(connected.connectionId, "github", {})).resolves.toMatchObject({ + toolkitSlug: "github", + authConfigId: "auth-github", + redirect_url: "https://connect.composio.test/github", + }); + expect(connectRequests).toEqual([ + expect.objectContaining({ authConfigId: "auth-github", userId: `paperclip:${company.id}` }), + ]); + await expect(service.pollComposioService(connected.connectionId, "github")).resolves.toMatchObject({ + child: { id: childId }, + }); + await expect(service.disconnectComposioService(connected.connectionId, "github")).resolves.toMatchObject({ + disconnectedAccountIds: ["account-github"], + removedChildIds: [childId], + }); + expect(deletedAccounts).toEqual(["account-github"]); + const [archivedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId)); + expect(archivedChild).toMatchObject({ status: "archived", enabled: false, credentialSecretRefs: [] }); + }); + it("stores custom header values as secrets and shows only header names", async () => { installMcpOAuthFixture({ auth: "header", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index f2b3bbb347..168ed901f0 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3683,6 +3683,71 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("createChild targeting another project does not forward the parent project workspace", async () => { + const companyId = randomUUID(); + const parentProjectId = randomUUID(); + const targetProjectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const targetProjectWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values([ + { id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" }, + { id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" }, + ]); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId: parentProjectId, + name: "paperclip", + isPrimary: true, + }, + { + id: targetProjectWorkspaceId, + companyId, + projectId: targetProjectId, + name: "paperclip-id", + isPrimary: true, + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId: parentProjectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Google Workspace MCP", + status: "in_progress", + priority: "medium", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceStrategy: { type: "git_worktree", baseRef: "origin/master" }, + }, + }); + + const { issue: child } = await svc.createChild(parentIssueId, { + title: "Implement the Paperclip ID connect broker", + status: "todo", + priority: "medium", + projectId: targetProjectId, + executionWorkspaceInheritanceMode: "strategy_only", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectId).toBe(targetProjectId); + expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId); + }); + it("clamps helper-created child requestDepth to the safe maximum", async () => { const companyId = randomUUID(); const projectId = randomUUID(); @@ -4800,6 +4865,82 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(child.executionWorkspaceId).toBe(executionWorkspaceId); }); + it("uses the target project's own workspaces for a cross-project child instead of inheriting the parent's", async () => { + const companyId = randomUUID(); + const parentProjectId = randomUUID(); + const targetProjectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const targetProjectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values([ + { id: parentProjectId, companyId, name: "Paperclip App", status: "in_progress" }, + { id: targetProjectId, companyId, name: "Paperclip ID", status: "in_progress" }, + ]); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId: parentProjectId, + name: "paperclip", + isPrimary: true, + }, + { + id: targetProjectWorkspaceId, + companyId, + projectId: targetProjectId, + name: "paperclip-id", + isPrimary: true, + }, + ]); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId: parentProjectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId: parentProjectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Google Workspace MCP", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId: targetProjectId, + title: "Implement the Paperclip ID connect broker", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectId).toBe(targetProjectId); + expect(child.projectWorkspaceId).toBe(targetProjectWorkspaceId); + expect(child.executionWorkspaceId).not.toBe(executionWorkspaceId); + }); + it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => { const companyId = randomUUID(); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 1408db670a..944c1a90f2 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -52,6 +52,7 @@ import { canonicalToolArguments, signToolArguments } from "../services/tool-cont import { createToolGatewayService as createToolGatewayServiceBase, type ToolGatewayService } from "../services/tool-gateway.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; +import type { ComposioClient } from "../services/composio.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -93,6 +94,96 @@ async function createCompany(db: ReturnType) { .then((rows) => rows[0]!); } +async function createComposioParentAndChild( + db: ReturnType, + companyId: string, +) { + const secrets = secretService(db); + const apiKey = await secrets.create(companyId, { + name: `Composio test key ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.credentials_apiKey`, + provider: "local_encrypted", + value: "composio-test-key", + }); + const [application] = await db.insert(toolApplications).values({ + companyId, + name: "Composio", + type: "rest_api", + status: "active", + }).returning(); + const [parent] = await db.insert(toolConnections).values({ + companyId, + applicationId: application!.id, + name: "Composio", + uid: `composio/${randomUUID()}`, + transport: "rest_api", + authKind: "api_key", + status: "active", + enabled: true, + config: { sourceTemplateKey: "composio" }, + transportConfig: { sourceTemplateKey: "composio" }, + credentialRefs: [{ + name: "credentials.apiKey", + secretId: apiKey.id, + version: "latest", + placement: "header", + key: "x-api-key", + prefix: null, + }], + credentialSecretRefs: [{ + secretId: apiKey.id, + versionSelector: "latest", + configPath: "credentials.apiKey", + required: true, + label: "Composio API key", + }], + }).returning(); + await db.insert(companySecretBindings).values({ + companyId, + secretId: apiKey.id, + targetType: "tool_connection", + targetId: parent!.id, + configPath: "credentials.apiKey", + }); + const [child] = await db.insert(toolConnections).values({ + companyId, + applicationId: application!.id, + name: "GitHub (via Composio)", + uid: `composio/github/${randomUUID()}`, + transport: "mcp_remote", + authKind: "none", + status: "active", + enabled: true, + config: { + provider: "composio", + parentConnectionId: parent!.id, + toolkitSlug: "github", + connectedAccountId: "account-github", + }, + transportConfig: {}, + }).returning(); + return { parent: parent!, child: child! }; +} + +function fakeComposioClient(accountStatus: () => string): ComposioClient { + return { + validateApiKey: vi.fn(async () => undefined), + listToolkits: vi.fn(async () => ({ items: [{ slug: "github", name: "GitHub" }] })), + listAuthConfigs: vi.fn(async () => ({ items: [] })), + createConnectLink: vi.fn(async () => ({ link_token: "link", redirect_url: "https://composio.test/link", expires_at: new Date().toISOString() })), + listConnectedAccounts: vi.fn(async () => ({ items: [{ + id: "account-github", + user_id: "paperclip:test", + status: accountStatus(), + toolkit: { slug: "github" }, + auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true }, + }] })), + deleteConnectedAccount: vi.fn(async () => undefined), + createSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" } })), + resumeSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" } })), + }; +} + // Build a Response-like object that mirrors what `fetch` returns for an MCP // Streamable HTTP JSON response: `text()`, `json()`, and a `content-type` // header. Production now reads the body via `text()` + content-type so it can @@ -3321,7 +3412,13 @@ describeEmbeddedPostgres("tool access service", () => { "linear", "google-sheets", "context7", + "composio", + "gmail", ]); + expect(res.body.apps.find((app: { slug: string }) => app.slug === "gmail").availability).toEqual({ + available: false, + reason: "Gmail is not available on this Paperclip instance yet.", + }); expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive"); expect(res.body.apps).toEqual( expect.arrayContaining([ @@ -3364,6 +3461,75 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("degrades a Composio child when its connected account becomes inactive", async () => { + const company = await createCompany(db); + const { child } = await createComposioParentAndChild(db, company.id); + const client = fakeComposioClient(() => "INACTIVE"); + const service = createTestToolAccessService(db, { composioClientFactory: () => client }); + + await expect(service.checkHealth(child.id)).rejects.toMatchObject({ + status: 502, + details: { + code: "composio_connected_account_inactive", + connection: expect.objectContaining({ id: child.id, healthStatus: "degraded" }), + }, + }); + await expect(service.getConnection(child.id)).resolves.toMatchObject({ + healthStatus: "degraded", + healthMessage: expect.stringContaining("INACTIVE"), + }); + expect(client.listConnectedAccounts).toHaveBeenCalledWith(expect.objectContaining({ + toolkitSlugs: ["github"], + })); + }); + + it("cascades Composio parent pause, restores active children, and keeps inactive children disabled", async () => { + const company = await createCompany(db); + const { parent, child } = await createComposioParentAndChild(db, company.id); + let accountStatus = "ACTIVE"; + const service = createTestToolAccessService(db, { + composioClientFactory: () => fakeComposioClient(() => accountStatus), + }); + + await service.updateConnection(parent.id, { enabled: false }); + await expect(service.getConnection(child.id)).resolves.toMatchObject({ + enabled: false, + config: expect.objectContaining({ disabledByComposioParent: true }), + }); + + accountStatus = "INACTIVE"; + await service.updateConnection(parent.id, { enabled: true }); + await expect(service.getConnection(child.id)).resolves.toMatchObject({ + enabled: false, + healthStatus: "degraded", + healthMessage: expect.stringContaining("INACTIVE"), + }); + + accountStatus = "ACTIVE"; + await service.updateConnection(parent.id, { enabled: true }); + const restored = await service.getConnection(child.id); + expect(restored).toMatchObject({ enabled: true, healthStatus: "unchecked", healthMessage: null }); + expect(restored.config).not.toHaveProperty("disabledByComposioParent"); + }); + + it("requires child-removal confirmation before deleting a Composio parent", async () => { + const company = await createCompany(db); + const { parent, child } = await createComposioParentAndChild(db, company.id); + const service = createTestToolAccessService(db); + + await expect(service.archiveConnection(parent.id, company.id)).rejects.toMatchObject({ + status: 409, + details: { + code: "composio_child_removal_confirmation_required", + childConnectionCount: 1, + }, + }); + await expect(service.archiveConnection(parent.id, company.id, undefined, { + confirmComposioChildren: true, + })).resolves.toMatchObject({ connection: expect.objectContaining({ status: "archived" }) }); + await expect(service.getConnection(child.id)).resolves.toMatchObject({ status: "archived", enabled: false }); + }); + it("returns server-derived create capabilities for a non-manager member", async () => { const company = await createCompany(db); const app = createRouteApp(db, boardSessionActor(company.id, "member")); @@ -5728,7 +5894,7 @@ describeEmbeddedPostgres("tool access service", () => { }); expect(JSON.stringify(connect.connection.config)).not.toContain("link-secret"); await expect(db.select().from(companySecrets)).resolves.toHaveLength(1); - await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(2); + await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(1); }); it("returns a sign-in-required code when a pasted link answers with an OAuth challenge", async () => { @@ -8248,6 +8414,66 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("removes the install-derived binding when an agent is uninstalled, and keeps an operator-authored one", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const other = await createAgent(db, company.id); + const { connection } = await createRemoteToolFixture(db, company.id); + const app = createRouteApp(db, undefined, createToolGatewayService(db, { + toolActionSigningSecret: "test-secret", + })); + + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [{ targetType: "agent", targetId: agent.id }] }) + .expect(200); + + const [profile] = await db + .select() + .from(toolProfiles) + .where(eq(toolProfiles.profileKey, `app:${connection.id}`)); + expect(profile).toBeDefined(); + + const bindingsFor = async (targetId: string) => db + .select() + .from(toolProfileBindings) + .where(and( + eq(toolProfileBindings.profileId, profile!.id), + eq(toolProfileBindings.targetType, "agent"), + eq(toolProfileBindings.targetId, targetId), + )); + + expect(await bindingsFor(agent.id)).toHaveLength(1); + + // A binding the operator authored through the access model, not through an + // install. Uninstalling must not touch it. + await db.insert(toolProfileBindings).values({ + companyId: company.id, + profileId: profile!.id, + targetType: "agent", + targetId: other.id, + priority: 100, + metadata: { source: "operator" }, + }); + + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [] }) + .expect(200); + + // The install row is gone, so the agent can no longer reach the connection. + expect(await db.select().from(toolConnectionInstalls) + .where(eq(toolConnectionInstalls.connectionId, connection.id))).toHaveLength(0); + // The binding the install created is gone too, so the permission state cannot + // report an agent the operator already removed. + expect(await bindingsFor(agent.id)).toHaveLength(0); + // The operator-authored binding survives. + expect(await bindingsFor(other.id)).toHaveLength(1); + + const effective = await createTestToolAccessService(db).getEffectiveProfilesForAgent(company.id, agent.id); + expect(effective.installedConnections.map((item) => item.id)).not.toContain(connection.id); + }); + it("limits connection configuration to the creator or a manager with role defaults", async () => { const company = await createCompany(db); const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`); diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index c23340b216..1635ba982d 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -26,6 +26,7 @@ import { toolApplications, toolCatalogEntries, toolCallEvents, + toolConnectionInstalls, toolConnections, toolGatewayRateLimitCounters, toolGatewaySessions, @@ -44,6 +45,7 @@ import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js"; import { toolAccessService } from "../services/tool-access.js"; import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; +import type { ComposioClient } from "../services/composio.js"; import { secretService } from "../services/secrets.js"; import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js"; import { @@ -1237,6 +1239,143 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { expect(otherTools.map((tool) => tool.catalogEntryId)).not.toContain(remoteTool.catalogEntry.id); }); + it("invokes an installed Composio child through a tool-scoped session and re-mints once on 401", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const remoteTool = await createRemoteMcpTool(db, company.id, { + applicationKey: "composio", + connectionName: "GitHub (via Composio)", + toolName: "GITHUB_LIST_REPOS", + title: "List repositories", + riskLevel: "read", + }); + const apiKey = await secretService(db).create(company.id, { + name: "Composio API key", + key: `tool_app.${randomUUID()}.composio_api_key`, + provider: "local_encrypted", + value: "ak_composio_gateway_fixture", + }); + const [parent] = await db.insert(toolConnections).values({ + companyId: company.id, + applicationId: remoteTool.application.id, + name: "Composio", + uid: `composio/${randomUUID()}`, + transport: "rest_api", + authKind: "api_key", + status: "active", + enabled: true, + config: { sourceTemplateKey: "composio" }, + transportConfig: { sourceTemplateKey: "composio" }, + credentialRefs: [{ + name: "credentials.apiKey", + secretId: apiKey.id, + version: "latest", + placement: "header", + key: "x-api-key", + prefix: null, + }], + credentialSecretRefs: [{ + secretId: apiKey.id, + versionSelector: "latest", + configPath: "credentials.apiKey", + required: true, + label: "Composio API key", + }], + }).returning(); + await db.insert(companySecretBindings).values({ + companyId: company.id, + secretId: apiKey.id, + targetType: "tool_connection", + targetId: parent!.id, + configPath: "credentials.apiKey", + }); + const childConfig = { + provider: "composio", + parentConnectionId: parent!.id, + toolkitSlug: "github", + connectedAccountId: "ca_github_fixture", + }; + await db.update(toolConnections).set({ config: childConfig, transportConfig: childConfig }) + .where(eq(toolConnections.id, remoteTool.connection.id)); + await db.insert(toolConnectionInstalls).values({ + companyId: company.id, + connectionId: remoteTool.connection.id, + targetType: "agent", + targetId: agent.id, + }); + const profile = await allowToolsForAgent(db, company.id, agent.id, []); + await db.insert(toolProfileEntries).values({ + companyId: company.id, + profileId: profile.id, + selectorType: "catalog_entry", + effect: "include", + catalogEntryId: remoteTool.catalogEntry.id, + }); + + const sessionRequests: Array<{ apiKey: string; userId: string; options: unknown }> = []; + let upstreamCalls = 0; + const gateway = createTestToolGatewayService(db, { + composioClientFactory: (resolvedApiKey) => ({ + createSession: async (userId, sessionOptions) => { + sessionRequests.push({ apiKey: resolvedApiKey, userId, options: sessionOptions }); + const suffix = sessionRequests.length; + return { + session_id: `composio-session-${suffix}`, + mcp: { + url: `https://mcp.composio.test/session-${suffix}`, + headers: { Authorization: `Bearer composio-session-token-${suffix}` }, + }, + }; + }, + }) as unknown as ComposioClient, + remoteHttpRequest: async (url, init) => { + upstreamCalls += 1; + expect(url).toBe(`https://mcp.composio.test/session-${upstreamCalls}`); + expect(new Headers(init.headers).get("authorization")).toBe(`Bearer composio-session-token-${upstreamCalls}`); + if (upstreamCalls === 1) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: "fixture", + result: { content: [{ type: "text", text: "repo-a" }], structuredContent: { repositories: ["repo-a"] } }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((candidate) => candidate.connectionId === remoteTool.connection.id); + expect(tool).toBeDefined(); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({ + status: "completed", + result: { content: "repo-a", data: { structuredContent: { repositories: ["repo-a"] } } }, + }); + await db.update(connectionGrants).set({ updatedAt: new Date(Date.now() + 1_000) }) + .where(eq(connectionGrants.connectionId, remoteTool.connection.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({ + status: "completed", + result: { content: "repo-a" }, + }); + expect(sessionRequests).toEqual([ + expect.objectContaining({ + apiKey: "ak_composio_gateway_fixture", + userId: `paperclip:${company.id}`, + options: expect.objectContaining({ + mcp: true, + toolkits: ["github"], + tools: { github: { enable: ["GITHUB_LIST_REPOS"] } }, + }), + }), + expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), + expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }), + ]); + const [persistedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, remoteTool.connection.id)); + expect(JSON.stringify(persistedChild!.config)).not.toContain("session-"); + expect(JSON.stringify(persistedChild!.transportConfig)).not.toContain("mcp.composio.test"); + expect(persistedChild!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([ + expect.stringMatching(/^composio\.session\.[a-f0-9]+\.url$/), + expect.stringMatching(/^composio\.session\.[a-f0-9]+\.header\.[a-f0-9]+$/), + ])); + }); + it("lists and executes connected local stdio MCP catalog tools through the gateway", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/app.ts b/server/src/app.ts index c06af3630a..538d5811ca 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -721,6 +721,19 @@ export async function createApp( } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); } + if (process.env.PAPERCLIP_MANAGED_RUNTIME_EXPOSURE === "tailscale_https") { + // The managed-runtime supervisor waits for the app port AND its derived + // Vite HMR companion port to bind before publishing the service. Static + // mode has no Vite, so bind the same placeholder listener dev mode uses + // or the supervisor kills a healthy server at the readiness deadline + // (PAP-18043). + const hmrServer = createHttpServer((_req, res) => { + res.writeHead(426, { "Content-Type": "text/plain" }); + res.end("Upgrade Required"); + }); + await listenViteHmrServer(hmrServer, resolveViteHmrPort(opts.serverPort), opts.bindHost); + viteHmrServer = hmrServer; + } } if (opts.uiMode === "vite-dev") { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d7180f70c6..2c9c24a82e 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -7329,6 +7329,34 @@ registerCurrentRoute({ summary: "Replace the member audience of a tool connection grant", }); +registerCurrentRoute({ + method: "get", + path: "/api/tool-connections/{connectionId}/services", + tags: ["tool-access"], + summary: "List the broker services behind a tool connection", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/connect", + tags: ["tool-access"], + summary: "Start a broker service connection for a toolkit", +}); + +registerCurrentRoute({ + method: "get", + path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/status", + tags: ["tool-access"], + summary: "Poll the connection status of a broker service", +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}", + tags: ["tool-access"], + summary: "Disconnect a broker service from a tool connection", +}); + registerCurrentRoute({ method: "get", path: "/api/tool-connections/{connectionId}/installs", @@ -7434,6 +7462,13 @@ registerCurrentRoute({ summary: "Handle a tool app OAuth callback", }); +registerCurrentRoute({ + method: "get", + path: "/api/tools/oauth/paperclip-id/callback", + tags: ["tool-access"], + summary: "Handle a brokered Paperclip ID OAuth callback", +}); + registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/tools/profiles", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index ac97092481..f2c61b2ada 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -48,6 +48,8 @@ import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; +import type { ComposioClient } from "../services/composio.js"; +import { paperclipIdGmailConnectorConfigFromEnv } from "../services/paperclip-id-gmail-connector.js"; import { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, oauthClientIdMetadataDocument, @@ -134,6 +136,7 @@ export function toolAccessRoutes( /** Test-only seams forwarded to the tool access service. */ remoteHttpEndpointLookup?: NonNullable[1]>["remoteHttpEndpointLookup"]; remoteHttpRequest?: NonNullable[1]>["remoteHttpRequest"]; + composioClientFactory?: (apiKey: string) => ComposioClient; } = {}, ) { const router = Router(); @@ -471,6 +474,7 @@ export function toolAccessRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const googleSheetsAvailability = googleSheetsRobotEmailFromEnv(); + const gmailAvailable = paperclipIdGmailConnectorConfigFromEnv() !== null; res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), apps: CONNECTABLE_APP_DEFINITIONS.map((app) => @@ -482,7 +486,15 @@ export function toolAccessRoutes( ? { available: true, robotEmail: googleSheetsAvailability.robotEmail } : { available: false, reason: googleSheetsAvailability.reason }, } - : { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY }, + : app.slug === "gmail" + ? { + ...app, + ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY, + availability: gmailAvailable + ? { available: true } + : { available: false, reason: "Gmail is not available on this Paperclip instance yet." }, + } + : { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY }, ), }); }); @@ -592,6 +604,56 @@ export function toolAccessRoutes( res.json(result); }); + router.get("/tools/oauth/paperclip-id/callback", async (req, res) => { + assertBoard(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const claimId = typeof req.query.claim_id === "string" ? req.query.claim_id : null; + const error = typeof req.query.error === "string" ? req.query.error : null; + const pendingState = state ? await svc.peekOAuthState(state) : null; + if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) { + throw badRequest("Invalid or expired OAuth state"); + } + const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId); + if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) { + await assertToolConnectionAccess(req, pendingConnection); + } else { + await assertToolConnectionConfigureAccess(req, pendingConnection); + } + const acceptsHtml = req.get("accept")?.includes("text/html") === true; + try { + const result = await svc.completePaperclipIdGmailCallback({ + state, + claimId, + error, + actor: getActorInfo(req), + }); + await logActivity(db, { + companyId: result.connection.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "tool_app.oauth_connected", + entityType: "tool_connection", + entityId: result.connection.id, + details: { applicationId: result.application.id, catalogEntryCount: result.catalog.length, provider: "gmail" }, + }); + if (acceptsHtml) { + const testPath = await oauthAppPath(result.connection.companyId, result.connection.id, "test"); + res.redirect(303, `${testPath}?success=1`); + return; + } + res.json(result); + } catch (callbackError) { + if (!acceptsHtml) throw callbackError; + const details = callbackError instanceof HttpError && callbackError.details && typeof callbackError.details === "object" + ? callbackError.details as Record + : null; + const params = new URLSearchParams({ oauth: details?.code === "oauth_authorization_denied" ? "denied" : "failed" }); + if (typeof details?.code === "string") params.set("code", details.code); + const setupPath = await oauthAppPath(pendingState.companyId, pendingState.connectionId, "setup"); + res.redirect(303, `${setupPath}?${params.toString()}`); + } + }); + router.get("/tools/oauth/callback", async (req, res) => { assertBoard(req); const state = typeof req.query.state === "string" ? req.query.state : ""; @@ -873,6 +935,58 @@ export function toolAccessRoutes( res.json(connection); }); + router.get("/tool-connections/:connectionId/services", async (req, res) => { + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertToolConnectionConfigureAccess(req, connection); + res.json(await svc.listComposioServices(connection.id, getActorInfo(req))); + }); + + router.post("/tool-connections/:connectionId/services/:toolkitSlug/connect", async (req, res) => { + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertToolConnectionConfigureAccess(req, connection); + const body = req.body && typeof req.body === "object" ? req.body as Record : {}; + const result = await svc.startComposioServiceConnect(connection.id, req.params.toolkitSlug as string, { + ...(typeof body.authConfigId === "string" ? { authConfigId: body.authConfigId } : {}), + ...(typeof body.callbackUrl === "string" ? { callbackUrl: body.callbackUrl } : {}), + }); + await logActivity(db, { + companyId: connection.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "composio.service_connect_started", + entityType: "tool_connection", + entityId: connection.id, + details: { toolkitSlug: req.params.toolkitSlug, authConfigId: result.authConfigId }, + }); + res.status(201).json(result); + }); + + router.get("/tool-connections/:connectionId/services/:toolkitSlug/status", async (req, res) => { + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertToolConnectionConfigureAccess(req, connection); + res.json(await svc.pollComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req))); + }); + + router.delete("/tool-connections/:connectionId/services/:toolkitSlug", async (req, res) => { + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertToolConnectionConfigureAccess(req, connection); + const result = await svc.disconnectComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req)); + await logActivity(db, { + companyId: connection.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "composio.service_disconnected", + entityType: "tool_connection", + entityId: connection.id, + details: { toolkitSlug: req.params.toolkitSlug, removedChildCount: result.removedChildIds.length }, + }); + res.json(result); + }); + router.get("/tool-connections/:connectionId/grants", async (req, res) => { assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); @@ -1272,6 +1386,7 @@ export function toolAccessRoutes( existing.id, existing.companyId, getActorInfo(req), + { confirmComposioChildren: req.query.confirmComposioChildren === "true" }, ); const applicationAfter = await svc.getApplication(existing.applicationId); // The receipt is counts and outcomes only. Removal is a revocation boundary diff --git a/server/src/services/composio-session-manager.ts b/server/src/services/composio-session-manager.ts new file mode 100644 index 0000000000..3dc6a1f662 --- /dev/null +++ b/server/src/services/composio-session-manager.ts @@ -0,0 +1,263 @@ +import { createHash, randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySecretBindings, toolConnections } from "@paperclipai/db"; +import type { ToolCredentialSecretRef } from "@paperclipai/shared"; +import { unprocessable } from "../errors.js"; +import { createComposioClient, type ComposioClient } from "./composio.js"; +import { secretService } from "./secrets.js"; + +const DEFAULT_SESSION_TTL_MS = 50 * 60 * 1000; +const sessionQueues = new Map>(); + +type ComposioChildConfig = { + parentConnectionId: string; + toolkitSlug: string; + connectedAccountId?: string; +}; + +type SessionRef = { name: string; configPath: string; secretId: string }; +type CachedSession = { + sessionId: string; + scopeKey: string; + fingerprint: string; + createdAt: string; + urlRef: SessionRef; + headerRefs: SessionRef[]; +}; + +export type ComposioSessionCredentials = { + sessionId: string; + scopeKey: string; + url: string; + headers: Record; +}; + +export type ComposioSessionManagerOptions = { + composioClientFactory?: (apiKey: string) => ComposioClient; + now?: () => Date; + sessionTtlMs?: number; +}; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +export function composioChildConfig(connection: typeof toolConnections.$inferSelect): ComposioChildConfig | null { + const config = record(connection.config); + if (config.provider !== "composio") return null; + if (typeof config.parentConnectionId !== "string" || typeof config.toolkitSlug !== "string") return null; + return { + parentConnectionId: config.parentConnectionId, + toolkitSlug: config.toolkitSlug, + ...(typeof config.connectedAccountId === "string" ? { connectedAccountId: config.connectedAccountId } : {}), + }; +} + +function normalizedTools(tools: string[] | undefined): string[] { + return [...new Set((tools ?? []).map((tool) => tool.trim()).filter(Boolean))].sort(); +} + +function scopeKeyFor(toolkitSlug: string, tools: string[]): string { + return createHash("sha256").update(JSON.stringify({ toolkitSlug, tools })).digest("hex").slice(0, 24); +} + +function cacheFrom(connection: typeof toolConnections.$inferSelect): Record { + return record(record(connection.transportConfig).composioSessions) as Record; +} + +export function createComposioSessionManager(db: Db, options: ComposioSessionManagerOptions = {}) { + const secrets = secretService(db); + const now = options.now ?? (() => new Date()); + const ttlMs = Math.max(1, options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS); + + async function connectionRow(connectionId: string) { + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)).limit(1); + if (!connection) throw unprocessable("The Composio toolkit connection no longer exists.", { code: "composio_child_missing" }); + return connection; + } + + async function parentApiKey(child: typeof toolConnections.$inferSelect, config: ComposioChildConfig) { + const [parent] = await db.select().from(toolConnections).where(and( + eq(toolConnections.id, config.parentConnectionId), + eq(toolConnections.companyId, child.companyId), + )).limit(1); + if (!parent) throw unprocessable("The parent Composio connection is missing.", { code: "composio_parent_missing" }); + const headerRef = parent.credentialRefs.find((ref) => ref.placement === "header" && ref.key.toLowerCase() === "x-api-key"); + const secretRef = parent.credentialSecretRefs.find((ref) => ref.configPath === "credentials.apiKey") + ?? (headerRef + ? { + secretId: headerRef.secretId, + versionSelector: headerRef.version ?? "latest", + configPath: headerRef.name.startsWith("credentials.") ? headerRef.name : `credentials.${headerRef.name}`, + } + : undefined); + if (!secretRef) throw unprocessable("The parent Composio API key is missing.", { code: "composio_api_key_missing" }); + const apiKey = await secrets.resolveSecretValue(parent.companyId, secretRef.secretId, secretRef.versionSelector ?? "latest", { + consumerType: "tool_connection", + consumerId: parent.id, + configPath: secretRef.configPath, + actorType: "system", + }); + const revision = createHash("sha256").update(JSON.stringify({ + parentId: parent.id, + updatedAt: parent.updatedAt.toISOString(), + secretId: secretRef.secretId, + versionSelector: secretRef.versionSelector ?? "latest", + })).digest("hex"); + return { apiKey, revision }; + } + + async function resolveRef(child: typeof toolConnections.$inferSelect, ref: SessionRef) { + return secrets.resolveSecretValue(child.companyId, ref.secretId, "latest", { + consumerType: "tool_connection", + consumerId: child.id, + configPath: ref.configPath, + actorType: "system", + }); + } + + async function resolveCached(child: typeof toolConnections.$inferSelect, cached: CachedSession): Promise { + const url = await resolveRef(child, cached.urlRef); + const headers = Object.fromEntries(await Promise.all(cached.headerRefs.map(async (ref) => [ref.name, await resolveRef(child, ref)]))); + return { sessionId: cached.sessionId, scopeKey: cached.scopeKey, url, headers }; + } + + async function createOrRotateRef(input: { + child: typeof toolConnections.$inferSelect; + cached?: SessionRef; + name: string; + configPath: string; + value: string; + }): Promise { + if (input.cached) { + await secrets.rotate(input.cached.secretId, { value: input.value }); + return { ...input.cached, name: input.name }; + } + const secret = await secrets.create(input.child.companyId, { + name: `${input.child.name} Composio session ${input.name} ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, + provider: "local_encrypted", + value: input.value, + description: `Hosted Composio MCP session credential for ${input.child.name}.`, + }); + return { name: input.name, configPath: input.configPath, secretId: secret.id }; + } + + async function mint( + connectionId: string, + tools: string[], + force: boolean, + scopeRevision?: string, + ): Promise { + const child = await connectionRow(connectionId); + const config = composioChildConfig(child); + if (!config) throw unprocessable("This connection is not a Composio toolkit child.", { code: "not_composio_child" }); + const { apiKey, revision } = await parentApiKey(child, config); + const scopeKey = scopeKeyFor(config.toolkitSlug, tools); + const fingerprint = createHash("sha256").update(JSON.stringify({ + revision, + toolkitSlug: config.toolkitSlug, + connectedAccountId: config.connectedAccountId ?? null, + tools, + scopeRevision: scopeRevision ?? null, + })).digest("hex"); + const cache = cacheFrom(child); + const cached = cache[scopeKey]; + if (!force && cached?.fingerprint === fingerprint && Date.parse(cached.createdAt) + ttlMs > now().getTime()) { + return resolveCached(child, cached); + } + + const client = options.composioClientFactory?.(apiKey) ?? createComposioClient({ apiKey }); + const session = await client.createSession(`paperclip:${child.companyId}`, { + mcp: true, + toolkits: [config.toolkitSlug], + ...(tools.length > 0 ? { tools: { [config.toolkitSlug]: { enable: tools } } } : {}), + ...(config.connectedAccountId ? { connectedAccounts: { [config.toolkitSlug]: [config.connectedAccountId] } } : {}), + }); + if (!session.mcp?.url) throw unprocessable("Composio did not return a hosted MCP URL.", { code: "composio_session_invalid" }); + + const prefix = `composio.session.${scopeKey}`; + const urlRef = await createOrRotateRef({ + child, + cached: cached?.urlRef, + name: "url", + configPath: `${prefix}.url`, + value: session.mcp.url, + }); + const priorHeaders = new Map((cached?.headerRefs ?? []).map((ref) => [ref.name.toLowerCase(), ref])); + const headerRefs: SessionRef[] = []; + for (const [name, value] of Object.entries(session.mcp.headers ?? {})) { + headerRefs.push(await createOrRotateRef({ + child, + cached: priorHeaders.get(name.toLowerCase()), + name, + configPath: `${prefix}.header.${createHash("sha256").update(name.toLowerCase()).digest("hex").slice(0, 16)}`, + value, + })); + } + const nextCached: CachedSession = { + sessionId: session.session_id, + scopeKey, + fingerprint, + createdAt: now().toISOString(), + urlRef, + headerRefs, + }; + const nextCache = { ...cache, [scopeKey]: nextCached }; + const refsByPath = new Map(child.credentialSecretRefs.map((ref) => [ref.configPath, ref])); + for (const ref of [urlRef, ...headerRefs]) { + refsByPath.set(ref.configPath, { + secretId: ref.secretId, + versionSelector: "latest", + configPath: ref.configPath, + required: true, + label: ref.name === "url" ? "Composio MCP session URL" : `Composio MCP ${ref.name} header`, + keyScope: scopeKey, + } satisfies ToolCredentialSecretRef); + } + const credentialSecretRefs = [...refsByPath.values()]; + const updated = await db.transaction(async (tx) => { + const [row] = await tx.update(toolConnections).set({ + transportConfig: { ...record(child.transportConfig), composioSessions: nextCache }, + credentialSecretRefs, + updatedAt: now(), + }).where(eq(toolConnections.id, child.id)).returning(); + await tx.delete(companySecretBindings).where(and( + eq(companySecretBindings.companyId, child.companyId), + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, child.id), + )); + if (credentialSecretRefs.length > 0) { + await tx.insert(companySecretBindings).values(credentialSecretRefs.map((ref) => ({ + companyId: child.companyId, + secretId: ref.secretId, + targetType: "tool_connection" as const, + targetId: child.id, + configPath: ref.configPath, + projectionClass: ref.projectionClass ?? "unclassified", + projectionAllowlistKey: ref.projectionAllowlistKey ?? null, + }))); + } + return row; + }); + return resolveCached(updated, nextCached); + } + + return { + ensureSession(connectionId: string, input: { tools?: string[]; force?: boolean; scopeRevision?: string } = {}) { + const tools = normalizedTools(input.tools); + const previous = sessionQueues.get(connectionId) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then( + () => mint(connectionId, tools, input.force === true, input.scopeRevision), + ); + const queued = pending.then(() => undefined, () => undefined); + sessionQueues.set(connectionId, queued); + return pending.finally(() => { + if (sessionQueues.get(connectionId) === queued) sessionQueues.delete(connectionId); + }); + }, + }; +} diff --git a/server/src/services/composio.test.ts b/server/src/services/composio.test.ts new file mode 100644 index 0000000000..fad99fef6e --- /dev/null +++ b/server/src/services/composio.test.ts @@ -0,0 +1,198 @@ +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; +}; + +async function startFixture( + handle: (request: IncomingMessage, response: ServerResponse) => void, +): Promise { + 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((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((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"); + }); +}); diff --git a/server/src/services/composio.ts b/server/src/services/composio.ts new file mode 100644 index 0000000000..d21be24cf2 --- /dev/null +++ b/server/src/services/composio.ts @@ -0,0 +1,216 @@ +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 = { + 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 }; + config?: Record; + 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; + authConfigs?: Record; + connectedAccounts?: Record; +}; + +export interface ComposioClient { + validateApiKey(): Promise; + listToolkits(options?: ComposioListOptions): Promise>; + listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise>; + createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise; + listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise>; + deleteConnectedAccount(connectedAccountId: string): Promise; + createSession(userId: string, options: ComposioSessionOptions): Promise; + resumeSession(sessionId: string, options: { mcp: true }): Promise; +} + +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(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise { + 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>("toolkits", {}, (url) => { + appendQuery(url, "cursor", options.cursor); + appendQuery(url, "limit", options.limit); + }); + }, + listAuthConfigs(options = {}) { + return request>("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("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>("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("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(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, { + method: "POST", + body: JSON.stringify({}), + }); + }, + }; +} diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts new file mode 100644 index 0000000000..0d9e312dd5 --- /dev/null +++ b/server/src/services/gmail-tool-governance.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { classifyRisk, isGmailToolPermanentlyBlocked } from "./tool-access.js"; + +describe("Gmail tool governance", () => { + it("allows reviewed reads and draft creation while keeping delivery and destructive actions blocked", () => { + expect(isGmailToolPermanentlyBlocked({ name: "search_threads" })).toBe(false); + expect(isGmailToolPermanentlyBlocked({ name: "get_message" })).toBe(false); + expect(classifyRisk({ name: "create_draft" }, "gmail")).toBe("write"); + expect(isGmailToolPermanentlyBlocked({ name: "create_draft" })).toBe(false); + + expect(isGmailToolPermanentlyBlocked({ name: "send_message" })).toBe(true); + expect(isGmailToolPermanentlyBlocked({ name: "trash_thread" })).toBe(true); + expect(isGmailToolPermanentlyBlocked({ name: "mark_as_spam" })).toBe(true); + expect(isGmailToolPermanentlyBlocked({ name: "update_labels" })).toBe(true); + }); +}); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 54e56fc936..f8253dffae 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -91,6 +91,7 @@ export { secretService } from "./secrets.js"; export { createRunSecretRedactionRegistry } from "./run-secret-redaction.js"; export { createSecretProposalsService } from "./secret-proposals.js"; export { googleSheetsRobotEmailFromEnv, toolAccessService } from "./tool-access.js"; +export { createComposioClient, ComposioApiError, type ComposioClient } from "./composio.js"; export { smokeLabService } from "./smoke-lab.js"; export { backfillLegacyToolOAuthTokens } from "./tool-oauth-legacy-backfill.js"; export { toolAccessPolicyService } from "./tool-access-policy.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e1c035527d..1d88d3f987 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -6717,6 +6717,11 @@ export function issueService(db: Db) { ...issueData } = data; const inheritStrategyOnly = executionWorkspaceInheritanceMode === "strategy_only"; + // A child may target another project. Parent workspace identity is only + // valid inside the parent's project, so do not forward it across that + // boundary; create() then resolves the target project's own workspaces. + const childProjectId = issueData.projectId ?? parent.projectId; + const childInheritsParentProject = childProjectId === parent.projectId; const hasExplicitExecutionWorkspaceOverride = issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || @@ -6728,8 +6733,10 @@ export function issueService(db: Db) { let child = await issueService(db).create(parent.companyId, { ...issueData, parentId: parent.id, - projectId: issueData.projectId ?? parent.projectId, - projectWorkspaceId: issueData.projectWorkspaceId ?? (inheritStrategyOnly ? parent.projectWorkspaceId : undefined), + projectId: childProjectId, + projectWorkspaceId: + issueData.projectWorkspaceId ?? + (inheritStrategyOnly && childInheritsParentProject ? parent.projectWorkspaceId : undefined), goalId: issueData.goalId ?? parent.goalId, actorResponsibleUserId: issueData.actorResponsibleUserId ?? null, trustExplicitResponsibleUserId: issueData.trustExplicitResponsibleUserId === true, @@ -7162,10 +7169,19 @@ export function issueService(db: Db) { if (issueData.projectId == null && workspaceSource.projectId) { issueData.projectId = workspaceSource.projectId; } - if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { + // Workspace linkage is only inheritable inside the source project. A + // cross-project child (for example, a Paperclip ID issue created from + // a Paperclip App parent) must fall through to its own project's + // default workspaces, otherwise the inherited ids fail the + // project-match assertions below and the create is impossible without + // the caller naming the target workspaces explicitly. + const inheritsSourceProject = + issueData.projectId == null || issueData.projectId === workspaceSource.projectId; + if (inheritsSourceProject && projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { projectWorkspaceId = workspaceSource.projectWorkspaceId; } if ( + inheritsSourceProject && isolatedWorkspacesEnabled && !hasExplicitExecutionWorkspaceOverride && workspaceSource.executionWorkspaceId diff --git a/server/src/services/paperclip-id-gmail-connector.test.ts b/server/src/services/paperclip-id-gmail-connector.test.ts new file mode 100644 index 0000000000..668c6bbb9b --- /dev/null +++ b/server/src/services/paperclip-id-gmail-connector.test.ts @@ -0,0 +1,160 @@ +import { + createCipheriv, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + type KeyObject, +} from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; + +import { + createPaperclipIdGmailConnector, + GMAIL_CONNECTOR_SCOPES, + paperclipIdGmailConnectorConfigFromEnv, + PaperclipIdConnectorError, + type PaperclipIdGmailConnectorConfig, +} from "./paperclip-id-gmail-connector.js"; + +const instanceId = "inst_test"; +const companyId = "company_test"; +const subject = "user_test"; + +function rawPrivateKey(key: KeyObject): string { + const jwk = key.export({ format: "jwk" }) as { d?: string }; + if (!jwk.d) throw new Error("missing private key bytes"); + return jwk.d; +} + +function config() { + const signing = generateKeyPairSync("ed25519"); + const sealing = generateKeyPairSync("x25519"); + return { + config: { + baseUrl: "https://id.example.test", + instanceId, + environment: "staging", + signPrivateKey: rawPrivateKey(signing.privateKey), + sealPrivateKey: rawPrivateKey(sealing.privateKey), + } satisfies PaperclipIdGmailConnectorConfig, + sealPublicKey: sealing.publicKey, + }; +} + +describe("Paperclip ID Gmail connector", () => { + it("starts a signed session with exact endpoint audience and scope contract", async () => { + const keys = config(); + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { request: string }; + const [, encodedClaims] = body.request.split("."); + const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8")); + expect(claims).toMatchObject({ + iss: instanceId, + aud: "https://id.example.test/api/connect/sessions", + sub: subject, + cid: companyId, + env: "staging", + op: "session", + ruri: "https://paperclip.example.test/api/tools/oauth/paperclip-id/callback", + rst: "state-1", + }); + return Response.json({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=broker-state", + expiresAt: "2026-08-21T20:00:00.000Z", + scopes: [...GMAIL_CONNECTOR_SCOPES], + }, { status: 201 }); + }); + const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch }); + + await expect(connector.startAuthorization({ + subject, + companyId, + returnUri: "https://paperclip.example.test/api/tools/oauth/paperclip-id/callback", + returnState: "state-1", + })).resolves.toMatchObject({ authorizationUrl: expect.stringContaining("accounts.google.com") }); + }); + + it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { + const keys = config(); + const credentials = { + v: 1 as const, + accessToken: "access-secret", + refreshToken: "refresh-secret", + tokenType: "Bearer", + accessTokenExpiresAt: "2026-08-21T20:00:00.000Z", + scopes: [...GMAIL_CONNECTOR_SCOPES], + subject, + companyId, + }; + const sealed = seal(credentials, keys.sealPublicKey, "gmail-initial-tokens", keys.config); + const request = vi.fn(async () => Response.json({ + claimId: "clm_test", + scopes: [...GMAIL_CONNECTOR_SCOPES], + sealed, + })); + const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch }); + + await expect(connector.claim({ subject, companyId, claimId: "clm_test" })).resolves.toEqual(credentials); + }); + + it("does not expose a broker response body when a request fails", async () => { + const keys = config(); + const request = vi.fn(async () => new Response(JSON.stringify({ + error: "provider rejected access-secret refresh-secret", + }), { status: 502 })); + const connector = createPaperclipIdGmailConnector({ config: keys.config, request: request as typeof fetch }); + + const error = await connector.refresh({ subject, companyId, refreshToken: "refresh-secret" }).catch((caught) => caught); + expect(error).toBeInstanceOf(PaperclipIdConnectorError); + expect(String(error)).not.toContain("access-secret"); + expect(String(error)).not.toContain("refresh-secret"); + expect(request).toHaveBeenCalledOnce(); + }); + + it("requires an all-or-nothing environment configuration and loopback for HTTP", () => { + expect(paperclipIdGmailConnectorConfigFromEnv({})).toBeNull(); + expect(() => paperclipIdGmailConnectorConfigFromEnv({ + PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId, + })).toThrowError(/incomplete/); + expect(() => paperclipIdGmailConnectorConfigFromEnv({ + PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: instanceId, + PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY: "key", + PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY: "key", + PAPERCLIP_ID_CONNECTOR_ENVIRONMENT: "development", + PAPERCLIP_ID_CONNECTOR_BASE_URL: "http://id.example.test", + })).toThrowError(/HTTPS/); + }); +}); + +function seal( + payload: unknown, + recipientPublicKey: KeyObject, + purpose: "gmail-initial-tokens" | "gmail-access-token", + configValue: PaperclipIdGmailConnectorConfig, +) { + const ephemeral = generateKeyPairSync("x25519"); + const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string }; + const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string }; + const ephemeralRaw = Buffer.from(ephemeralJwk.x, "base64url"); + const recipientRaw = Buffer.from(recipientJwk.x, "base64url"); + const aad = Buffer.from([1, "X25519-HKDF-SHA256-A256GCM", purpose, configValue.instanceId, configValue.environment].join("\n")); + const key = Buffer.from(hkdfSync( + "sha256", + diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey }), + Buffer.concat([ephemeralRaw, recipientRaw]), + aad, + 32, + )); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(aad); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final(), cipher.getAuthTag()]); + return { + v: 1, + alg: "X25519-HKDF-SHA256-A256GCM", + purpose, + epk: ephemeralJwk.x, + iv: iv.toString("base64url"), + ct: ciphertext.toString("base64url"), + }; +} diff --git a/server/src/services/paperclip-id-gmail-connector.ts b/server/src/services/paperclip-id-gmail-connector.ts new file mode 100644 index 0000000000..5bf8444b76 --- /dev/null +++ b/server/src/services/paperclip-id-gmail-connector.ts @@ -0,0 +1,340 @@ +import { + createDecipheriv, + createHash, + createPrivateKey, + createPublicKey, + diffieHellman, + hkdfSync, + randomUUID, + sign, + type KeyObject, +} from "node:crypto"; + +export const GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1"; +export const GMAIL_CONNECTOR_SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", +] as const; + +export type PaperclipIdConnectorEnvironment = "development" | "staging" | "production"; +export type PaperclipIdConnectorOperation = "session" | "claim" | "refresh" | "revoke"; + +export type PaperclipIdGmailConnectorConfig = { + baseUrl: string; + instanceId: string; + environment: PaperclipIdConnectorEnvironment; + signPrivateKey: string; + sealPrivateKey: string; +}; + +export type SealedGmailCredentials = { + v: 1; + accessToken: string; + refreshToken: string | null; + tokenType: string; + accessTokenExpiresAt: string; + scopes: string[]; + subject: string; + companyId: string; +}; + +type SealedEnvelope = { + v: 1; + alg: "X25519-HKDF-SHA256-A256GCM"; + purpose: "gmail-initial-tokens" | "gmail-access-token"; + epk: string; + iv: string; + ct: string; +}; + +type ConnectorResponse = { + authorizationUrl?: unknown; + expiresAt?: unknown; + scopes?: unknown; + claimId?: unknown; + sealed?: unknown; +}; + +const ENDPOINTS: Record = { + session: "/api/connect/sessions", + claim: "/api/connect/claims", + refresh: "/api/connect/refresh", + revoke: "/api/connect/revoke", +}; +const JWS_TYP = "paperclip-connector-request+jwt"; +const SEAL_ALGORITHM = "X25519-HKDF-SHA256-A256GCM"; +const AES_TAG_BYTES = 16; +const RAW_PRIVATE_KEY_BYTES = 32; +const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +const X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex"); +const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); + +/** A stable, intentionally detail-free error for all remote broker failures. */ +export class PaperclipIdConnectorError extends Error { + constructor( + message: string, + readonly code: string, + readonly status?: number, + ) { + super(message); + this.name = "PaperclipIdConnectorError"; + } +} + +export function paperclipIdGmailConnectorConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): PaperclipIdGmailConnectorConfig | null { + const instanceId = env.PAPERCLIP_ID_CONNECTOR_INSTANCE_ID?.trim(); + const signPrivateKey = env.PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY?.trim(); + const sealPrivateKey = env.PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY?.trim(); + const environment = env.PAPERCLIP_ID_CONNECTOR_ENVIRONMENT?.trim(); + const baseUrl = env.PAPERCLIP_ID_CONNECTOR_BASE_URL?.trim() || "https://id.paperclip.app"; + const values = [instanceId, signPrivateKey, sealPrivateKey, environment]; + if (values.every((value) => !value)) return null; + if (values.some((value) => !value)) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE"); + } + if (environment !== "development" && environment !== "staging" && environment !== "production") { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector environment is invalid", "CONNECTOR_CONFIG_INVALID"); + } + const parsedBaseUrl = new URL(baseUrl); + if (parsedBaseUrl.protocol !== "https:" && !(parsedBaseUrl.protocol === "http:" && isLoopback(parsedBaseUrl.hostname))) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector URL must use HTTPS", "CONNECTOR_CONFIG_INVALID"); + } + if (parsedBaseUrl.username || parsedBaseUrl.password || parsedBaseUrl.search || parsedBaseUrl.hash) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector URL is invalid", "CONNECTOR_CONFIG_INVALID"); + } + parsedBaseUrl.pathname = parsedBaseUrl.pathname.replace(/\/$/, ""); + return { + baseUrl: parsedBaseUrl.toString().replace(/\/$/, ""), + instanceId: instanceId!, + environment, + signPrivateKey: signPrivateKey!, + sealPrivateKey: sealPrivateKey!, + }; +} + +export function createPaperclipIdGmailConnector(input: { + config: PaperclipIdGmailConnectorConfig; + request?: typeof fetch; + now?: () => number; +}) { + const config = input.config; + const request = input.request ?? fetch; + const now = input.now ?? Date.now; + const signingKey = privateKey(config.signPrivateKey, "ed25519"); + const sealKey = privateKey(config.sealPrivateKey, "x25519"); + + async function call( + operation: PaperclipIdConnectorOperation, + claims: { subject: string; companyId: string; returnUri?: string; returnState?: string; claimId?: string }, + secret?: { field: "refreshToken" | "token"; value: string }, + ): Promise { + const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString(); + const issuedAt = Math.floor(now() / 1000); + const payload: Record = { + iss: config.instanceId, + aud: endpoint, + sub: claims.subject, + cid: claims.companyId, + env: config.environment, + op: operation, + iat: issuedAt, + exp: issuedAt + 60, + jti: randomUUID(), + }; + if (claims.returnUri !== undefined) payload.ruri = claims.returnUri; + if (claims.returnState !== undefined) payload.rst = claims.returnState; + if (claims.claimId !== undefined) payload.cl = claims.claimId; + if (secret) payload.sh = await sha256Base64Url(secret.value); + const body = { + request: signRequest(payload, signingKey), + ...(secret ? { [secret.field]: secret.value } : {}), + }; + let response: Response; + try { + response = await request(endpoint, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector is unavailable", "CONNECTOR_UNAVAILABLE"); + } + if (operation === "revoke" && response.status === 204) return {}; + if (!response.ok) { + throw new PaperclipIdConnectorError( + "Paperclip ID Gmail connector rejected the request", + response.status === 409 ? "REAUTHORIZATION_REQUIRED" : "CONNECTOR_REQUEST_FAILED", + response.status, + ); + } + try { + return await response.json() as ConnectorResponse; + } catch { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid response", "CONNECTOR_BAD_RESPONSE"); + } + } + + function openCredentials( + response: ConnectorResponse, + purpose: SealedEnvelope["purpose"], + subject: string, + companyId: string, + ): SealedGmailCredentials { + const envelope = parseEnvelope(response.sealed, purpose); + const credentials = unseal(envelope, sealKey, config.instanceId, config.environment); + if (credentials.subject !== subject || credentials.companyId !== companyId) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail credential binding did not match", "CONNECTOR_BINDING_MISMATCH"); + } + if (!sameStringSet(credentials.scopes, GMAIL_CONNECTOR_SCOPES)) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail scope grant did not match", "REAUTHORIZATION_REQUIRED"); + } + return credentials; + } + + return { + async startAuthorization(values: { subject: string; companyId: string; returnUri: string; returnState: string }) { + const response = await call("session", values); + if (typeof response.authorizationUrl !== "string" || typeof response.expiresAt !== "string") { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid session", "CONNECTOR_BAD_RESPONSE"); + } + if (!sameStringSet(response.scopes, GMAIL_CONNECTOR_SCOPES)) { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid scope set", "CONNECTOR_BAD_RESPONSE"); + } + const authorizationUrl = new URL(response.authorizationUrl); + if (authorizationUrl.protocol !== "https:" || authorizationUrl.hostname !== "accounts.google.com") { + throw new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid authorization URL", "CONNECTOR_BAD_RESPONSE"); + } + return { authorizationUrl: authorizationUrl.toString(), expiresAt: response.expiresAt }; + }, + async claim(values: { subject: string; companyId: string; claimId: string }) { + return openCredentials(await call("claim", values), "gmail-initial-tokens", values.subject, values.companyId); + }, + async refresh(values: { subject: string; companyId: string; refreshToken: string }) { + return openCredentials( + await call("refresh", values, { field: "refreshToken", value: values.refreshToken }), + "gmail-access-token", + values.subject, + values.companyId, + ); + }, + async revoke(values: { subject: string; companyId: string; token: string }) { + await call("revoke", values, { field: "token", value: values.token }); + }, + }; +} + +export type PaperclipIdGmailConnector = ReturnType; + +function signRequest(payload: Record, key: KeyObject): string { + const header = { alg: "EdDSA", typ: JWS_TYP }; + const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`; + return `${signingInput}.${sign(null, Buffer.from(signingInput, "utf8"), key).toString("base64url")}`; +} + +function privateKey(value: string, curve: "ed25519" | "x25519"): KeyObject { + try { + let parsed: KeyObject; + if (value.includes("BEGIN PRIVATE KEY")) { + parsed = createPrivateKey(value); + } else { + const raw = Buffer.from(value, "base64url"); + parsed = raw.length === RAW_PRIVATE_KEY_BYTES + ? createPrivateKey({ + key: Buffer.concat([curve === "ed25519" ? ED25519_PKCS8_PREFIX : X25519_PKCS8_PREFIX, raw]), + format: "der", + type: "pkcs8", + }) + : createPrivateKey({ key: raw, format: "der", type: "pkcs8" }); + } + if (parsed.asymmetricKeyType !== curve) throw new Error("wrong key type"); + return parsed; + } catch { + throw new PaperclipIdConnectorError(`Paperclip ID ${curve} private key is invalid`, "CONNECTOR_CONFIG_INVALID"); + } +} + +function parseEnvelope(value: unknown, purpose: SealedEnvelope["purpose"]): SealedEnvelope { + if (!value || typeof value !== "object" || Array.isArray(value)) throw badEnvelope(); + const candidate = value as Partial; + if (candidate.v !== 1 || candidate.alg !== SEAL_ALGORITHM || candidate.purpose !== purpose + || typeof candidate.epk !== "string" || typeof candidate.iv !== "string" || typeof candidate.ct !== "string") { + throw badEnvelope(); + } + return candidate as SealedEnvelope; +} + +function unseal( + envelope: SealedEnvelope, + recipientPrivateKey: KeyObject, + instanceId: string, + environment: string, +): SealedGmailCredentials { + try { + const ephemeralRaw = Buffer.from(envelope.epk, "base64url"); + if (ephemeralRaw.length !== 32) throw badEnvelope(); + const ephemeralKey = createPublicKey({ + key: Buffer.concat([X25519_SPKI_PREFIX, ephemeralRaw]), + format: "der", + type: "spki", + }); + const recipientJwk = createPublicKey(recipientPrivateKey).export({ format: "jwk" }) as { x?: string }; + if (!recipientJwk.x) throw badEnvelope(); + const recipientRaw = Buffer.from(recipientJwk.x, "base64url"); + const aad = Buffer.from([1, SEAL_ALGORITHM, envelope.purpose, instanceId, environment].join("\n"), "utf8"); + const key = Buffer.from(hkdfSync( + "sha256", + diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }), + Buffer.concat([ephemeralRaw, recipientRaw]), + aad, + 32, + )); + const iv = Buffer.from(envelope.iv, "base64url"); + const combined = Buffer.from(envelope.ct, "base64url"); + if (iv.length !== 12 || combined.length <= AES_TAG_BYTES) throw badEnvelope(); + const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: AES_TAG_BYTES }); + decipher.setAAD(aad); + decipher.setAuthTag(combined.subarray(-AES_TAG_BYTES)); + const plaintext = Buffer.concat([ + decipher.update(combined.subarray(0, -AES_TAG_BYTES)), + decipher.final(), + ]); + const parsed = JSON.parse(plaintext.toString("utf8")) as Partial; + if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0 + || !(parsed.refreshToken === null || typeof parsed.refreshToken === "string") + || typeof parsed.tokenType !== "string" || typeof parsed.accessTokenExpiresAt !== "string" + || !Array.isArray(parsed.scopes) || !parsed.scopes.every((scope) => typeof scope === "string") + || typeof parsed.subject !== "string" || typeof parsed.companyId !== "string") { + throw badEnvelope(); + } + return parsed as SealedGmailCredentials; + } catch (error) { + if (error instanceof PaperclipIdConnectorError) throw error; + throw badEnvelope(); + } +} + +function badEnvelope() { + return new PaperclipIdConnectorError("Paperclip ID Gmail connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE"); +} + +async function sha256Base64Url(value: string): Promise { + return createHash("sha256").update(value, "utf8").digest("base64url"); +} + +function base64Url(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function sameStringSet(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.every((item) => typeof item === "string") + && value.length === expected.length + && expected.every((item) => value.includes(item)); +} + +function isLoopback(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"; +} diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 7d0dceff5e..3bfbbf061f 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -151,6 +151,14 @@ import { import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "./tool-runtime-metrics.js"; import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError } from "./tool-runtime-supervisor.js"; import { listConnectionLifecycleEvents } from "./tool-connection-activity.js"; +import { ComposioApiError, createComposioClient, type ComposioClient } from "./composio.js"; +import { composioChildConfig, createComposioSessionManager } from "./composio-session-manager.js"; +import { + createPaperclipIdGmailConnector, + GMAIL_CONNECTOR_SCOPES, + paperclipIdGmailConnectorConfigFromEnv, + type PaperclipIdGmailConnector, +} from "./paperclip-id-gmail-connector.js"; type ActorInfo = { actorType?: "agent" | "user" | "system" | "plugin"; @@ -483,6 +491,10 @@ type ToolAccessServiceOptions = { remoteHttpEndpointLookup?: RemoteHttpEndpointLookup; /** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; + /** Test seam for Composio without live vendor traffic. */ + composioClientFactory?: (apiKey: string) => ComposioClient; + /** Test seam for the centrally registered Gmail OAuth broker. */ + paperclipIdGmailConnector?: PaperclipIdGmailConnector | null; }; type DbTransaction = Parameters[0]>[0]; @@ -702,6 +714,7 @@ const APPROVED_STDIO_TEMPLATES: Record | undefined, @@ -1684,6 +1701,12 @@ export function classifyRisk(tool: McpToolDescriptor, sourceTemplateKey?: string return "read"; } +export function isGmailToolPermanentlyBlocked(tool: McpToolDescriptor): boolean { + const riskLevel = classifyRisk(tool, "gmail"); + return verbMatches(tool.name, "send|trash|spam|delete|remove|destroy|execute|run") + || (normalizedProviderToolName(tool.name).includes("label") && riskLevel !== "read"); +} + function descriptorHash(tool: McpToolDescriptor, riskLevel: ToolRiskLevel): string { return stableHash({ name: tool.name, @@ -1717,13 +1740,24 @@ function isOAuthEndpointRejection(error: unknown): boolean { function healthFailureHttpStatus(failure: { status: ToolConnectionHealthStatus; code: string }): number { if (failure.status === "missing_secret") return 422; + if (failure.code === "composio_api_key_rejected") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; return 502; } function sanitizeHttpFailure(error: unknown): { status: ToolConnectionHealthStatus; message: string; code: string } { + if (error instanceof ComposioApiError) { + return { + status: "error", + message: error.message, + code: error.status === 401 || error.status === 403 ? "composio_api_key_rejected" : "composio_request_failed", + }; + } if (error instanceof HttpError) { const code = asRecord(error.details).code; + if (code === "composio_connected_account_inactive") { + return { status: "degraded", message: error.message, code }; + } if (typeof code === "string" && code.startsWith("remote_http_")) { return { status: "error", message: error.message, code }; } @@ -1800,8 +1834,19 @@ function readStdioTemplateId(config: Record): string { export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}) { const secrets = secretService(db); + const composioSessions = createComposioSessionManager(db, { + composioClientFactory: options.composioClientFactory, + now: options.now, + }); const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); + const gmailConnectorConfig = options.paperclipIdGmailConnector === undefined + ? paperclipIdGmailConnectorConfigFromEnv() + : null; + const gmailConnector = options.paperclipIdGmailConnector + ?? (gmailConnectorConfig + ? createPaperclipIdGmailConnector({ config: gmailConnectorConfig, now: () => now().getTime() }) + : null); const runtimeSupervisor = createToolRuntimeSupervisor(db, options); // These maps remove duplicate work inside one service instance. OAuth also // uses the database refresh lease below as its cross-process boundary. @@ -3530,10 +3575,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} eq(companySecretBindings.targetId, connection.id), ), ); - const bindings = [ + const rawBindings = [ ...connection.credentialRefs.map((ref) => ({ secretId: ref.secretId, - configPath: `credentials.${ref.name}`, + configPath: credentialRefConfigPath(ref), projectionClass: "unclassified", projectionAllowlistKey: null, })), @@ -3544,6 +3589,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} projectionAllowlistKey: ref.projectionAllowlistKey ?? null, })), ]; + const bindings = [...new Map(rawBindings.map((ref) => [ + `${ref.secretId}:${ref.configPath}`, + ref, + ])).values()]; if (bindings.length === 0) return; await db.insert(companySecretBindings).values(bindings.map((ref) => ({ companyId: connection.companyId, @@ -3672,11 +3721,25 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connectionId: string, companyId?: string, actor?: ActorInfo, + removalOptions: { confirmComposioChildren?: boolean } = {}, ): Promise { const connection = await getConnectionRow(connectionId, companyId); const now = new Date(); const binding = actorBinding(actor); + if (isComposioConnection(connection)) { + const children = (await existingComposioChildren(connection)).filter((child) => child.status !== "archived"); + if (children.length > 0 && removalOptions.confirmComposioChildren !== true) { + throw conflict("Deleting this Composio connection also removes its connected services. Confirm child removal to continue.", { + code: "composio_child_removal_confirmation_required", + childConnectionCount: children.length, + }); + } + for (const child of children) { + await removeConnection(child.id, child.companyId, actor, { confirmComposioChildren: true }); + } + } + // Grants are read before they are revoked: a retried removal must still see // the credential refs of a grant an earlier pass already marked revoked. const grantRows = await db @@ -4016,11 +4079,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const scope = credentialScope(connection); for (const ref of connection.credentialRefs) { let value: string; + const configPath = credentialRefConfigPath(ref); try { value = await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", { consumerType: "tool_connection", consumerId: connection.id, - configPath: `credentials.${ref.name}`, + configPath, actorType: "system", }); } catch (error) { @@ -4059,12 +4123,20 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return headers; } - async function remoteTools(connection: typeof toolConnections.$inferSelect): Promise { - const headers = { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(connection) }; + async function remoteTools( + connection: typeof toolConnections.$inferSelect, + credentialHeaders?: Record, + ): Promise { + const composioChild = composioChildConfig(connection); + const composioSession = composioChild ? await composioSessions.ensureSession(connection.id) : null; + const headers = composioSession?.headers + ?? credentialHeaders + ?? { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(connection) }; + const endpoint = composioSession?.url ?? remoteEndpoint(connection.config); // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. - const response = await requestRemoteHttpEndpoint(new URL(remoteEndpoint(connection.config)), { + let response = await requestRemoteHttpEndpoint(new URL(endpoint), { method: "POST", // MCP Streamable HTTP requires advertising that we accept both a JSON body // and an SSE stream; spec-compliant servers 406 without it (see mcp-http.ts). @@ -4076,6 +4148,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} params: {}, }), }); + if (response.status === 401 && composioChild) { + const refreshed = await composioSessions.ensureSession(connection.id, { force: true }); + response = await requestRemoteHttpEndpoint(new URL(refreshed.url), { + method: "POST", + headers: mcpHttpRequestHeaders(refreshed.headers), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "paperclip-catalog-refresh-retry", + method: "tools/list", + params: {}, + }), + }); + } if (!response.ok) { const authenticate = response.headers.get("www-authenticate") ?? ""; if (response.status === 401 && /bearer|oauth|authorization/i.test(authenticate)) { @@ -4141,8 +4226,293 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} })); } - async function discoverTools(connection: typeof toolConnections.$inferSelect): Promise { - if (connection.transport === "mcp_remote") return remoteTools(connection); + function isComposioConnection(connection: typeof toolConnections.$inferSelect): boolean { + return asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY; + } + + async function composioClientForParent(parent: typeof toolConnections.$inferSelect) { + if (!isComposioConnection(parent) || parent.transport !== "rest_api") { + throw unprocessable("This connection is not a parent Composio connection.", { code: "not_composio_parent" }); + } + const headers = await resolveCredentialHeaders(parent); + const apiKey = Object.entries(headers).find(([name]) => name.toLowerCase() === "x-api-key")?.[1]; + if (!apiKey) throw unprocessable("The Composio API key secret is missing.", { code: "secret_missing" }); + return options.composioClientFactory?.(apiKey) ?? createComposioClient({ apiKey }); + } + + async function existingComposioChildren(parent: typeof toolConnections.$inferSelect) { + const rows = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, parent.companyId), + eq(toolConnections.applicationId, parent.applicationId), + )); + return rows.filter((row) => composioChildConfig(row)?.parentConnectionId === parent.id); + } + + async function assertComposioConnectedAccountActive(child: typeof toolConnections.$inferSelect) { + const childConfig = composioChildConfig(child); + if (!childConfig) return; + const parent = await getConnectionRow(childConfig.parentConnectionId, child.companyId); + const client = await composioClientForParent(parent); + const accounts = await client.listConnectedAccounts({ + toolkitSlugs: [childConfig.toolkitSlug], + userIds: [`paperclip:${child.companyId}`], + limit: 100, + }); + const account = childConfig.connectedAccountId + ? accounts.items.find((candidate) => candidate.id === childConfig.connectedAccountId) + : accounts.items.find((candidate) => candidate.toolkit.slug === childConfig.toolkitSlug); + if (account?.status.toUpperCase() === "ACTIVE") return; + const status = account?.status.trim().toUpperCase() || "MISSING"; + throw unprocessable( + `Composio reports the ${childConfig.toolkitSlug} connected account as ${status}. Reconnect it in Composio.`, + { code: "composio_connected_account_inactive", connectedAccountStatus: status }, + ); + } + + async function disableComposioChildren(parent: typeof toolConnections.$inferSelect) { + const children = await existingComposioChildren(parent); + for (const child of children) { + if (child.status === "archived") continue; + const config = asRecord(child.config); + await db.update(toolConnections).set({ + enabled: false, + config: child.enabled ? { ...config, disabledByComposioParent: true } : config, + updatedAt: now(), + }).where(eq(toolConnections.id, child.id)); + } + } + + async function restoreComposioChildren(parent: typeof toolConnections.$inferSelect) { + const children = await existingComposioChildren(parent); + const restorable = children.filter((child) => + child.status !== "archived" && asRecord(child.config).disabledByComposioParent === true, + ); + if (restorable.length === 0) return; + + let accounts: Awaited>["items"] = []; + try { + const client = await composioClientForParent(parent); + accounts = (await client.listConnectedAccounts({ + userIds: [`paperclip:${parent.companyId}`], + limit: 1000, + })).items; + } catch { + // Fail closed while Composio is unavailable. A later resume or reconnect + // can retry without exposing a child whose account state is unknown. + return; + } + + for (const child of restorable) { + const childConfig = composioChildConfig(child)!; + const account = childConfig.connectedAccountId + ? accounts.find((candidate) => candidate.id === childConfig.connectedAccountId) + : accounts.find((candidate) => candidate.toolkit.slug === childConfig.toolkitSlug); + const config = { ...asRecord(child.config) }; + delete config.disabledByComposioParent; + const active = account?.status.toUpperCase() === "ACTIVE"; + await db.update(toolConnections).set({ + enabled: active, + config: active ? config : { ...config, disabledByComposioParent: true }, + healthStatus: active ? "unchecked" : "degraded", + healthMessage: active + ? null + : `Composio reports the ${childConfig.toolkitSlug} connected account as ${account?.status.toUpperCase() ?? "MISSING"}. Reconnect it in Composio.`, + updatedAt: now(), + }).where(eq(toolConnections.id, child.id)); + } + } + + async function syncComposioChild( + parent: typeof toolConnections.$inferSelect, + account: { id: string; status: string; toolkit: { slug: string } }, + toolkitName: string, + actor?: ActorInfo, + ) { + if (account.status.toUpperCase() !== "ACTIVE") return null; + const children = await existingComposioChildren(parent); + const existing = children.find((candidate) => { + const config = composioChildConfig(candidate); + return config?.toolkitSlug === account.toolkit.slug && candidate.status !== "archived"; + }); + if (existing) { + const config = composioChildConfig(existing)!; + if (config.connectedAccountId !== account.id) { + const nextConfig = { ...existing.config, connectedAccountId: account.id }; + const [updated] = await db.update(toolConnections).set({ + config: nextConfig, + transportConfig: { ...existing.transportConfig, connectedAccountId: account.id, composioSessions: {} }, + updatedAt: now(), + }).where(eq(toolConnections.id, existing.id)).returning(); + return updated; + } + return existing; + } + const connectionId = randomUUID(); + const binding = actorBinding(actor); + const config = { + provider: "composio", + parentConnectionId: parent.id, + toolkitSlug: account.toolkit.slug, + connectedAccountId: account.id, + }; + const [created] = await db.insert(toolConnections).values({ + id: connectionId, + companyId: parent.companyId, + applicationId: parent.applicationId, + name: `${toolkitName} (via Composio)`, + uid: connectionUid(`composio:${parent.id}`, account.toolkit.slug, connectionId), + connectionKind: "managed", + ownership: parent.ownership, + transport: "mcp_remote", + authKind: "none", + credentialPolicy: "shared", + status: "active", + enabled: true, + config, + transportConfig: { ...config, composioSessions: {} }, + credentialRefs: [], + credentialSecretRefs: [], + createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, + createdByUserId: binding.actorType === "user" ? binding.actorId : null, + }).returning(); + if (!created) throw new Error("Failed to create Composio toolkit connection"); + await ensureDefaultOrganizationGrant(created); + await syncCredentialBindings(created); + await ensureRuntimeSlot(created); + await audit({ + companyId: created.companyId, + connectionId: created.id, + action: "composio.child_created", + outcome: "success", + actor, + details: { parentConnectionId: parent.id, toolkitSlug: account.toolkit.slug }, + }); + return created; + } + + async function syncComposioToolkit( + parent: typeof toolConnections.$inferSelect, + toolkitSlug: string, + actor?: ActorInfo, + ) { + const client = await composioClientForParent(parent); + const userId = `paperclip:${parent.companyId}`; + const [toolkits, accounts] = await Promise.all([ + client.listToolkits({ limit: 1000 }), + client.listConnectedAccounts({ toolkitSlugs: [toolkitSlug], userIds: [userId], limit: 100 }), + ]); + const toolkit = toolkits.items.find((item) => item.slug === toolkitSlug); + if (!toolkit) throw notFound("Composio toolkit not found"); + const account = accounts.items.find((item) => item.toolkit.slug === toolkitSlug && item.status.toUpperCase() === "ACTIVE") + ?? accounts.items.find((item) => item.toolkit.slug === toolkitSlug) + ?? null; + const child = account ? await syncComposioChild(parent, account, toolkit.name, actor) : null; + if (child) await refreshCatalog(child.id, actor, { enableAllByDefault: true }); + return { toolkit, account, child: child ? toConnection(await getConnectionRow(child.id)) : null }; + } + + async function validateComposioConnection(connection: typeof toolConnections.$inferSelect) { + const client = await composioClientForParent(connection); + await client.validateApiKey(); + } + + async function listComposioServices(parentConnectionId: string, actor?: ActorInfo) { + const parent = await getConnectionRow(parentConnectionId); + const client = await composioClientForParent(parent); + const userId = `paperclip:${parent.companyId}`; + const [toolkits, accounts] = await Promise.all([ + client.listToolkits({ limit: 1000 }), + client.listConnectedAccounts({ userIds: [userId], limit: 1000 }), + ]); + const children = await existingComposioChildren(parent); + const childByToolkit = new Map(children.filter((child) => child.status !== "archived").map((child) => [ + composioChildConfig(child)?.toolkitSlug, + child, + ])); + const services = []; + for (const toolkit of toolkits.items) { + const toolkitAccounts = accounts.items.filter((account) => account.toolkit.slug === toolkit.slug); + const account = toolkitAccounts.find((candidate) => candidate.status.toUpperCase() === "ACTIVE") + ?? toolkitAccounts[0] + ?? null; + let child = childByToolkit.get(toolkit.slug) ?? null; + if (account?.status.toUpperCase() === "ACTIVE" && !child) { + child = await syncComposioChild(parent, account, toolkit.name, actor); + if (child) await refreshCatalog(child.id, actor, { enableAllByDefault: true }); + } + services.push({ + toolkit, + status: account?.status.toUpperCase() === "ACTIVE" + ? "connected" + : account ? "pending" : "not_connected", + connectedAccountId: account?.id ?? null, + connectedAccountStatus: account?.status ?? null, + childConnectionId: child?.id ?? null, + }); + } + return { parentConnectionId: parent.id, userId, services }; + } + + async function startComposioServiceConnect( + parentConnectionId: string, + toolkitSlug: string, + input: { authConfigId?: string; callbackUrl?: string }, + ) { + const parent = await getConnectionRow(parentConnectionId); + const client = await composioClientForParent(parent); + let authConfigId = input.authConfigId?.trim(); + if (!authConfigId) { + const configs = await client.listAuthConfigs({ toolkitSlugs: [toolkitSlug], showDisabled: false, limit: 100 }); + authConfigId = configs.items.find((config) => config.toolkit.slug === toolkitSlug && config.status !== "DISABLED")?.id; + } + if (!authConfigId) throw unprocessable("This Composio toolkit has no enabled auth configuration.", { code: "composio_auth_config_missing" }); + const link = await client.createConnectLink({ + authConfigId, + userId: `paperclip:${parent.companyId}`, + alias: `paperclip-${parent.companyId}-${toolkitSlug}`, + ...(input.callbackUrl ? { callbackUrl: input.callbackUrl } : {}), + }); + return { toolkitSlug, authConfigId, ...link }; + } + + async function disconnectComposioService(parentConnectionId: string, toolkitSlug: string, actor?: ActorInfo) { + const parent = await getConnectionRow(parentConnectionId); + const client = await composioClientForParent(parent); + const accounts = await client.listConnectedAccounts({ + toolkitSlugs: [toolkitSlug], + userIds: [`paperclip:${parent.companyId}`], + limit: 100, + }); + for (const account of accounts.items.filter((candidate) => candidate.toolkit.slug === toolkitSlug)) { + await client.deleteConnectedAccount(account.id); + } + const children = await existingComposioChildren(parent); + const removedChildIds: string[] = []; + for (const child of children) { + if (composioChildConfig(child)?.toolkitSlug !== toolkitSlug || child.status === "archived") continue; + await removeConnection(child.id, child.companyId, actor); + removedChildIds.push(child.id); + } + await audit({ + companyId: parent.companyId, + connectionId: parent.id, + action: "composio.service_disconnected", + outcome: "success", + actor, + details: { toolkitSlug, connectedAccountCount: accounts.items.length, removedChildCount: removedChildIds.length }, + }); + return { toolkitSlug, disconnectedAccountIds: accounts.items.map((account) => account.id), removedChildIds }; + } + + async function discoverTools( + connection: typeof toolConnections.$inferSelect, + credentialHeaders?: Record, + ): Promise { + if (connection.transport === "mcp_remote") return remoteTools(connection, credentialHeaders); + if (isComposioConnection(connection)) { + await validateComposioConnection(connection); + return []; + } await resolveCredentialHeaders(connection); return localTools(connection); } @@ -4178,14 +4548,23 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const connection = await getConnectionRow(connectionId); try { if (connection.transport === "mcp_remote") { + await assertComposioConnectedAccountActive(connection); await remoteTools(connection); + } else if (isComposioConnection(connection)) { + await validateComposioConnection(connection); } else { await resolveCredentialHeaders(connection); await stdioTemplateId(connection.companyId, connection.config); } - const updated = await updateConnectionHealth(connection, "ok", connection.transport === "local_stdio" - ? "Approved stdio template is ready." - : "Remote MCP server responded to tools/list."); + const updated = await updateConnectionHealth( + connection, + "ok", + isComposioConnection(connection) + ? "Composio accepted the API key and returned its toolkits." + : connection.transport === "local_stdio" + ? "Approved stdio template is ready." + : "Remote MCP server responded to tools/list.", + ); const runtimeSlot = await ensureRuntimeSlot(updated); await audit({ companyId: connection.companyId, @@ -4222,13 +4601,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function refreshCatalog( connectionId: string, actor?: ActorInfo, - refreshOptions: { enableAllByDefault?: boolean; restoreDraftDefaults?: boolean } = {}, + refreshOptions: { + enableAllByDefault?: boolean; + restoreDraftDefaults?: boolean; + credentialHeaders?: Record; + } = {}, ): Promise { const connection = await getConnectionRow(connectionId); const refreshedAt = now(); let descriptors: McpToolDescriptor[]; try { - descriptors = await discoverTools(connection); + descriptors = await discoverTools(connection, refreshOptions.credentialHeaders); } catch (error) { const failure = sanitizeHttpFailure(error); const updated = await updateConnectionHealth(connection, failure.status, failure.message); @@ -4270,14 +4653,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} && (!existing || changed) && existing?.status !== "disabled" && (!safeDefault || riskLevel !== "read"); - const status = shouldQuarantine - ? "quarantined" - : existing?.status === "disabled" - ? "disabled" - : quarantineOnRefresh && existing?.status === "quarantined" - ? "quarantined" - : "active"; - if (shouldQuarantine) quarantinedCount += 1; + const gmailPermanentlyBlocked = sourceTemplateKey === "gmail" && isGmailToolPermanentlyBlocked(descriptor); + const status = gmailPermanentlyBlocked + ? "disabled" + : shouldQuarantine + ? "quarantined" + : existing?.status === "disabled" + ? "disabled" + : quarantineOnRefresh && existing?.status === "quarantined" + ? "quarantined" + : "active"; + if (shouldQuarantine && !gmailPermanentlyBlocked) quarantinedCount += 1; if (existing) { const [updated] = await db @@ -6530,6 +6916,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} throw badRequest("Choose a connection method for this app"); } const method = galleryEntry ? connectionMethodFor(galleryEntry, input.connectionMethodKey) : null; + const requestedGrantKind = input.grantKind ?? "organization"; + if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) { + throw badRequest(`${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`); + } const transport = method?.transport ?? "mcp_remote"; const normalizedMethodConfig = galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY || !method ? null @@ -6550,6 +6940,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), } : { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true }; + if (method?.oauthStrategy === "paperclip_id_connector") { + config.oauth = { + strategy: method.oauthStrategy, + provider: "gmail", + resource: method.defaults?.serverUrl, + scopes: [...GMAIL_CONNECTOR_SCOPES], + }; + config.quarantineNewEntries = true; + } // A pasted URL may arrive with a client the operator preregistered in the // provider's own console, because that authorization server supports neither // CIMD nor dynamic registration. Record the client id now; the secret becomes @@ -6812,8 +7211,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} }; } + let health: ToolConnectionHealthCheckResult; try { - await checkConnectionHealth(connectionRow.id, actor); + health = await checkConnectionHealth(connectionRow.id, actor); } catch (error) { if (!galleryEntry && error instanceof HttpError && asRecord(error.details).code === "oauth_challenge") { const [oauthConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionRow.id)); @@ -6851,6 +7251,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } throw error; } + if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY) { + const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, applicationRow.id)); + return { + connectionId: health.connection.id, + application: toApplication(application), + connection: health.connection, + catalog: [], + actions: { readOnly: [], canMakeChanges: [] }, + suggestedDefaults: recommendedDefaultsForApp(galleryEntry, method?.key), + }; + } const restoreDraftDefaults = Boolean(revivedConnectionPrevious); const refresh = await refreshCatalog(connectionRow.id, actor, { enableAllByDefault: restoreDraftDefaults, @@ -7300,6 +7711,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .returning(); await syncCredentialBindings(updated); const health = await checkConnectionHealth(updated.id, actor); + if (isComposioConnection(updated) && updated.enabled && updated.status === "active") { + await restoreComposioChildren(updated); + } const catalogBefore = await db .select({ id: toolCatalogEntries.id, riskLevel: toolCatalogEntries.riskLevel }) .from(toolCatalogEntries) @@ -7335,6 +7749,66 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; assertOAuthRedirectConstraints(galleryEntry, input.redirectUri); + const galleryMethod = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null; + if (galleryMethod?.oauthStrategy === "paperclip_id_connector") { + if (!gmailConnector) { + throw unprocessable("Gmail connections are not available on this Paperclip instance yet", { + code: "paperclip_id_connector_unavailable", + }); + } + const binding = actorBinding(input.actor); + if (!binding.actorType || !binding.actorId) { + throw forbidden("Gmail sign-in requires an authenticated actor"); + } + const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null); + if (!subjectUserId) { + throw forbidden("Agent-started Gmail sign-in requires an authorized user subject"); + } + if (binding.actorType === "user" && subjectUserId !== binding.actorId) { + throw forbidden("Board users may only authorize their own Gmail identity"); + } + await db.delete(toolOauthStates).where(lt(toolOauthStates.expiresAt, now())); + const state = randomOauthToken(); + const returnUri = new URL(input.redirectUri); + returnUri.pathname = "/api/tools/oauth/paperclip-id/callback"; + returnUri.search = ""; + returnUri.hash = ""; + const session = await gmailConnector.startAuthorization({ + subject: subjectUserId, + companyId, + returnUri: returnUri.toString(), + returnState: state, + }); + const remoteExpiry = new Date(session.expiresAt); + const expiresAt = Number.isFinite(remoteExpiry.getTime()) + ? new Date(Math.min(remoteExpiry.getTime(), now().getTime() + 10 * 60 * 1000)) + : new Date(now().getTime() + 10 * 60 * 1000); + await db.insert(toolOauthStates).values({ + state, + companyId, + connectionId: connection.id, + // Paperclip ID owns PKCE for this flow. The local state row remains the + // single-use browser correlator and never stores broker token material. + codeVerifier: "paperclip-id-connector", + createdByActorType: binding.actorType, + createdByActorId: binding.actorId, + createdBySessionId: binding.sessionId, + subjectUserId, + requestedScopes: [...GMAIL_CONNECTOR_SCOPES], + returnTo: input.returnTo, + issueId: input.issueId, + expiresAt, + }); + return { + connectionId: connection.id, + provider: "gmail", + authorizationUrl: session.authorizationUrl, + expiresAt: expiresAt.toISOString(), + issuer: "https://accounts.google.com", + resource: galleryMethod.defaults?.serverUrl ?? null, + registrationSource: null, + }; + } const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri); if (endpoints.grantType === "client_credentials") { throw unprocessable("This app uses shared machine credentials and does not need browser sign in"); @@ -7613,6 +8087,160 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} )); } + async function completePaperclipIdGmailCallback(input: { + state: string; + claimId?: string | null; + error?: string | null; + actor?: ActorInfo; + }): Promise { + const stateRow = await consumeOAuthState(input.state, input.actor); + if (input.error) { + await rejectPendingOAuthInteraction(stateRow, input.actor); + throw new HttpError(400, "Google authorization did not complete. Start a new Gmail connection to try again.", { + code: input.error === "access_denied" ? "oauth_authorization_denied" : "paperclip_id_connector_failed", + }); + } + if (!input.claimId) throw badRequest("Gmail callback is missing a claim identifier"); + if (!gmailConnector) { + throw unprocessable("Gmail connections are not available on this Paperclip instance yet", { + code: "paperclip_id_connector_unavailable", + }); + } + let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId); + const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; + const method = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null; + if (method?.oauthStrategy !== "paperclip_id_connector" || !stateRow.subjectUserId) { + throw badRequest("OAuth state does not belong to a Gmail connector flow"); + } + const credentials = await gmailConnector.claim({ + subject: stateRow.subjectUserId, + companyId: stateRow.companyId, + claimId: input.claimId, + }); + if (!credentials.refreshToken) { + throw unprocessable("Google did not return offline access. Reconnect Gmail and grant both requested scopes.", { + code: "oauth_refresh_missing", + }); + } + + const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, stateRow.subjectUserId), + eq(companyMemberships.status, "active"), + )).limit(1); + if (!membership) { + throw forbidden("Your company membership is no longer active. Restore access before you connect Gmail again."); + } + const [existingUserGrant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, stateRow.subjectUserId), + )).limit(1); + const existingRefs = existingUserGrant?.credentialSecretRefs ?? []; + const accessRef = await createOrRotateOAuthSecret({ + companyId: connection.companyId, + connection, + configPath: "oauth.access_token", + label: "Gmail access token", + value: credentials.accessToken, + actor: input.actor, + existingRefs, + ownerUserId: stateRow.subjectUserId, + }); + const refreshRef = await createOrRotateOAuthSecret({ + companyId: connection.companyId, + connection, + configPath: "oauth.refresh_token", + label: "Gmail refresh token", + value: credentials.refreshToken, + actor: input.actor, + existingRefs, + ownerUserId: stateRow.subjectUserId, + }); + const credentialSecretRefs = [ + ...existingRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"), + accessRef, + refreshRef, + ]; + const grantValues = { + providerTenant: { + name: "Gmail", + externalId: credentials.subject, + oauth: { + strategy: "paperclip_id_connector", + accessTokenExpiresAt: credentials.accessTokenExpiresAt, + scopes: credentials.scopes, + tokenType: credentials.tokenType, + }, + }, + credentialSecretRefs, + status: "active" as const, + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: now(), + }; + if (existingUserGrant) { + await db.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id)); + } else { + await db.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "user", + subjectUserId: stateRow.subjectUserId, + ...grantValues, + isDefault: false, + createdByUserId: stateRow.subjectUserId, + }); + } + const nextConfig = { + ...connection.config, + oauth: { + ...oauthConfig(connection), + strategy: "paperclip_id_connector", + provider: "gmail", + resource: method.defaults?.serverUrl, + scopes: [...GMAIL_CONNECTOR_SCOPES], + }, + }; + [connection] = await db.update(toolConnections).set({ + status: "active", + enabled: true, + authKind: "oauth", + config: nextConfig, + transportConfig: nextConfig, + updatedAt: now(), + }).where(eq(toolConnections.id, connection.id)).returning(); + await db.update(toolApplications).set({ status: "active", updatedAt: now() }).where(eq(toolApplications.id, connection.applicationId)); + await syncCredentialBindings(connection, credentialSecretRefs); + if (stateRow.interactionId) { + await db.update(issueThreadInteractions).set({ + status: "accepted", + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: stateRow.subjectUserId, + resolvedAt: now(), + updatedAt: now(), + }).where(eq(issueThreadInteractions.id, stateRow.interactionId)); + } + const refresh = await refreshCatalog(connection.id, input.actor, { + enableAllByDefault: false, + credentialHeaders: { Authorization: `Bearer ${credentials.accessToken}` }, + }); + const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId)); + return { + connectionId: connection.id, + application: toApplication(application), + connection: refresh.connection, + catalog: refresh.catalog, + actions: groupedActions(refresh.catalog), + suggestedDefaults: recommendedDefaultsForApp(galleryEntry!, method.key), + auth: null, + }; + } + async function completeOAuthCallback(input: { state: string; code?: string | null; @@ -7970,6 +8598,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} peekOAuthState, + completePaperclipIdGmailCallback, + completeOAuthCallback, listExamples: async (companyId: string): Promise => { @@ -8244,6 +8874,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return connections; }, + listComposioServices, + + startComposioServiceConnect, + + pollComposioService: async (parentConnectionId: string, toolkitSlug: string, actor?: ActorInfo) => { + const parent = await getConnectionRow(parentConnectionId); + return syncComposioToolkit(parent, toolkitSlug, actor); + }, + + disconnectComposioService, + createConnection: async (companyId: string, input: CreateToolConnection, actor?: ActorInfo): Promise => { let applicationId = input.applicationId; let applicationNamespace = input.applicationName ?? input.name; @@ -8300,6 +8941,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await ensureDefaultOrganizationGrant(row); await syncCredentialBindings(row); await ensureRuntimeSlot(row); + if (isComposioConnection(row) && (input.enabled !== undefined || input.status !== undefined)) { + if (!row.enabled || row.status !== "active") await disableComposioChildren(row); + else await restoreComposioChildren(row); + } return toConnection(row); }, @@ -8610,6 +9255,43 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} revokeConnectionGrant: async (idOrUid: string, grantId: string, actor?: ActorInfo) => { const connection = await getConnectionRow(idOrUid); const binding = actorBinding(actor); + const [currentGrant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + )).limit(1); + if (!currentGrant) throw notFound("Connection grant not found"); + let providerRevocation = "not_applicable"; + if (oauthConfig(connection).strategy === "paperclip_id_connector" && currentGrant.subjectUserId && gmailConnector) { + const tokenRef = currentGrant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token") + ?? currentGrant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token"); + if (tokenRef) { + try { + const token = await secrets.resolveSecretValue( + connection.companyId, + tokenRef.secretId, + tokenRef.versionSelector ?? "latest", + { + consumerType: "tool_connection", + consumerId: connection.id, + configPath: tokenRef.configPath, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + }, + ); + await gmailConnector.revoke({ + subject: currentGrant.subjectUserId, + companyId: connection.companyId, + token, + }); + providerRevocation = "success"; + } catch { + // Local revocation is authoritative for Paperclip and must not be + // rolled back because Google or the broker is temporarily offline. + providerRevocation = "failed"; + } + } + } const grant = await db.transaction(async (tx) => { const removedDelegations = await tx.delete(connectionGrantDelegations).where(and( eq(connectionGrantDelegations.companyId, connection.companyId), @@ -8651,7 +9333,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} action: "connection_grant.revoked", outcome: "success", reasonCode: "grant_revoked", - details: { grantId: grant.id, kind: grant.kind }, + details: { grantId: grant.id, kind: grant.kind, providerRevocation }, }); return grant; }, @@ -8730,10 +9412,43 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} eq(toolConnectionInstalls.connectionId, connection.id), )); const existingKeys = new Set(existing.map((install) => `${install.targetType}:${install.targetId}`)); - const removeIds = existing - .filter((install) => !requested.has(`${install.targetType}:${install.targetId}`)) - .map((install) => install.id); - if (removeIds.length > 0) await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removeIds)); + const removals = existing + .filter((install) => !requested.has(`${install.targetType}:${install.targetId}`)); + const removeIds = removals.map((install) => install.id); + if (removeIds.length > 0) { + await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removeIds)); + // Uninstalling must also drop the binding this path created. Installing + // writes both an install row and a profile binding, so deleting only the + // install row leaves a binding that no surface can see or remove. The + // install row is the reach gate (`mintConnectionTokenForAgent` fails with + // `installation_required`, and the heartbeat only hands over installed + // connections), so a stale binding grants no reach on its own — but it + // still makes `finishApp`, which rebuilds `access` from the bindings, + // read a target the operator already removed. + // + // Only bindings tagged `source: "tool_connection_install"` are removed. + // A binding the operator authored through the access model carries a + // different source and must survive an uninstall. + const [installProfile] = await tx + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where(and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.profileKey, `app:${connection.id}`), + )) + .limit(1); + if (installProfile) { + for (const install of removals) { + await tx.delete(toolProfileBindings).where(and( + eq(toolProfileBindings.companyId, connection.companyId), + eq(toolProfileBindings.profileId, installProfile.id), + eq(toolProfileBindings.targetType, install.targetType), + eq(toolProfileBindings.targetId, install.targetId), + sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`, + )); + } + } + } const additions = [...requested.entries()].filter(([key]) => !existingKeys.has(key)).map(([, install]) => install); if (additions.length > 0) { await tx.insert(toolConnectionInstalls).values(additions.map((install) => ({ @@ -8823,6 +9538,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .returning(); await syncCredentialBindings(row); await ensureRuntimeSlot(row); + if (isComposioConnection(row)) { + if (row.enabled) await restoreComposioChildren(row); + else await disableComposioChildren(row); + } return toConnection(row); }, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index e470afe5ab..8eb82b0f64 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -71,6 +71,14 @@ import { type ToolRuntimeSlotView, } from "./tool-runtime-supervisor.js"; import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js"; +import { composioChildConfig, createComposioSessionManager } from "./composio-session-manager.js"; +import type { ComposioClient } from "./composio.js"; +import { + createPaperclipIdGmailConnector, + paperclipIdGmailConnectorConfigFromEnv, + PaperclipIdConnectorError, + type PaperclipIdGmailConnector, +} from "./paperclip-id-gmail-connector.js"; import { canonicalToolArguments, readSignedToolArgumentsPayload, @@ -784,6 +792,10 @@ export function createToolGatewayService( toolActionSigningSecret?: string; /** Test seam for deterministic remote MCP protocol fixtures. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; + /** Test seam for Composio session creation without vendor traffic. */ + composioClientFactory?: (apiKey: string) => ComposioClient; + /** Test seam for refreshing personal Gmail grants. */ + paperclipIdGmailConnector?: PaperclipIdGmailConnector | null; mcpGatewayProtocolLimits?: Partial<{ authFailures: Partial; gatewayRequests: Partial; @@ -803,6 +815,18 @@ export function createToolGatewayService( const interactions = issueThreadInteractionService(db); const policyService = toolAccessPolicyService(db); const secrets = secretService(db); + const gmailConnectorConfig = options.paperclipIdGmailConnector === undefined + ? paperclipIdGmailConnectorConfigFromEnv() + : null; + const gmailConnector = options.paperclipIdGmailConnector + ?? (gmailConnectorConfig + ? createPaperclipIdGmailConnector({ config: gmailConnectorConfig, now: options.now }) + : null); + const gmailRefreshFlights = new Map>(); + const composioSessions = createComposioSessionManager(db, { + composioClientFactory: options.composioClientFactory, + now: options.now ? () => new Date(options.now!()) : undefined, + }); const protocolLimits = mcpGatewayProtocolLimits(options.mcpGatewayProtocolLimits); let nextProtocolRateLimitPruneAt = 0; @@ -2422,11 +2446,116 @@ export function createToolGatewayService( ); } + async function maybeRefreshPaperclipIdGmailGrant( + session: ToolGatewaySession, + connection: typeof toolConnections.$inferSelect, + grant: typeof connectionGrants.$inferSelect, + ): Promise { + const oauth = asRecord(asRecord(connection.config)?.oauth); + if (oauth?.strategy !== "paperclip_id_connector") return grant; + const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth); + const expiresAt = typeof grantOauth?.accessTokenExpiresAt === "string" + ? Date.parse(grantOauth.accessTokenExpiresAt) + : Number.NaN; + const currentTime = options.now?.() ?? Date.now(); + if (Number.isFinite(expiresAt) && expiresAt > currentTime + 60_000) return grant; + const existingFlight = gmailRefreshFlights.get(grant.id); + if (existingFlight) return existingFlight; + const refresh = (async () => { + if (!gmailConnector || !grant.subjectUserId) { + await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) }) + .where(eq(connectionGrants.id, grant.id)); + throw new ToolGatewayHttpError(409, "Gmail authorization must be reconnected", "gmail_reauthorization_required", { + connectionId: connection.id, + grantId: grant.id, + }); + } + const accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token"); + const refreshRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token"); + if (!accessRef || !refreshRef) { + await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(currentTime) }) + .where(eq(connectionGrants.id, grant.id)); + throw new ToolGatewayHttpError(409, "Gmail authorization must be reconnected", "gmail_reauthorization_required", { + connectionId: connection.id, + grantId: grant.id, + }); + } + const refreshToken = await secrets.resolveSecretValue( + connection.companyId, + refreshRef.secretId, + refreshRef.versionSelector ?? "latest", + { + accessContext: { + consumerType: "tool_connection", + consumerId: connection.id, + configPath: refreshRef.configPath, + actorType: "system", + actorId: session.agentId, + issueId: session.issueId, + heartbeatRunId: session.runId, + }, + }, + ); + try { + const credentials = await gmailConnector.refresh({ + subject: grant.subjectUserId, + companyId: connection.companyId, + refreshToken, + }); + await secrets.rotate(accessRef.secretId, { value: credentials.accessToken }); + if (credentials.refreshToken) { + await secrets.rotate(refreshRef.secretId, { value: credentials.refreshToken }); + } + const providerTenant = { + ...(grant.providerTenant ?? {}), + oauth: { + ...(grant.providerTenant?.oauth ?? {}), + strategy: "paperclip_id_connector", + accessTokenExpiresAt: credentials.accessTokenExpiresAt, + scopes: credentials.scopes, + tokenType: credentials.tokenType, + }, + }; + const [updated] = await db.update(connectionGrants).set({ providerTenant, updatedAt: new Date(options.now?.() ?? Date.now()) }) + .where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.status, "active"))) + .returning(); + if (!updated) { + throw new ToolGatewayHttpError(409, "Gmail authorization is no longer active", "gmail_reauthorization_required", { + connectionId: connection.id, + grantId: grant.id, + }); + } + return updated; + } catch (error) { + if (error instanceof ToolGatewayHttpError) throw error; + if (error instanceof PaperclipIdConnectorError && error.code === "REAUTHORIZATION_REQUIRED") { + await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date(options.now?.() ?? Date.now()) }) + .where(eq(connectionGrants.id, grant.id)); + throw new ToolGatewayHttpError(409, "Gmail authorization must be reconnected", "gmail_reauthorization_required", { + connectionId: connection.id, + grantId: grant.id, + }); + } + throw new ToolGatewayHttpError(502, "Gmail authorization could not be refreshed", "gmail_refresh_failed", { + connectionId: connection.id, + grantId: grant.id, + }); + } + })(); + gmailRefreshFlights.set(grant.id, refresh); + try { + return await refresh; + } finally { + if (gmailRefreshFlights.get(grant.id) === refresh) gmailRefreshFlights.delete(grant.id); + } + } + async function resolveCredentialHeaders( session: ToolGatewaySession, connection: typeof toolConnections.$inferSelect, grant: typeof connectionGrants.$inferSelect, ): Promise> { + grant = await maybeRefreshPaperclipIdGmailGrant(session, connection, grant); const headers: Record = {}; for (const ref of connection.credentialRefs ?? []) { if (ref.placement !== "header") continue; @@ -3406,27 +3535,37 @@ export function createToolGatewayService( ): Promise { const { entry, connection } = await resolveConnectedRemoteTool(session, tool); const grant = await resolveConnectionGrant(session, connection); - const endpoint = remoteEndpoint(connection.config ?? {}); + const composioScopeRevision = `${grant.id}:${grant.status}:${grant.updatedAt.toISOString()}`; + const composioChild = composioChildConfig(connection); + let composioSession = composioChild + ? await composioSessions.ensureSession(connection.id, { + tools: [entry.toolName], + scopeRevision: composioScopeRevision, + }) + : null; + let endpoint = composioSession?.url ?? remoteEndpoint(connection.config ?? {}); // Method-defined headers are trusted catalog configuration. Treat them as // managed headers so callers cannot override the scope that was reviewed // during tools/list. Credentials remain authoritative on collisions. - const credentialHeaders = { + let credentialHeaders = composioSession?.headers ?? { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(session, connection, grant), }; - const { headers, summary: headerSummary } = buildRemoteHeaders({ + let builtHeaders = buildRemoteHeaders({ session, connection, credentialHeaders, callerHeaders, }); + let headers = builtHeaders.headers; + let headerSummary = builtHeaders.summary; const requestId = `paperclip-tool-${randomUUID()}`; const execution: RemoteHttpExecutionAudit = { transport: "mcp_remote", request: { protocol: "MCP JSON-RPC 2.0", httpMethod: "POST", - endpoint: auditSafeEndpoint(endpoint), + endpoint: composioChild ? `${new URL(endpoint).origin}/[composio-session]` : auditSafeEndpoint(endpoint), mcpMethod: "tools/call", requestId, upstreamToolName: entry.toolName, @@ -3458,7 +3597,7 @@ export function createToolGatewayService( }, }), }; - const response = options.remoteHttpRequest + let response = options.remoteHttpRequest ? await options.remoteHttpRequest(endpoint, requestInit) : await guardedRemoteHttpFetch(endpoint, requestInit, { ...remoteHttpFetchOptions(), @@ -3467,6 +3606,25 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + if (response.status === 401 && composioChild) { + composioSession = await composioSessions.ensureSession(connection.id, { + tools: [entry.toolName], + scopeRevision: composioScopeRevision, + force: true, + }); + endpoint = composioSession.url; + credentialHeaders = composioSession.headers; + builtHeaders = buildRemoteHeaders({ session, connection, credentialHeaders, callerHeaders }); + headers = builtHeaders.headers; + headerSummary = builtHeaders.summary; + const retryInit = { ...requestInit, headers: mcpHttpRequestHeaders(headers) }; + response = options.remoteHttpRequest + ? await options.remoteHttpRequest(endpoint, retryInit) + : await guardedRemoteHttpFetch(endpoint, retryInit, { + ...remoteHttpFetchOptions(), + responseTimeoutMs: ms, + }); + } const body = await readBoundedRemoteResponse(response); execution.response = { httpStatus: response.status, diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 52b397e9cf..3aff6673c5 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -1,3 +1,9 @@ +import type { + ComposioConnectLinkResponse, + ComposioDisconnectResponse, + ComposioServiceStatusResponse, + ComposioServicesResponse, +} from "@/pages/apps/composio-services"; import type { ToolApplication, ToolConnection, @@ -359,9 +365,9 @@ export const toolsApi = { api.patch(`/tool-connections/${connectionId}`, input), // Removal is a credential-revoking teardown (PAP-17119), so the response // carries the cleanup receipt alongside the archived connection. - archiveConnection: (connectionId: string) => + archiveConnection: (connectionId: string, options: { confirmComposioChildren?: boolean } = {}) => api.delete( - `/tool-connections/${connectionId}`, + `/tool-connections/${connectionId}${options.confirmComposioChildren ? "?confirmComposioChildren=true" : ""}`, ), checkConnectionHealth: (connectionId: string) => api.post(`/tool-connections/${connectionId}/health-check`, {}), @@ -393,6 +399,24 @@ export const toolsApi = { api.get( `/tool-connections/${connectionId}/test-calls/${actionRequestId}`, ), + // --- Composio services (PAP-17865) --- + // A Composio connection brokers many toolkits; these four read and change the + // per-toolkit state the Services tab renders. + listComposioServices: (connectionId: string) => + api.get(`/tool-connections/${connectionId}/services`), + startComposioServiceConnect: (connectionId: string, toolkitSlug: string) => + api.post( + `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/connect`, + {}, + ), + getComposioServiceStatus: (connectionId: string, toolkitSlug: string) => + api.get( + `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/status`, + ), + disconnectComposioService: (connectionId: string, toolkitSlug: string) => + api.delete( + `/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}`, + ), importMcpJson: (companyId: string, body: { mcpJson: unknown }) => api.post(`/companies/${companyId}/tools/mcp/import-json`, body), listStdioTemplates: (companyId: string) => diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index a356a10990..dddb6fc900 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -9,11 +9,13 @@ import { useSidebar } from "@/context/SidebarContext"; import { queryKeys } from "@/lib/queryKeys"; import { APP_TABS, + BROKER_ONLY_APP_TABS, CONNECTED_ONLY_APP_TABS, appApplicationTabHref, appTabHref, type AppTabKey, } from "@/pages/apps/app-tabs"; +import { isComposioBrokerConnection } from "@/pages/apps/composio-services"; import { AppLogo } from "@/pages/apps/AppLogo"; import { appDefinitionLogoUrl, @@ -59,6 +61,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { }); const connection = connectionQuery.data; + const isBroker = isComposioBrokerConnection(connection); const application = props.kind === "application" ? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId) : null; @@ -101,9 +104,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {