feat(apps): add Composio and Gmail connectors (#12342)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections need both direct providers and managed provider
hubs.
> - The grant layer now defines safe credential ownership.
> - Composio needs parent and child connection lifecycle rules, and
Gmail needs governed setup.
> - This pull request adds both connector families on the grant
foundation.
> - The benefit is broader app access without weakening credential
isolation.

## Linked Issues or Issue Description

Refs #11965

This is stack 4 of 11. It depends on stack 3 and replaces another
reviewable part of #11965.

## What Changed

- Add Composio parent and child connection support.
- Add Gmail connection setup and governance.
- Preserve credential paths and remove duplicate binding declarations.
- Cascade Composio pause and restore actions to child connections.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 164 tests passed.
- `pnpm build`

## Risks

- Parent lifecycle changes can affect every Composio child.
- The service restores only children whose provider accounts remain
active.
- Credential binding paths are normalized before secret resolution.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-29 12:08:33 -05:00 committed by GitHub
parent 20ccf3f476
commit 6244e4cf32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 5110 additions and 91 deletions

View File

@ -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

358
doc/connections/GMAIL.md Normal file
View File

@ -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)

View File

@ -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.

View File

@ -158,7 +158,16 @@ export const connectionGrants = pgTable(
connectionId: uuid("connection_id").notNull(),
kind: text("kind").$type<ConnectionGrantKind>().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<ToolCredentialSecretRef[]>().notNull().default([]),
status: text("status").$type<ConnectionGrantStatus>().notNull().default("active"),
isDefault: boolean("is_default").notNull().default(false),

View File

@ -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");

View File

@ -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[];

View File

@ -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()}});
});

View File

@ -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) =>

View File

@ -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"
}
}
]
}

View File

@ -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"
}
]
}

View File

@ -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<Record<ToolConnectionOwnership,boolean>> }

View File

@ -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;

View File

@ -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<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});

View File

@ -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,

View File

@ -1,7 +1,7 @@
#!/usr/bin/env -S node --import tsx
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
@ -154,9 +154,14 @@ if (bindMode === "custom" && !bindHost) {
process.exit(1);
}
// Managed HTTPS runtimes serve the built UI bundle: the Vite dev middleware's
// unbundled module waterfall stalls behind the Tailscale HTTPS proxy and the
// first page load in a fresh browser profile stays blank forever (PAP-18043).
const explicitUiDevMiddleware = process.env.PAPERCLIP_UI_DEV_MIDDLEWARE;
const serveBuiltUiForManagedRuntime = managedRuntimeExposure && explicitUiDevMiddleware === undefined;
const env: NodeJS.ProcessEnv = {
...process.env,
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"),
};
if (mode === "dev") {
@ -399,7 +404,7 @@ async function runPnpm(args: string[], options: {
async function getMigrationStatusPayload() {
const status = await runPnpm(
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
["--silent", "--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
{ env },
);
if (status.code !== 0) {
@ -411,16 +416,26 @@ async function getMigrationStatusPayload() {
process.exit(status.code);
}
try {
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
} catch (error) {
process.stderr.write(
status.stderr ||
status.stdout ||
"[paperclip] migration-status returned invalid JSON payload\n",
);
throw toError(error, "Unable to parse migration-status JSON output");
// pnpm can interleave its own reporter lines (e.g. "Unsupported engine"
// warnings) into stdout, so parse the last line that is a JSON object
// instead of trusting the whole stream.
const jsonLines = status.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("{"));
for (let index = jsonLines.length - 1; index >= 0; index -= 1) {
try {
return JSON.parse(jsonLines[index]) as { status?: string; pendingMigrations?: string[] };
} catch {
// keep scanning earlier JSON-looking lines
}
}
process.stderr.write(
status.stderr ||
status.stdout ||
"[paperclip] migration-status returned invalid JSON payload\n",
);
throw new Error("Unable to parse migration-status JSON output");
}
async function refreshPendingMigrations() {
@ -507,6 +522,53 @@ async function buildPluginSdk() {
}
}
function newestMtimeMs(target: string): number {
const stat = statSync(target, { throwIfNoEntry: false });
if (!stat) return 0;
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = stat.mtimeMs;
for (const entry of readdirSync(target)) {
if (entry === "node_modules" || entry === ".git" || entry === "dist") continue;
const childNewest = newestMtimeMs(path.join(target, entry));
if (childNewest > newest) newest = childNewest;
}
return newest;
}
function uiBundleIsFresh(): boolean {
const distIndex = path.join(repoRoot, "ui", "dist", "index.html");
const distStat = statSync(distIndex, { throwIfNoEntry: false });
if (!distStat) return false;
const sources = [
path.join(repoRoot, "ui", "src"),
path.join(repoRoot, "ui", "public"),
path.join(repoRoot, "ui", "index.html"),
path.join(repoRoot, "ui", "package.json"),
path.join(repoRoot, "ui", "vite.config.ts"),
path.join(repoRoot, "packages", "shared", "src"),
];
return sources.every((source) => newestMtimeMs(source) <= distStat.mtimeMs);
}
async function buildUiBundleForManagedRuntime(): Promise<boolean> {
console.log("[paperclip] managed runtime: building the UI bundle for static serving...");
const result = await runPnpm(
["--filter", "@paperclipai/ui", "build"],
{ stdio: "inherit" },
);
if (result.signal) {
exitForSignal(result.signal);
return false;
}
if (result.code !== 0) {
console.error(
"[paperclip] UI bundle build failed; falling back to the Vite dev middleware (the page may load slowly or stay blank over HTTPS)",
);
return false;
}
return true;
}
async function markChildAsCurrent() {
previousSnapshot = collectWatchedSnapshot();
dirtyPaths = new Set();
@ -705,7 +767,20 @@ process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
// The managed runtime readiness window is tight, so reuse a fresh bundle
// when possible and overlap a needed rebuild with the migration preflight.
let uiBundleBuild: Promise<boolean> | null = null;
if (serveBuiltUiForManagedRuntime) {
if (uiBundleIsFresh()) {
console.log("[paperclip] managed runtime: reusing the up-to-date UI bundle in ui/dist");
} else {
uiBundleBuild = buildUiBundleForManagedRuntime();
}
}
await maybePreflightMigrations();
if (uiBundleBuild) {
env.PAPERCLIP_UI_DEV_MIDDLEWARE = (await uiBundleBuild) ? "false" : "true";
}
await startServerChild();
installDevIntervals();

View File

@ -20,12 +20,14 @@ const apps=[
["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})],
["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})],
["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")],
["composio","Composio","Connect Composio so Paperclip can discover and manage the toolkits in your project.","productivity","composio.dev",["https://backend.composio.dev/*"],method("api-key","rest_api","api_key",{serviceHost:"backend.composio.dev"},"S3","Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.",{whenToUse:"Use a project API key from the Composio project that owns the toolkits and connected accounts.",credentialFields:[field("apiKey","Composio project API key","Paste the Composio API key")],keyPlacement:{location:"header",name:"x-api-key"},consoleLinks:{keys:"https://app.composio.dev/",settings:"https://app.composio.dev/",docs:"https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions"}}),{featured:true}],
["oauth-generic","OAuth app","Connect a provider using your own OAuth client.","other","oauth.net",[],method("oauth","rest_api","oauth",{},"S3","Register an OAuth client with the provider and add Paperclip's redirect URI.",{credentialFields:[{...field("clientId","Client ID","Paste the client ID"),type:"text",secret:false},field("clientSecret","Client secret","Paste the client secret")]})],
["api-key-generic","API key app","Connect an API using a key from your provider.","other","openapis.org",[],method("api-key","rest_api","api_key",{},"S3","Create a restricted API key and paste it here.",{credentialFields:[field("apiKey","API key","Paste the API key")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})],
["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})],
["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})],
["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})],
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","posthog","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:Array.isArray(m)?m:[m],...extra}));
apps.push({schemaVersion:1,slug:"gmail",name:"Gmail",description:"Search and read Gmail messages and create drafts without enabling mail sending.",categories:["communication","productivity"],featured:true,branding:{logoUrl:favicon("gmail.com")},urlPatterns:["https://gmailmcp.googleapis.com/*"],docsUrl:"https://developers.google.com/workspace/guides/configure-mcp-servers",redirectConstraints:"https-or-loopback-http",methods:[{key:"paperclip-id-oauth",label:"Connect Gmail",transport:"mcp_remote",auth:"oauth",oauthStrategy:"paperclip_id_connector",grantKinds:["user"],ownershipModes:["customer"],whenToUse:"Use Paperclip ID for a personal Gmail connection with centrally registered Google OAuth.",defaults:{serverUrl:"https://gmailmcp.googleapis.com/mcp/v1",scopesHint:["https://www.googleapis.com/auth/gmail.readonly","https://www.googleapis.com/auth/gmail.compose"]},guidanceMd:"Connect your Gmail identity. Paperclip can search and read mail and create drafts. Sending mail is not enabled.",warnings:["This connection is personal. Agents need an explicit install, profile, and delegation before they can use it."],riskTier:"S3"}]});
const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim());
const parseCapture=(fileName)=>{
const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8");

View File

@ -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",

View File

@ -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();

View File

@ -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<typeof createDb>) {
.then((rows) => rows[0]!);
}
async function createComposioParentAndChild(
db: ReturnType<typeof createDb>,
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()}`);

View File

@ -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);

View File

@ -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") {

View File

@ -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",

View File

@ -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<Parameters<typeof toolAccessService>[1]>["remoteHttpEndpointLookup"];
remoteHttpRequest?: NonNullable<Parameters<typeof toolAccessService>[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<string, unknown>
: 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<string, unknown> : {};
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

View File

@ -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<string, Promise<void>>();
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<string, string>;
};
export type ComposioSessionManagerOptions = {
composioClientFactory?: (apiKey: string) => ComposioClient;
now?: () => Date;
sessionTtlMs?: number;
};
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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<string, CachedSession> {
return record(record(connection.transportConfig).composioSessions) as Record<string, CachedSession>;
}
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<ComposioSessionCredentials> {
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<SessionRef> {
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<ComposioSessionCredentials> {
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);
});
},
};
}

View File

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

View File

@ -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<T> = {
items: T[];
next_cursor?: string | null;
total_pages?: number;
current_page?: number;
total_items?: number;
};
export type ComposioToolkit = {
slug: string;
name: string;
type?: string;
auth_schemes?: string[];
composio_managed_auth_schemes?: string[];
no_auth?: boolean;
auth_guide_url?: string | null;
meta?: {
description?: string | null;
logo?: string | null;
app_url?: string | null;
tools_count?: number;
triggers_count?: number;
version?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ComposioAuthConfig = {
id: string;
uuid?: string;
name?: string;
auth_scheme: string;
is_composio_managed: boolean;
status?: string;
toolkit: { slug: string; logo?: string | null; auth_guide_url?: string | null; auth_hint_url?: string | null };
[key: string]: unknown;
};
export type ComposioConnectLink = {
link_token: string;
redirect_url: string;
expires_at: string;
connected_account_id?: string | null;
[key: string]: unknown;
};
export type ComposioConnectedAccount = {
id: string;
user_id: string;
status: string;
status_reason?: string | null;
is_disabled?: boolean;
toolkit: { slug: string };
auth_config: { id: string; auth_scheme: string; is_composio_managed: boolean; is_disabled?: boolean };
[key: string]: unknown;
};
export type ComposioSession = {
session_id: string;
mcp: { type?: string; url: string; headers?: Record<string, string> };
config?: Record<string, unknown>;
config_version?: number;
warnings?: Array<{ code: string; message: string }>;
[key: string]: unknown;
};
export type ComposioListOptions = { cursor?: string; limit?: number };
export type ComposioSessionOptions = {
mcp: true;
toolkits?: string[];
tools?: Record<string, { enable: string[] }>;
authConfigs?: Record<string, string>;
connectedAccounts?: Record<string, string[]>;
};
export interface ComposioClient {
validateApiKey(): Promise<void>;
listToolkits(options?: ComposioListOptions): Promise<ComposioPage<ComposioToolkit>>;
listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise<ComposioPage<ComposioAuthConfig>>;
createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise<ComposioConnectLink>;
listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise<ComposioPage<ComposioConnectedAccount>>;
deleteConnectedAccount(connectedAccountId: string): Promise<void>;
createSession(userId: string, options: ComposioSessionOptions): Promise<ComposioSession>;
resumeSession(sessionId: string, options: { mcp: true }): Promise<ComposioSession>;
}
type ComposioClientOptions = { apiKey: string; baseUrl?: string; fetch?: typeof globalThis.fetch };
function appendQuery(url: URL, key: string, value: string | number | boolean | undefined) {
if (value !== undefined) url.searchParams.append(key, String(value));
}
function appendQueryList(url: URL, key: string, values: string[] | undefined) {
for (const value of values ?? []) url.searchParams.append(key, value);
}
function errorMessage(status: number): string {
if (status === 401 || status === 403) return "Composio rejected the API key.";
if (status === 429) return "Composio rate-limited the request. Try again shortly.";
return `Composio request failed with HTTP ${status}.`;
}
export function createComposioClient(options: ComposioClientOptions): ComposioClient {
const apiKey = options.apiKey.trim();
if (!apiKey) throw new Error("A Composio API key is required.");
const baseUrl = (options.baseUrl ?? DEFAULT_COMPOSIO_API_BASE_URL).replace(/\/+$/, "");
const fetchImpl = options.fetch ?? globalThis.fetch;
async function request<T>(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise<T> {
const url = new URL(`${baseUrl}/${path.replace(/^\/+/, "")}`);
query?.(url);
let response: Response;
try {
response = await fetchImpl(url, {
...init,
headers: {
accept: "application/json",
"x-api-key": apiKey,
...(init.body ? { "content-type": "application/json" } : {}),
...init.headers,
},
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
try {
return await response.json() as T;
} catch {
throw new ComposioApiError("Composio returned an invalid response.", response.status);
}
}
return {
async validateApiKey() {
await this.listToolkits({ limit: 1 });
},
listToolkits(options = {}) {
return request<ComposioPage<ComposioToolkit>>("toolkits", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
});
},
listAuthConfigs(options = {}) {
return request<ComposioPage<ComposioAuthConfig>>("auth_configs", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQuery(url, "show_disabled", options.showDisabled);
appendQuery(url, "toolkit_slug", options.toolkitSlugs?.join(","));
});
},
createConnectLink(input) {
return request<ComposioConnectLink>("connected_accounts/link", {
method: "POST",
body: JSON.stringify({
auth_config_id: input.authConfigId,
user_id: input.userId,
...(input.alias ? { alias: input.alias } : {}),
...(input.callbackUrl ? { callback_url: input.callbackUrl } : {}),
}),
});
},
listConnectedAccounts(options = {}) {
return request<ComposioPage<ComposioConnectedAccount>>("connected_accounts", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQueryList(url, "toolkit_slugs", options.toolkitSlugs);
appendQueryList(url, "statuses", options.statuses);
appendQueryList(url, "user_ids", options.userIds);
appendQueryList(url, "auth_config_ids", options.authConfigIds);
});
},
async deleteConnectedAccount(connectedAccountId) {
const url = new URL(`${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`);
let response: Response;
try {
response = await fetchImpl(url, {
method: "DELETE",
headers: { accept: "application/json", "x-api-key": apiKey },
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
},
createSession(userId, options) {
return request<ComposioSession>("tool_router/session", {
method: "POST",
body: JSON.stringify({
user_id: userId,
mcp: options.mcp,
...(options.toolkits ? { toolkits: { enabled: options.toolkits } } : {}),
...(options.tools ? { tools: options.tools } : {}),
...(options.authConfigs ? { auth_configs: options.authConfigs } : {}),
...(options.connectedAccounts ? { connected_accounts: options.connectedAccounts } : {}),
}),
});
},
resumeSession(sessionId, _options) {
return request<ComposioSession>(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, {
method: "POST",
body: JSON.stringify({}),
});
},
};
}

View File

@ -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);
});
});

View File

@ -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";

View File

@ -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

View File

@ -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"),
};
}

View File

@ -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<PaperclipIdConnectorOperation, string> = {
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<ConnectorResponse> {
const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString();
const issuedAt = Math.floor(now() / 1000);
const payload: Record<string, unknown> = {
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<typeof createPaperclipIdGmailConnector>;
function signRequest(payload: Record<string, unknown>, 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<SealedEnvelope>;
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<SealedGmailCredentials>;
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<string> {
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]";
}

View File

@ -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<Response>;
/** 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<Parameters<Db["transaction"]>[0]>[0];
@ -702,6 +714,7 @@ const APPROVED_STDIO_TEMPLATES: Record<string, {
};
const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets";
const COMPOSIO_GALLERY_KEY = "composio";
const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets";
const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV = "GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS";
const CONNECTION_TOKEN_MINT_TOOL_NAME = "connection_token.mint";
@ -795,6 +808,10 @@ function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) {
}));
}
function credentialRefConfigPath(ref: { name: string }): string {
return ref.name.startsWith("credentials.") ? ref.name : `credentials.${ref.name}`;
}
export function normalizeConnectionMethodConfig(
method: ConnectionMethodDef,
configValues: Record<string, unknown> | 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, unknown>): 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<ToolConnectionRemovalResult> {
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<McpToolDescriptor[]> {
const headers = { ...projectedConnectionHeaders(connection), ...await resolveCredentialHeaders(connection) };
async function remoteTools(
connection: typeof toolConnections.$inferSelect,
credentialHeaders?: Record<string, string>,
): Promise<McpToolDescriptor[]> {
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<McpToolDescriptor[]> {
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<ReturnType<ComposioClient["listConnectedAccounts"]>>["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<string, string>,
): Promise<McpToolDescriptor[]> {
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<string, string>;
} = {},
): Promise<ToolCatalogRefreshResult> {
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<ConnectToolAppResult> {
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<ToolExampleSummary[]> => {
@ -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<ToolConnection> => {
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);
},

View File

@ -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<Response>;
/** 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<McpGatewayRateLimitConfig>;
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
@ -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<string, Promise<typeof connectionGrants.$inferSelect>>();
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<typeof connectionGrants.$inferSelect> {
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<Record<string, string>> {
grant = await maybeRefreshPaperclipIdGmailGrant(session, connection, grant);
const headers: Record<string, string> = {};
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
@ -3406,27 +3535,37 @@ export function createToolGatewayService(
): Promise<RemoteHttpExecutionResult> {
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,

View File

@ -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<ToolConnection>(`/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<ToolConnection & { removal: ToolConnectionRemovalSummary }>(
`/tool-connections/${connectionId}`,
`/tool-connections/${connectionId}${options.confirmComposioChildren ? "?confirmComposioChildren=true" : ""}`,
),
checkConnectionHealth: (connectionId: string) =>
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/health-check`, {}),
@ -393,6 +399,24 @@ export const toolsApi = {
api.get<ToolConnectionTestCallStatus>(
`/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<ComposioServicesResponse>(`/tool-connections/${connectionId}/services`),
startComposioServiceConnect: (connectionId: string, toolkitSlug: string) =>
api.post<ComposioConnectLinkResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/connect`,
{},
),
getComposioServiceStatus: (connectionId: string, toolkitSlug: string) =>
api.get<ComposioServiceStatusResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/status`,
),
disconnectComposioService: (connectionId: string, toolkitSlug: string) =>
api.delete<ComposioDisconnectResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}`,
),
importMcpJson: (companyId: string, body: { mcpJson: unknown }) =>
api.post<McpJsonImportPreview>(`/companies/${companyId}/tools/mcp/import-json`, body),
listStdioTemplates: (companyId: string) =>

View File

@ -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) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
).map((tab) => (
{APP_TABS.filter((tab) => visibleTab(tab.key, props.kind, isBroker)).map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
@ -121,6 +122,15 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
);
}
function visibleTab(
tab: AppTabKey,
kind: AppDetailSidebarProps["kind"],
isBroker: boolean,
): boolean {
if (kind !== "connection" && CONNECTED_ONLY_APP_TABS.has(tab)) return false;
return !BROKER_ONLY_APP_TABS.has(tab) || isBroker;
}
function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
return props.kind === "connection"
? appTabHref(props.connectionId, tab)

View File

@ -79,8 +79,8 @@ const APP_COPY: Record<string, AppCopy> = {
short: "Share each sheet with the robot email, then paste the links.",
},
gmail: {
tagline: "Read mail and send drafts for your review.",
short: "Read mail and send drafts for your review.",
tagline: "Read mail and create drafts for your review.",
short: "Read mail and create drafts for your review.",
},
hubspot: {
tagline: "Look up contacts and update deal stages.",

View File

@ -31,6 +31,8 @@ export const queryKeys = {
["tools", "connection", connectionId, "installs"] as const,
connectionGrants: (connectionId: string) =>
["tools", "connection", connectionId, "grants"] as const,
composioServices: (connectionId: string) =>
["tools", "connection", connectionId, "composio-services"] as const,
catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const,
connectionActivity: (connectionId: string) =>
["tools", "connection", connectionId, "activity"] as const,

View File

@ -1,4 +1,7 @@
import { useState } from "react";
import { ServicesList } from "./apps/app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
import type { ComposioServiceRow } from "./apps/composio-services";
import {
BookOpen,
Bot,
@ -232,6 +235,58 @@ const DESIGN_GUIDE_DEGRADED_OUTPUTS: IssueWorkProduct[] = [
/* Section wrapper */
/* ------------------------------------------------------------------ */
/**
* Composio service rows for the design guide (PAP-17865). One row per state, so
* a reader can compare all four side by side rather than connecting a real
* Composio project to see them.
*/
const DESIGN_GUIDE_COMPOSIO_ROWS: ComposioServiceRow[] = [
{
toolkitSlug: "github",
name: "GitHub",
description: "Issues, pull requests, and repository actions",
logoUrl: null,
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "design-guide-child",
toolCount: 42,
noAuth: false,
},
{
toolkitSlug: "hubspot",
name: "HubSpot",
description: "CRM contacts and deals",
logoUrl: null,
state: "attention",
connectedAccountStatus: "EXPIRED",
childConnectionId: "design-guide-child-2",
toolCount: 18,
noAuth: false,
},
{
toolkitSlug: "slack",
name: "Slack",
description: "Channels and messages",
logoUrl: null,
state: "pending",
connectedAccountStatus: "INITIALIZING",
childConnectionId: null,
toolCount: 12,
noAuth: false,
},
{
toolkitSlug: "gmail",
name: "Gmail",
description: "Read and send mail",
logoUrl: null,
state: "not_connected",
connectedAccountStatus: null,
childConnectionId: null,
toolCount: 9,
noAuth: false,
},
];
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-4">
@ -1947,6 +2002,49 @@ export function DesignGuide() {
</SubSection>
</Section>
<Section title="Composio Services">
<p className="text-sm text-muted-foreground">
A broker connection (Composio) fronts many services, so its detail page lists toolkits
with per-service state instead of one credential. Row state comes from Composio's own
account status, which is why there is a fourth <code>attention</code> state alongside the
three the design asks for: an expired credential is neither connected nor still settling.
</p>
<SubSection title="Row states">
<ServicesList
rows={DESIGN_GUIDE_COMPOSIO_ROWS}
busySlug={null}
onConnect={() => {}}
onRecheck={() => {}}
onDisconnect={() => {}}
/>
</SubSection>
<SubSection title="Busy row">
<ServicesList
rows={[DESIGN_GUIDE_COMPOSIO_ROWS[2]!]}
busySlug={DESIGN_GUIDE_COMPOSIO_ROWS[2]!.toolkitSlug}
onConnect={() => {}}
onRecheck={() => {}}
onDisconnect={() => {}}
/>
</SubSection>
<SubSection title="Provenance chip">
<p className="mb-2 text-xs text-muted-foreground">
Shown wherever a brokered child connection appears, so the parent/child coupling is
legible. Links to the broker's Services tab when the parent is known.
</p>
<div className="flex items-center gap-3">
<ComposioProvenanceChip
connection={{
config: { provider: "composio", parentConnectionId: "parent-1", toolkitSlug: "github" },
}}
/>
<ComposioProvenanceChip
connection={{ config: { provider: "composio", toolkitSlug: "gmail" } }}
/>
</div>
</SubSection>
</Section>
<Section title="Environment Variables Editor">
<p className="text-sm text-muted-foreground">
Reusable env-var editor (agents, projects, environments, routines). One shared grid, an

View File

@ -11,6 +11,7 @@ import { APP_TABS } from "./app-tabs";
const getConnectionMock = vi.hoisted(() => vi.fn());
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listCatalogMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
const listPoliciesMock = vi.hoisted(() => vi.fn());
@ -40,6 +41,7 @@ vi.mock("@/api/tools", () => ({
getConnection: (connectionId: string) => getConnectionMock(connectionId),
getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId),
listGallery: (companyId: string) => listGalleryMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
listPolicies: (companyId: string) => listPoliciesMock(companyId),
@ -265,6 +267,7 @@ describe("AppDetail", () => {
mockSearchParams.value = new URLSearchParams();
getConnectionMock.mockResolvedValue(connection());
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [],
@ -380,6 +383,9 @@ describe("AppDetail", () => {
expect(APP_TABS.map((tab) => tab.key)).toEqual([
"setup",
"test",
// Services (PAP-17865) sits below Test rather than above it, so the
// Setup→Test adjacency this test exists to protect still holds.
"services",
"review",
"permissions",
"activity",

View File

@ -38,6 +38,8 @@ import {
} from "./app-definition-display";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
import { SetupPanel, connectionProviderName } from "./app-detail/SetupPanel";
import { ServicesPanel } from "./app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
import { actsAsSummary } from "./connection-identity";
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
@ -78,6 +80,11 @@ export function AppDetail() {
queryFn: () => toolsApi.getConnection(connectionId),
enabled: !!connectionId && !!activeTab,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "setup",
});
const installsQuery = useQuery({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
@ -136,6 +143,11 @@ export function AppDetail() {
});
const connection = connectionQuery.data;
const composioChildConnectionCount = (connectionsQuery.data?.connections ?? []).filter(
(candidate) => candidate.status !== "archived"
&& candidate.config?.provider === "composio"
&& candidate.config?.parentConnectionId === connectionId,
).length;
const logoEntry = useMemo(
() => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection),
[galleryQuery.data, connection],
@ -433,7 +445,9 @@ export function AppDetail() {
});
const removeApp = useMutation({
mutationFn: () => toolsApi.archiveConnection(connectionId),
mutationFn: () => toolsApi.archiveConnection(connectionId, {
confirmComposioChildren: composioChildConnectionCount > 0,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
@ -649,6 +663,7 @@ export function AppDetail() {
connection={connection}
appName={appName}
galleryEntry={logoEntry}
childConnectionCount={composioChildConnectionCount}
removing={removeApp.isPending}
onRemove={() => removeApp.mutate()}
onReplaced={() => {
@ -659,6 +674,9 @@ export function AppDetail() {
/>
</div>
)}
{activeTab === "services" && (
<ServicesPanel connectionId={connectionId} appName={appName} />
)}
{activeTab === "review" && (
reviewFailed
? <ToolsLoadError onRetry={() => {
@ -819,6 +837,7 @@ function AppDetailHeader({
</div>
)}
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
<ComposioProvenanceChip connection={connection} className="mt-1" />
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={status} />
{actionCount !== null && (

View File

@ -26,6 +26,7 @@ const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
@ -424,6 +425,32 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(posthog.wholeOrg?.disabled).toBe(false);
});
it("shows Gmail as a personal-only identity", async () => {
listGalleryMock.mockResolvedValue({ apps: [GMAIL] });
await render();
await act(async () => {
buttonContaining("Gmail")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Just me.");
expect(document.body.textContent).toContain("Agents use this Gmail identity only when work runs for you.");
expect(document.body.textContent).not.toContain("The whole organization");
});
it("opens a brokered Gmail deep link at the access step", async () => {
mockParams.appKey = "gmail";
mockSearch.value = "byo=1&appKey=gmail&stage=access";
listGalleryMock.mockResolvedValue({ apps: [GMAIL] });
await render();
expect(document.body.textContent).toContain("Who is this credential for?");
expect(document.body.textContent).toContain("Just me.");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
/**
* Design §"Question 2": a member who may create a personal grant but cannot
* configure a company-wide install sees **Any agent** disabled with the

View File

@ -112,6 +112,7 @@ function defaultGrantKindFor(
entry: AppDefinition | null,
method: ConnectionMethodDef | null,
): ConnectionGrantKind {
if (method?.grantKinds?.length === 1) return method.grantKinds[0]!;
const auth = method?.auth ?? (entry ? getAvailableConnectionMethod(entry)?.auth : null);
return auth === "oauth" ? "user" : "organization";
}
@ -522,7 +523,8 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null;
const methods = requestedEntry ? getAvailableConnectionMethods(requestedEntry) : [];
const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug);
const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !directOAuth;
const brokeredOAuth = method?.oauthStrategy === "paperclip_id_connector";
const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !brokeredOAuth && !directOAuth;
if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) {
setEntry(null);
setStep("gallery");
@ -837,6 +839,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
methodKey={connectionMethodKey}
onMethodChange={(nextMethod) => {
setConnectionMethodKey(nextMethod.key);
if (nextMethod.grantKinds?.length === 1) setGrantKind(nextMethod.grantKinds[0]!);
setCredentials({});
setConfigValues(defaultMethodConfig(nextMethod));
}}
@ -930,6 +933,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
providerName={entry?.name ?? appName}
companyId={selectedCompanyId}
authKind={accessStepAuthKind}
grantKinds={accessStepMethod?.grantKinds}
grantKind={grantKind}
setGrantKind={setGrantKind}
installChoice={installChoice}
@ -1268,7 +1272,8 @@ function GalleryStep({
const copy = appCopyFor(app.slug, app.description);
const methods = getAvailableConnectionMethods(app);
const oauth = methods[0]?.auth === "oauth";
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
const brokeredOAuth = methods[0]?.oauthStrategy === "paperclip_id_connector";
const oauthBlocked = methods.length === 1 && oauth && !brokeredOAuth && !isMcpDirectOAuthConnectSlug(app.slug);
const unavailable = app.availability?.available === false;
return (
<button
@ -2183,6 +2188,7 @@ export function AccessStep({
providerName,
companyId,
authKind,
grantKinds,
grantKind,
setGrantKind,
installChoice,
@ -2198,6 +2204,7 @@ export function AccessStep({
providerName: string;
companyId: string;
authKind: ToolConnectionAuthKind;
grantKinds?: ConnectionGrantKind[];
grantKind: ConnectionGrantKind;
setGrantKind: (kind: ConnectionGrantKind) => void;
installChoice: "specific" | "all";
@ -2229,6 +2236,7 @@ export function AccessStep({
// scope stays legible instead of quietly disappearing.
const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true;
const needsIdentityChoice = authKind !== "none";
const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]);
const canContinue = installChoice === "all"
? canSetCompanyInstall
: installAgentIds.size > 0;
@ -2244,7 +2252,12 @@ export function AccessStep({
<div className="mt-6 space-y-6">
<div>
<h3 className="text-sm font-semibold text-foreground">Who is this credential for?</h3>
{needsIdentityChoice ? (
{needsIdentityChoice && allowedGrantKinds.length === 1 ? (
<p className="mt-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Just me.</span>{" "}
Agents use this {appName} identity only when work runs for you.
</p>
) : needsIdentityChoice ? (
<RadioCardGroup
ariaLabel="Who is this credential for?"
className="mt-2 sm:grid-cols-2"
@ -2261,7 +2274,7 @@ export function AccessStep({
title: "The whole organization",
description: "Agents use one shared identity for eligible organization members.",
},
]}
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
/>
) : (
// A connection with no credential has no identity to choose, so

View File

@ -88,6 +88,8 @@ describe("Browse store door (PAP-13254 door 1)", () => {
galleryEntry({ key: "github", name: "GitHub", tagline: "Open PRs and issues." }),
galleryEntry({ key: "slack", name: "Slack", tagline: "Post messages to channels." }),
galleryEntry({ key: "notion", name: "Notion", tagline: "Read and update workspace content." }),
galleryEntry({ key: "composio", name: "Composio", tagline: "Connect hosted toolkits." }),
galleryEntry({ key: "gmail", name: "Gmail", tagline: "Search and draft email." }),
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
],
});
@ -136,7 +138,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(text).not.toContain("review its actions before enabling it");
});
it("enables Notion, Zapier, and custom URLs while fading unfinished integrations", async () => {
it("enables Notion, Zapier, Gmail, and custom URLs while fading unfinished integrations", async () => {
await renderBrowse();
const zapierTiles = Array.from(
@ -148,6 +150,12 @@ describe("Browse store door (PAP-13254 door 1)", () => {
const notionTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Notion"]'),
);
const composioTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Composio"]'),
);
const gmailTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Gmail"]'),
);
const tile = container.querySelector<HTMLButtonElement>(
'button[aria-label="Coming soon for Acme CRM"]',
);
@ -159,6 +167,10 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
expect(notionTiles).toHaveLength(2);
expect(notionTiles.every((button) => !button.disabled)).toBe(true);
expect(composioTiles).toHaveLength(1);
expect(composioTiles[0]?.disabled).toBe(false);
expect(gmailTiles).toHaveLength(1);
expect(gmailTiles[0]?.disabled).toBe(false);
expect(githubTiles.every((button) => button.disabled)).toBe(true);
expect(tile?.disabled).toBe(true);
expect(byoCard?.disabled).toBe(false);
@ -175,6 +187,16 @@ describe("Browse store door (PAP-13254 door 1)", () => {
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
await act(async () => {
composioTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=composio&stage=setup");
await act(async () => {
gmailTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=gmail&stage=access");
await act(async () => {
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

View File

@ -39,6 +39,8 @@ function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
if (slug === "notion") return NOTION_CONNECT_HREF;
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
if (slug === "posthog") return "/apps/connect?byo=1&appKey=posthog&stage=setup";
if (slug === "composio") return "/apps/connect?byo=1&appKey=composio&stage=setup";
if (slug === "gmail") return "/apps/connect?byo=1&appKey=gmail&stage=access";
return null;
}
@ -173,6 +175,7 @@ export function Browse() {
const tileProps = (entry: AppGalleryDisplayEntry) => {
const summary = connectionSummaryBySlug.get(appDefinitionSlug(entry));
const connectHref = connectHrefFor(entry);
const available = entry.availability?.available !== false;
const primaryConnection = summary?.primaryConnection ?? null;
const owner = primaryConnection ? connectionOwnerProfile(primaryConnection, userProfileById) : null;
const addAnotherHref = summary
@ -188,7 +191,7 @@ export function Browse() {
? () => navigate(summary && summary.count > 1
? `/apps/app/${summary.applicationId}/setup`
: `/apps/${primaryConnection.id}/setup`)
: connectHref
: available && connectHref
? () => navigate(connectHref)
: undefined,
onAddAnother: addAnotherHref ? () => navigate(addAnotherHref) : undefined,

View File

@ -0,0 +1,54 @@
import { Blocks } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { appTabHref } from "./app-tabs";
import { composioChildParentConnectionId, composioChildToolkitSlug } from "./composio-services";
/**
* "via Composio" on a connection that Composio brokers (PAP-17865).
*
* A toolkit child looks like any other app in the connections list, which hides
* the coupling that matters most: its credentials live in Composio, and pausing
* or removing the Composio connection takes it down too. The chip states that
* relationship wherever the child appears, and links to the broker's Services tab
* so the parent is one click away.
*/
export function ComposioProvenanceChip({
connection,
className,
}: {
connection: { config?: Record<string, unknown> | null } | null | undefined;
className?: string;
}) {
const toolkitSlug = composioChildToolkitSlug(connection);
if (!toolkitSlug) return null;
const parentConnectionId = composioChildParentConnectionId(connection);
const chipClass = cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
className,
);
const label = (
<>
<Blocks className="h-3 w-3" />
via Composio
</>
);
// Without a parent id there is nowhere to send the reader, so the chip stays a
// label rather than becoming a dead link.
if (!parentConnectionId) {
return <span className={chipClass} title={`Brokered by Composio (${toolkitSlug})`}>{label}</span>;
}
return (
<Link
to={appTabHref(parentConnectionId, "services")}
className={cn(chipClass, "transition-colors hover:bg-accent hover:text-accent-foreground")}
title={`Brokered by Composio (${toolkitSlug}) — open the Composio Services tab`}
onClick={(event) => event.stopPropagation()}
>
{label}
</Link>
);
}

View File

@ -23,7 +23,8 @@ vi.mock("@/api/tools", () => ({
listConnections: (companyId: string) => listConnectionsMock(companyId),
listAppsAttention: (companyId: string) => listAppsAttentionMock(companyId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
archiveConnection: (connectionId: string) => archiveConnectionMock(connectionId),
archiveConnection: (connectionId: string, options?: { confirmComposioChildren?: boolean }) =>
archiveConnectionMock(connectionId, options),
},
}));
@ -395,7 +396,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github");
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github", {
confirmComposioChildren: false,
});
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection deleted",
@ -405,6 +408,49 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
);
});
it("confirms that deleting a Composio parent removes its child services", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ id: "app-composio", name: "Composio" })],
});
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "composio-parent",
applicationId: "app-composio",
name: "Composio",
config: { sourceTemplateKey: "composio" },
}),
connection({
id: "composio-github",
applicationId: "app-composio",
name: "GitHub (via Composio)",
config: { provider: "composio", parentConnectionId: "composio-parent", toolkitSlug: "github" },
}),
],
});
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Composio connection"]',
);
await act(async () => {
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.textContent).toContain("This also removes 1 connected service");
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Delete connection",
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("composio-parent", {
confirmComposioChildren: true,
});
});
it("reports the remaining active connections after deleting one", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ id: "app-github", name: "GitHub" })],
@ -437,7 +483,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github-primary");
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github-primary", {
confirmComposioChildren: false,
});
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection deleted",

View File

@ -33,6 +33,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { AppLogo } from "./AppLogo";
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
import { composioChildParentConnectionId } from "./composio-services";
import {
appApplicationSourceSlug,
appDefinitionLogoUrl,
@ -116,6 +118,7 @@ export function Connections() {
id: string;
appName: string;
remainingConnectionCount: number;
childConnectionCount: number;
} | null>(null);
useEffect(() => {
@ -154,8 +157,15 @@ export function Connections() {
});
const deleteConnection = useMutation({
mutationFn: (target: { id: string; appName: string; remainingConnectionCount: number }) =>
toolsApi.archiveConnection(target.id),
mutationFn: (target: {
id: string;
appName: string;
remainingConnectionCount: number;
childConnectionCount: number;
}) =>
toolsApi.archiveConnection(target.id, {
confirmComposioChildren: target.childConnectionCount > 0,
}),
onSuccess: (_connection, target) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
@ -395,8 +405,9 @@ export function Connections() {
size={32}
/>
<div className="min-w-0">
<div className="font-medium text-foreground">
{row.displayName}
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{row.displayName}</span>
<ComposioProvenanceChip connection={row.connection} />
</div>
{hint && (
<div className="truncate text-xs text-muted-foreground">{hint}</div>
@ -449,6 +460,9 @@ export function Connections() {
id: connection.id,
appName: application.name,
remainingConnectionCount: row.remainingAgentAvailableConnectionCount,
childConnectionCount: connections.filter(
(candidate) => composioChildParentConnectionId(candidate) === connection.id,
).length,
});
}}
>
@ -485,7 +499,9 @@ export function Connections() {
Delete {connectionToDelete?.appName ?? "this"} connection?
</AlertDialogTitle>
<AlertDialogDescription>
{connectionToDelete && connectionToDelete.remainingConnectionCount > 0
{connectionToDelete && connectionToDelete.childConnectionCount > 0
? `This also removes ${connectionToDelete.childConnectionCount} connected ${connectionToDelete.childConnectionCount === 1 ? "service" : "services"} and takes agent access away immediately. The Composio key and child session credentials are deleted.`
: connectionToDelete && connectionToDelete.remainingConnectionCount > 0
? `This connection's saved credentials are deleted and agents lose access through it immediately. Agents can still use ${connectionToDelete.appName} through ${connectionToDelete.remainingConnectionCount} other active ${connectionToDelete.remainingConnectionCount === 1 ? "connection" : "connections"}.`
: "The saved credentials are deleted and agents lose access immediately. Connecting it again later needs a new sign-in or key."}
</AlertDialogDescription>

View File

@ -27,6 +27,16 @@ function renderDangerZone() {
return container;
}
function renderComposioDangerZone() {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(
<DangerZone appName="Composio" childConnectionCount={2} removing={false} onRemove={vi.fn()} />,
));
return container;
}
/**
* Remove app deletes the operator's credentials and revokes agent access
* (PAP-17119). The confirmation has to say so before the operator commits a
@ -56,4 +66,10 @@ describe("DangerZone", () => {
expect(text).toContain("Deletes the saved credentials for PostHog");
expect(text).toContain("needs a new sign-in or key");
});
it("names every child service that parent removal will take down", () => {
const node = renderComposioDangerZone();
expect(node.textContent).toContain("removes 2 connected services");
expect(node.textContent).toContain("Agents lose access to all of them right away");
});
});

View File

@ -16,11 +16,13 @@ export function AdvancedPanel({
connection,
appName,
galleryEntry,
childConnectionCount,
removing,
onRemove,
onReplaced,
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
removing: boolean;
childConnectionCount?: number;
onRemove: () => void;
onReplaced: () => void;
}) {
@ -28,7 +30,12 @@ export function AdvancedPanel({
<div className="space-y-6">
<KeySection connection={connection} galleryEntry={galleryEntry} onReplaced={onReplaced} />
<TechnicalDetails connection={connection} />
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
<DangerZone
appName={appName}
childConnectionCount={childConnectionCount}
removing={removing}
onRemove={onRemove}
/>
</div>
);
}
@ -269,10 +276,12 @@ function TechnicalDetails({ connection }: { connection: ToolConnection }) {
export function DangerZone({
appName,
childConnectionCount = 0,
removing,
onRemove,
}: {
appName: string;
childConnectionCount?: number;
removing: boolean;
onRemove: () => void;
}) {
@ -286,8 +295,9 @@ export function DangerZone({
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
Deletes the saved credentials for {appName} and takes agent access away right away.
Connecting it again later needs a new sign-in or key.
{childConnectionCount > 0
? `Deletes the saved credentials for ${appName} and removes ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}. Agents lose access to all of them right away.`
: `Deletes the saved credentials for ${appName} and takes agent access away right away. Connecting it again later needs a new sign-in or key.`}
</p>
</div>
{confirming ? (

View File

@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ServicesList } from "./ServicesPanel";
import type { ComposioServiceRow } from "../composio-services";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({
// The row's "Manage" affordance only has to be a link with the right target;
// exercising the real router here would test the router, not the panel.
Link: ({ to, children, ...rest }: { to: string; children?: unknown }) => (
<a href={to} {...rest}>{children as never}</a>
),
}));
function act(callback: () => void) {
flushSync(callback);
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
container?.remove();
container = null;
root = null;
});
function row(over: Partial<ComposioServiceRow> & { toolkitSlug: string }): ComposioServiceRow {
return {
name: over.toolkitSlug,
description: null,
logoUrl: null,
state: "not_connected",
connectedAccountStatus: null,
childConnectionId: null,
toolCount: null,
noAuth: false,
...over,
};
}
function renderList(rows: ComposioServiceRow[], handlers: {
onConnect?: (row: ComposioServiceRow) => void;
onRecheck?: (row: ComposioServiceRow) => void;
onDisconnect?: (row: ComposioServiceRow) => void;
} = {}) {
container = document.createElement("div");
document.body.appendChild(container);
const renderRoot = createRoot(container);
root = renderRoot;
act(() => renderRoot.render(
<ServicesList
rows={rows}
busySlug={null}
onConnect={handlers.onConnect ?? vi.fn()}
onRecheck={handlers.onRecheck ?? vi.fn()}
onDisconnect={handlers.onDisconnect ?? vi.fn()}
/>,
));
return container;
}
function buttonLabelled(node: HTMLElement, label: string) {
return Array.from(node.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === label);
}
/**
* The three states the design asks for, plus the `attention` state Composio can
* report. Each state has to be distinguishable *and* offer the one action that
* moves it forward a "Connected" row that still says "Connect" would be the
* bug this covers.
*/
describe("ServicesList row states", () => {
it("offers Connect on a not-connected service", () => {
const node = renderList([row({ toolkitSlug: "gmail", name: "Gmail", description: "Email" })]);
const text = node.textContent ?? "";
expect(text).toContain("Gmail");
expect(text).toContain("Not connected");
expect(buttonLabelled(node, "Connect")).toBeTruthy();
expect(buttonLabelled(node, "Disconnect")).toBeFalsy();
});
it("shows a pending service as waiting, with no Connect to click twice", () => {
const node = renderList([row({
toolkitSlug: "slack",
name: "Slack",
state: "pending",
connectedAccountStatus: "INITIALIZING",
})]);
const text = node.textContent ?? "";
expect(text).toContain("Pending");
expect(text).toContain("Waiting for Composio to confirm the connection.");
expect(buttonLabelled(node, "Connect")).toBeFalsy();
// Re-check is the only affordance, so an impatient user cannot mint a second link.
expect(node.querySelector('[aria-label="Check Slack again"]')).toBeTruthy();
});
it("shows a connected service with Manage and Disconnect", () => {
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
toolCount: 12,
})]);
const text = node.textContent ?? "";
expect(text).toContain("Connected");
expect(text).toContain("12 actions");
expect(buttonLabelled(node, "Connect")).toBeFalsy();
expect(buttonLabelled(node, "Disconnect")).toBeTruthy();
const manage = node.querySelector<HTMLAnchorElement>('a[href="/apps/child-1/permissions"]');
expect(manage?.textContent).toContain("Manage");
});
/**
* An expired credential is neither "connected" nor "still working": it needs the
* user. It therefore gets Reconnect *and* Disconnect, and says what Composio
* reported, so the reader is not left guessing why a green row went quiet.
*/
it("explains an unusable credential and offers Reconnect", () => {
const node = renderList([row({
toolkitSlug: "hubspot",
name: "HubSpot",
state: "attention",
connectedAccountStatus: "EXPIRED",
childConnectionId: "child-2",
})]);
const text = node.textContent ?? "";
expect(text).toContain("Needs attention");
expect(text).toContain("Composio reports this connection as expired");
expect(buttonLabelled(node, "Reconnect")).toBeTruthy();
expect(buttonLabelled(node, "Disconnect")).toBeTruthy();
});
it("renders every state together without collapsing any of them", () => {
const node = renderList([
row({ toolkitSlug: "github", name: "GitHub", state: "connected", childConnectionId: "c1" }),
row({ toolkitSlug: "slack", name: "Slack", state: "pending" }),
row({ toolkitSlug: "gmail", name: "Gmail" }),
row({ toolkitSlug: "hubspot", name: "HubSpot", state: "attention", connectedAccountStatus: "EXPIRED" }),
]);
expect(node.querySelectorAll("li")).toHaveLength(4);
const text = node.textContent ?? "";
for (const label of ["Connected", "Pending", "Not connected", "Needs attention"]) {
expect(text).toContain(label);
}
});
});
describe("ServicesList actions", () => {
it("asks to connect the row that was clicked", () => {
const onConnect = vi.fn();
const node = renderList([
row({ toolkitSlug: "gmail", name: "Gmail" }),
row({ toolkitSlug: "asana", name: "Asana" }),
], { onConnect });
const asanaRow = Array.from(node.querySelectorAll("li"))
.find((item) => item.textContent?.includes("Asana"));
const connect = Array.from(asanaRow!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Connect");
act(() => connect!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onConnect).toHaveBeenCalledTimes(1);
expect(onConnect.mock.calls[0]![0].toolkitSlug).toBe("asana");
});
/** Disconnect is destructive, so the row must not act on its own — it asks first. */
it("routes Disconnect through the caller's confirm step", () => {
const onDisconnect = vi.fn();
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
childConnectionId: "c1",
})], { onDisconnect });
act(() => buttonLabelled(node, "Disconnect")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onDisconnect).toHaveBeenCalledTimes(1);
expect(onDisconnect.mock.calls[0]![0].toolkitSlug).toBe("github");
});
it("disables the row's actions while it is busy", () => {
container = document.createElement("div");
document.body.appendChild(container);
const renderRoot = createRoot(container);
root = renderRoot;
act(() => renderRoot.render(
<ServicesList
rows={[row({ toolkitSlug: "gmail", name: "Gmail" })]}
busySlug="gmail"
onConnect={vi.fn()}
onRecheck={vi.fn()}
onDisconnect={vi.fn()}
/>,
));
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((button) => button.disabled)).toBe(true);
});
});
/**
* The tab must never render session or MCP credential material. The panel is
* given rows that deliberately have no such field, so this asserts the row type
* stays that way: if a future change threads a session URL or header into the
* row, this catches it at the render boundary.
*/
describe("ServicesList credential hygiene", () => {
it("renders no URL or bearer-looking text for a connected service", () => {
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
})]);
const text = node.textContent ?? "";
expect(text).not.toMatch(/https?:\/\//);
expect(text).not.toMatch(/bearer/i);
expect(text).not.toMatch(/x-api-key/i);
// The Composio account id is an opaque handle, but it has no reason to be on screen.
expect(text).not.toContain("ca-");
});
});

View File

@ -0,0 +1,435 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { cn } from "@/lib/utils";
import { AppLogo } from "../AppLogo";
import { appTabHref } from "../app-tabs";
import {
composioServiceIsSettling,
composioServiceRows,
type ComposioServiceRow,
type ComposioServiceState,
} from "../composio-services";
/** How often a settling row is re-read while the user finishes authorizing in Composio. */
const PENDING_POLL_MS = 3_000;
/**
* The Services tab of a Composio connection (PAP-17865).
*
* Composio is a broker: this one connection's API key fronts every toolkit in the
* customer's Composio project. So this tab is a list of *services*, each with its
* own state, rather than the single-credential Setup tab every other app gets.
*
* Connecting a toolkit deliberately leaves Paperclip: the server mints a
* Composio-hosted Connect Link and the browser opens it in a new tab, so the
* third-party consent screen and any API key the toolkit needs are entered in
* Composio and never transit Paperclip. Because that happens out of band, the
* only way to learn the result is to re-read it hence the poll below, which is
* what lets a row go pendingconnected without a page reload.
*/
export function ServicesPanel({
connectionId,
appName,
}: {
connectionId: string;
appName: string;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [confirmDisconnect, setConfirmDisconnect] = useState<ComposioServiceRow | null>(null);
const [busySlug, setBusySlug] = useState<string | null>(null);
const servicesQuery = useQuery({
queryKey: queryKeys.tools.composioServices(connectionId),
queryFn: () => toolsApi.listComposioServices(connectionId),
enabled: !!connectionId,
// A row only settles when Composio finishes on the other tab, so poll while
// anything is in flight and stop as soon as nothing is. `refetchOnWindowFocus`
// (react-query's default) covers the common case of the user coming straight
// back after authorizing.
refetchInterval: (query) =>
composioServiceRows(query.state.data).some((row) => composioServiceIsSettling(row.state))
? PENDING_POLL_MS
: false,
});
const rows = composioServiceRows(servicesQuery.data);
/** A toolkit connecting adds a child connection, so the app-wide lists have to be re-read. */
const invalidateConnectionLists = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.composioServices(connectionId) });
queryClient.invalidateQueries({ queryKey: ["tools"] });
queryClient.invalidateQueries({ queryKey: ["apps"] });
};
const startConnect = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.startComposioServiceConnect(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: (link, row) => {
// The address comes back from Composio, so it is checked at the navigation
// boundary before the browser acts on it (same rule as PAP-17099).
const target = resolveAuthorizationTarget(link.redirect_url);
if (!target.ok) {
pushToast({ title: `Couldn't connect ${row.name}`, body: target.message, tone: "error" });
return;
}
// A new tab, not a top-level navigation: the user keeps this page — and its
// poll — alive while authorizing, which is what makes the row flip in place.
window.open(target.url, "_blank", "noopener,noreferrer");
pushToast({
title: `Finish connecting ${row.name} in Composio`,
body: "We opened Composio in a new tab. This list updates as soon as it reports back.",
tone: "info",
});
void servicesQuery.refetch();
},
onError: (error, row) =>
pushToast({
title: `Couldn't connect ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
const recheck = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.getComposioServiceStatus(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: () => invalidateConnectionLists(),
onError: (error, row) =>
pushToast({
title: `Couldn't check ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
const disconnect = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.disconnectComposioService(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: (_result, row) => {
setConfirmDisconnect(null);
invalidateConnectionLists();
pushToast({
title: `${row.name} disconnected`,
body: `Agents can no longer use ${row.name}, and its credentials are deleted from Composio.`,
tone: "success",
});
},
onError: (error, row) =>
pushToast({
title: `Couldn't disconnect ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
if (servicesQuery.isLoading) {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading services from Composio, this may take a moment.
</div>
);
}
if (servicesQuery.isError) {
return (
<ServicesLoadError
message={servicesQuery.error instanceof Error ? servicesQuery.error.message : null}
onRetry={() => { void servicesQuery.refetch(); }}
/>
);
}
return (
<div className="space-y-6">
<ServicesIntro appName={appName} connectedCount={rows.filter((r) => r.state === "connected").length} />
{rows.length === 0 ? (
<ServicesEmptyState />
) : (
<ServicesList
rows={rows}
busySlug={busySlug}
onConnect={(row) => startConnect.mutate(row)}
onRecheck={(row) => recheck.mutate(row)}
onDisconnect={(row) => setConfirmDisconnect(row)}
/>
)}
{confirmDisconnect && (
<DisconnectDialog
row={confirmDisconnect}
pending={disconnect.isPending}
onCancel={() => setConfirmDisconnect(null)}
onConfirm={() => disconnect.mutate(confirmDisconnect)}
/>
)}
</div>
);
}
function ServicesIntro({ appName, connectedCount }: { appName: string; connectedCount: number }) {
return (
<div className="max-w-2xl space-y-1">
<h2 className="text-lg font-semibold">Services</h2>
<p className="text-sm leading-6 text-muted-foreground">
{appName} brokers these services. Connect one and it becomes its own app in Paperclip, which
you then give to agents on its Permissions tab.
{connectedCount > 0 && (
<>
{" "}
<span className="font-medium text-foreground">
{connectedCount} {connectedCount === 1 ? "service is" : "services are"} connected.
</span>
</>
)}
</p>
</div>
);
}
function ServicesEmptyState() {
return (
<div className="rounded-xl border border-border bg-card p-6">
<p className="text-sm font-medium">No services available yet</p>
<p className="mt-1 max-w-xl text-sm text-muted-foreground">
This Composio project has no toolkits Paperclip can offer. Add a toolkit and an auth
configuration in Composio, then check back.
</p>
</div>
);
}
function ServicesLoadError({ message, onRetry }: { message: string | null; onRetry: () => void }) {
return (
<div className="space-y-3 py-8">
<p className="text-sm text-destructive">
{message ?? "Couldnt load services from Composio."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
</div>
);
}
/**
* The toolkit list. Split out from the panel so every row state can be rendered
* and screenshotted without a server.
*/
export function ServicesList({
rows,
busySlug,
onConnect,
onRecheck,
onDisconnect,
}: {
rows: ComposioServiceRow[];
busySlug: string | null;
onConnect: (row: ComposioServiceRow) => void;
onRecheck: (row: ComposioServiceRow) => void;
onDisconnect: (row: ComposioServiceRow) => void;
}) {
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => (
<ServiceRow
key={row.toolkitSlug}
row={row}
busy={busySlug === row.toolkitSlug}
onConnect={onConnect}
onRecheck={onRecheck}
onDisconnect={onDisconnect}
/>
))}
</ul>
);
}
export function ServiceRow({
row,
busy,
onConnect,
onRecheck,
onDisconnect,
}: {
row: ComposioServiceRow;
busy: boolean;
onConnect: (row: ComposioServiceRow) => void;
onRecheck: (row: ComposioServiceRow) => void;
onDisconnect: (row: ComposioServiceRow) => void;
}) {
return (
<li className="flex flex-wrap items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
<AppLogo name={row.name} logoUrl={row.logoUrl} size={32} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{row.name}</span>
<ServiceStateBadge state={row.state} />
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{serviceDetailLine(row)}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{row.state === "connected" && row.childConnectionId && (
<Button asChild size="sm" variant="ghost">
<Link to={appTabHref(row.childConnectionId, "permissions")}>Manage</Link>
</Button>
)}
{row.state === "pending" && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onRecheck(row)}
aria-label={`Check ${row.name} again`}
>
{busy
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
</Button>
)}
{row.state === "not_connected" ? (
<Button size="sm" disabled={busy} onClick={() => onConnect(row)}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : (
<>
Connect
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</>
)}
</Button>
) : row.state === "attention" ? (
<>
<Button size="sm" disabled={busy} onClick={() => onConnect(row)}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Reconnect"}
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDisconnect(row)}>
Disconnect
</Button>
</>
) : row.state === "connected" ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDisconnect(row)}>
Disconnect
</Button>
) : null}
</div>
</li>
);
}
/**
* The detail line under a service name. It answers "why is this row in this
* state", which for `pending` and `attention` is the only place Composio's own
* explanation can appear.
*/
function serviceDetailLine(row: ComposioServiceRow): string {
const toolCount = row.toolCount !== null
? `${row.toolCount} ${row.toolCount === 1 ? "action" : "actions"}`
: null;
if (row.state === "pending") {
return "Waiting for Composio to confirm the connection.";
}
if (row.state === "attention") {
return row.connectedAccountStatus
? `Composio reports this connection as ${row.connectedAccountStatus.toLowerCase()}. Reconnect to fix it.`
: "This connection is no longer usable. Reconnect to fix it.";
}
if (row.state === "connected") {
return [toolCount, "available to agents you install it for"].filter(Boolean).join(" · ");
}
return [
row.description,
toolCount,
row.noAuth ? "No sign-in needed" : null,
].filter(Boolean).join(" · ") || "Not connected";
}
const STATE_LABEL: Record<ComposioServiceState, string> = {
not_connected: "Not connected",
pending: "Pending",
connected: "Connected",
attention: "Needs attention",
};
/**
* Row state, in the same visual language as the connection status badge in the
* app header a reader should not have to learn two palettes for "connected".
*/
function ServiceStateBadge({ state }: { state: ComposioServiceState }) {
const klass: Record<ComposioServiceState, string> = {
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
pending: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
attention: "border-destructive/40 bg-destructive/10 text-destructive",
not_connected: "border-border bg-muted text-muted-foreground",
};
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
klass[state],
)}
>
{state === "connected" && <Check className="h-3 w-3" />}
{state === "pending" && <Loader2 className="h-3 w-3 animate-spin" />}
{state === "attention" && <AlertTriangle className="h-3 w-3" />}
{STATE_LABEL[state]}
</span>
);
}
function DisconnectDialog({
row,
pending,
onCancel,
onConfirm,
}: {
row: ComposioServiceRow;
pending: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect {row.name}?</AlertDialogTitle>
<AlertDialogDescription>
This removes {row.name} from Paperclip and deletes its credentials in Composio. Agents
using its actions lose them immediately. Connecting it again needs a new sign-in.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending} autoFocus>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={pending}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
>
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : `Disconnect ${row.name}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -1,8 +1,9 @@
import { Activity, Beaker, Inbox, Settings2, ShieldCheck } from "lucide-react";
import { Activity, Beaker, Blocks, Inbox, Settings2, ShieldCheck } from "lucide-react";
export const APP_TABS = [
{ key: "setup", label: "Setup", icon: Settings2 },
{ key: "test", label: "Test", icon: Beaker },
{ key: "services", label: "Services", icon: Blocks },
{ key: "review", label: "Review", icon: Inbox },
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "activity", label: "Activity", icon: Activity },
@ -13,9 +14,25 @@ export type AppTabKey = (typeof APP_TABS)[number]["key"];
/**
* Tabs hidden for an application that has no live connection (the
* `AppNotConnected` shell). The Test tab runs real calls against a connected
* app, so it only appears once the app is connected.
* app, so it only appears once the app is connected. Services lists the toolkits
* behind a broker's API key, which there is likewise nothing to read without one.
*/
export const CONNECTED_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>(["test"]);
export const CONNECTED_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>([
"test",
"services",
]);
/**
* Tabs that only make sense for a connection that brokers other services
* (PAP-17865).
*
* Composio is the only broker today: one API-key connection fronts many toolkits,
* so it is the only connection with per-service state to manage. Every other app
* *is* the service, so a Services tab there would be an empty page. This is a
* separate set from `CONNECTED_ONLY_APP_TABS` because the two conditions compose
* a broker tab needs both a live connection and a broker.
*/
export const BROKER_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>(["services"]);
export function appTabHref(connectionId: string, tab: AppTabKey): string {
return `/apps/${connectionId}/${tab}`;

View File

@ -0,0 +1,237 @@
import { describe, expect, it } from "vitest";
import {
composioChildParentConnectionId,
composioChildToolkitSlug,
composioServiceIsSettling,
composioServiceRowFromStatus,
composioServiceRows,
composioServiceStateFor,
isComposioBrokerConnection,
type ComposioServicesResponse,
} from "./composio-services";
import { BROKER_ONLY_APP_TABS, CONNECTED_ONLY_APP_TABS } from "./app-tabs";
function toolkit(slug: string, over: Record<string, unknown> = {}) {
return {
slug,
name: slug.toUpperCase(),
meta: { description: `${slug} desc`, logo: `https://logo/${slug}.png`, tools_count: 3 },
...over,
};
}
function listResponse(
services: ComposioServicesResponse["services"],
): ComposioServicesResponse {
return { parentConnectionId: "parent-1", userId: "paperclip:company-1", services };
}
describe("composioServiceStateFor", () => {
it("reads ACTIVE as connected regardless of casing", () => {
expect(composioServiceStateFor("ACTIVE")).toBe("connected");
expect(composioServiceStateFor("active")).toBe("connected");
});
it("reads a missing account as not connected", () => {
expect(composioServiceStateFor(null)).toBe("not_connected");
expect(composioServiceStateFor(undefined)).toBe("not_connected");
expect(composioServiceStateFor(" ")).toBe("not_connected");
});
it("reads an unusable credential as attention, not pending", () => {
for (const status of ["EXPIRED", "INACTIVE", "DISABLED", "FAILED", "REVOKED", "ERROR"]) {
expect(composioServiceStateFor(status)).toBe("attention");
}
});
/**
* The fallback direction matters: an unrecognized status must read as "still
* working on it" so a status Composio adds later shows a spinner the poll will
* clear, rather than a wrong terminal verdict the user cannot act on.
*/
it("treats an unknown in-flight status as pending", () => {
expect(composioServiceStateFor("INITIALIZING")).toBe("pending");
expect(composioServiceStateFor("INITIATED")).toBe("pending");
expect(composioServiceStateFor("SOME_FUTURE_STATUS")).toBe("pending");
});
});
describe("composioServiceRows", () => {
it("renders all three design states from one payload", () => {
const rows = composioServiceRows(listResponse([
{
toolkit: toolkit("gmail"),
status: "not_connected",
connectedAccountId: null,
connectedAccountStatus: null,
childConnectionId: null,
},
{
toolkit: toolkit("github"),
status: "connected",
connectedAccountId: "ca-1",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
},
{
toolkit: toolkit("slack"),
status: "pending",
connectedAccountId: "ca-2",
connectedAccountStatus: "INITIALIZING",
childConnectionId: null,
},
]));
expect(rows.map((row) => [row.toolkitSlug, row.state])).toEqual([
["github", "connected"],
["slack", "pending"],
["gmail", "not_connected"],
]);
});
it("carries the child connection id so a connected row can link to it", () => {
const [row] = composioServiceRows(listResponse([{
toolkit: toolkit("github"),
status: "connected",
connectedAccountId: "ca-1",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
}]));
expect(row!.childConnectionId).toBe("child-1");
expect(row!.logoUrl).toBe("https://logo/github.png");
expect(row!.toolCount).toBe(3);
});
/**
* The server's own `status` field only knows connected/pending/not_connected, so
* an expired account arrives labelled `pending`. Deriving state from the account
* status instead is what keeps that row from showing a spinner forever.
*/
it("classifies an expired account as attention even when the server said pending", () => {
const [row] = composioServiceRows(listResponse([{
toolkit: toolkit("github"),
status: "pending",
connectedAccountId: "ca-1",
connectedAccountStatus: "EXPIRED",
childConnectionId: "child-1",
}]));
expect(row!.state).toBe("attention");
});
it("sorts attention first, then connected, then pending, then the alphabetical tail", () => {
const rows = composioServiceRows(listResponse([
{ toolkit: toolkit("zoom"), status: "not_connected", connectedAccountId: null, connectedAccountStatus: null, childConnectionId: null },
{ toolkit: toolkit("asana"), status: "not_connected", connectedAccountId: null, connectedAccountStatus: null, childConnectionId: null },
{ toolkit: toolkit("slack"), status: "pending", connectedAccountId: "c", connectedAccountStatus: "INITIALIZING", childConnectionId: null },
{ toolkit: toolkit("github"), status: "connected", connectedAccountId: "c", connectedAccountStatus: "ACTIVE", childConnectionId: "x" },
{ toolkit: toolkit("gmail"), status: "connected", connectedAccountId: "c", connectedAccountStatus: "EXPIRED", childConnectionId: "y" },
]));
expect(rows.map((row) => row.toolkitSlug)).toEqual(["gmail", "github", "slack", "asana", "zoom"]);
});
it("tolerates a missing payload and missing toolkit metadata", () => {
expect(composioServiceRows(undefined)).toEqual([]);
const [row] = composioServiceRows(listResponse([{
toolkit: { slug: "bare", name: "" },
status: "not_connected",
connectedAccountId: null,
connectedAccountStatus: null,
childConnectionId: null,
}]));
expect(row!.name).toBe("bare");
expect(row!.description).toBeNull();
expect(row!.logoUrl).toBeNull();
expect(row!.toolCount).toBeNull();
});
});
describe("composioServiceRowFromStatus", () => {
/**
* The poll endpoint answers a different shape than the list. Normalizing both
* to one row is what lets a pending row flip to connected in place.
*/
it("normalizes the poll payload into the same row the list produces", () => {
const row = composioServiceRowFromStatus({
toolkit: toolkit("github"),
account: { id: "ca-1", status: "ACTIVE" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
child: { id: "child-9" } as any,
});
expect(row.state).toBe("connected");
expect(row.childConnectionId).toBe("child-9");
expect(row.toolkitSlug).toBe("github");
});
it("reads no account as not connected", () => {
const row = composioServiceRowFromStatus({
toolkit: toolkit("gmail"),
account: null,
child: null,
});
expect(row.state).toBe("not_connected");
expect(row.childConnectionId).toBeNull();
});
});
describe("composioServiceIsSettling", () => {
it("keeps polling only while a row is pending", () => {
expect(composioServiceIsSettling("pending")).toBe(true);
expect(composioServiceIsSettling("connected")).toBe(false);
expect(composioServiceIsSettling("not_connected")).toBe(false);
// An unusable credential needs the user, not another poll.
expect(composioServiceIsSettling("attention")).toBe(false);
});
});
describe("connection provenance", () => {
const broker = { config: { sourceTemplateKey: "composio" } };
const child = {
config: { provider: "composio", parentConnectionId: "parent-1", toolkitSlug: "github" },
};
it("recognizes the broker connection", () => {
expect(isComposioBrokerConnection(broker)).toBe(true);
});
/** The two kinds must not overlap, or a child would grow its own Services tab. */
it("does not mistake a toolkit child for the broker", () => {
expect(isComposioBrokerConnection(child)).toBe(false);
expect(composioChildToolkitSlug(broker)).toBeNull();
});
it("ignores unrelated apps", () => {
expect(isComposioBrokerConnection({ config: { sourceTemplateKey: "github" } })).toBe(false);
expect(isComposioBrokerConnection({ config: {} })).toBe(false);
expect(isComposioBrokerConnection(null)).toBe(false);
expect(composioChildToolkitSlug({ config: { sourceTemplateKey: "github" } })).toBeNull();
expect(composioChildToolkitSlug(null)).toBeNull();
});
it("reads a child's toolkit and parent for the provenance chip", () => {
expect(composioChildToolkitSlug(child)).toBe("github");
expect(composioChildParentConnectionId(child)).toBe("parent-1");
});
it("reads a child with no parent link as unlinked rather than throwing", () => {
const orphan = { config: { provider: "composio", toolkitSlug: "github" } };
expect(composioChildToolkitSlug(orphan)).toBe("github");
expect(composioChildParentConnectionId(orphan)).toBeNull();
});
});
describe("Services tab visibility", () => {
/**
* Services needs both gates: a live connection to read an API key from, and a
* broker to have services at all.
*/
it("is gated on both a connection and a broker", () => {
expect(CONNECTED_ONLY_APP_TABS.has("services")).toBe(true);
expect(BROKER_ONLY_APP_TABS.has("services")).toBe(true);
});
it("does not gate the ordinary tabs behind broker-only", () => {
for (const tab of ["setup", "test", "review", "permissions", "activity"] as const) {
expect(BROKER_ONLY_APP_TABS.has(tab)).toBe(false);
}
});
});

View File

@ -0,0 +1,225 @@
import type { ToolConnection } from "@paperclipai/shared";
/**
* Reading Composio's service endpoints for the Services tab (PAP-17865).
*
* A Composio connection is a gateway to many services, not a service itself, so
* its detail page lists *toolkits* (GitHub, Gmail, ) and each row has its own
* connection state. The server (PAP-17866) answers two differently shaped
* payloads for the same row:
*
* - the list (`GET …/services`) already classifies each row, and
* - the poll (`GET …/services/:slug/status`) returns the raw Composio toolkit,
* connected account, and child connection, leaving the classification open.
*
* Rather than teach the panel both shapes, both are normalized to `ComposioServiceRow`
* here. That keeps the "what state is this row in" decision in one testable place
* instead of spread across render branches, and it means a later contract cleanup
* on the server only has to move these two adapters.
*/
/** Raw Composio toolkit as the server forwards it. Only the display fields are read. */
export interface ComposioToolkitPayload {
slug: string;
name: string;
no_auth?: boolean;
auth_schemes?: string[];
composio_managed_auth_schemes?: string[];
meta?: {
description?: string | null;
logo?: string | null;
tools_count?: number;
} | null;
}
/** `GET /tool-connections/:id/services` */
export interface ComposioServicesResponse {
parentConnectionId: string;
/** Composio `user_id` these accounts belong to (`paperclip:{companyId}`). Not a secret. */
userId: string;
services: Array<{
toolkit: ComposioToolkitPayload;
status: string;
connectedAccountId: string | null;
connectedAccountStatus: string | null;
childConnectionId: string | null;
}>;
}
/** `GET /tool-connections/:id/services/:toolkitSlug/status` — the poll target. */
export interface ComposioServiceStatusResponse {
toolkit: ComposioToolkitPayload;
account: { id: string; status: string; status_reason?: string | null } | null;
child: ToolConnection | null;
}
/** `POST /tool-connections/:id/services/:toolkitSlug/connect` — Composio-hosted link. */
export interface ComposioConnectLinkResponse {
toolkitSlug: string;
authConfigId: string;
redirect_url: string;
expires_at?: string | null;
connected_account_id?: string | null;
}
/** `DELETE /tool-connections/:id/services/:toolkitSlug` */
export interface ComposioDisconnectResponse {
toolkitSlug: string;
disconnectedAccountIds: string[];
removedChildIds: string[];
}
/**
* The three states the design asks for, plus `attention`.
*
* `attention` is not a fourth visual language it reuses the same row as
* `connected` with a warning tone. It exists because Composio can report an
* account that is neither absent nor usable (expired, revoked upstream), and
* collapsing that into `pending` would show a spinner that never resolves.
*/
export type ComposioServiceState = "not_connected" | "pending" | "connected" | "attention";
export interface ComposioServiceRow {
toolkitSlug: string;
name: string;
description: string | null;
logoUrl: string | null;
state: ComposioServiceState;
/** Raw Composio account status (`ACTIVE`, `INITIALIZING`, `EXPIRED`, …), for the detail line. */
connectedAccountStatus: string | null;
/** The Paperclip child connection for this toolkit, once one exists. */
childConnectionId: string | null;
/** Number of tools the toolkit exposes, when Composio reports it. */
toolCount: number | null;
/** True when Composio needs no credential for this toolkit. */
noAuth: boolean;
}
/** Composio account statuses that mean "there is a credential and it works". */
const ACTIVE_STATUS = "ACTIVE";
/**
* Statuses that mean a credential exists but cannot be used. Anything else
* unfinished (`INITIALIZING`, `INITIATED`, ) is treated as still in flight, so a
* status Composio adds later degrades to `pending` rather than to a wrong verdict.
*/
const FAILED_STATUSES = new Set(["EXPIRED", "INACTIVE", "DISABLED", "FAILED", "REVOKED", "ERROR"]);
export function composioServiceStateFor(accountStatus: string | null | undefined): ComposioServiceState {
const status = accountStatus?.trim().toUpperCase();
if (!status) return "not_connected";
if (status === ACTIVE_STATUS) return "connected";
if (FAILED_STATUSES.has(status)) return "attention";
return "pending";
}
function toolkitDisplay(toolkit: ComposioToolkitPayload) {
return {
toolkitSlug: toolkit.slug,
name: toolkit.name || toolkit.slug,
description: toolkit.meta?.description?.trim() || null,
logoUrl: toolkit.meta?.logo?.trim() || null,
toolCount: typeof toolkit.meta?.tools_count === "number" ? toolkit.meta.tools_count : null,
noAuth: toolkit.no_auth === true,
};
}
/** Normalizes the list payload. Trusts the account status, not the server's `status` string. */
export function composioServiceRows(response: ComposioServicesResponse | undefined): ComposioServiceRow[] {
return (response?.services ?? [])
.map((service) => ({
...toolkitDisplay(service.toolkit),
// `connectedAccountStatus` is the ground truth: the server's `status` field
// only distinguishes connected/pending/not_connected, so reading it would
// lose the difference between "still authorizing" and "expired".
state: composioServiceStateFor(service.connectedAccountStatus),
connectedAccountStatus: service.connectedAccountStatus,
childConnectionId: service.childConnectionId,
}))
.sort(compareServiceRows);
}
/** Normalizes the poll payload into the same row the list produced. */
export function composioServiceRowFromStatus(
response: ComposioServiceStatusResponse,
): ComposioServiceRow {
return {
...toolkitDisplay(response.toolkit),
state: composioServiceStateFor(response.account?.status),
connectedAccountStatus: response.account?.status ?? null,
childConnectionId: response.child?.id ?? null,
};
}
const STATE_ORDER: Record<ComposioServiceState, number> = {
attention: 0,
connected: 1,
pending: 2,
not_connected: 3,
};
/**
* Rows that need the reader's attention first, then connected services, then the
* long alphabetical tail of everything connectable. A Composio project can expose
* hundreds of toolkits, so the handful that are actually wired up must not be lost
* in it.
*/
function compareServiceRows(a: ComposioServiceRow, b: ComposioServiceRow): number {
const byState = STATE_ORDER[a.state] - STATE_ORDER[b.state];
return byState !== 0 ? byState : a.name.localeCompare(b.name);
}
/** True while a row should keep polling — the Connect Link is open and unresolved. */
export function composioServiceIsSettling(state: ComposioServiceState): boolean {
return state === "pending";
}
/**
* Telling the two kinds of Composio connection apart.
*
* The server (PAP-17866) stamps each kind with a different `config` marker, and
* they are mutually exclusive:
*
* - the broker carries the gallery's `sourceTemplateKey: "composio"`, and
* - a toolkit child carries `provider: "composio"` plus its parent and slug.
*
* Both predicates read those markers rather than the connection's name or
* transport, because a child is an ordinary `mcp_remote` connection and its name
* is user-renameable.
*/
function configOf(connection: { config?: Record<string, unknown> | null }): Record<string, unknown> {
return connection.config ?? {};
}
/** The parent: a Composio API-key connection whose Services tab lists toolkits. */
export function isComposioBrokerConnection(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): boolean {
if (!connection) return false;
const config = configOf(connection);
return config.sourceTemplateKey === "composio" && config.provider !== "composio";
}
/**
* The toolkit slug a child connection serves, or `null` if it is not a Composio
* child. Drives the "via Composio" provenance chip.
*/
export function composioChildToolkitSlug(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): string | null {
if (!connection) return null;
const config = configOf(connection);
if (config.provider !== "composio") return null;
const slug = config.toolkitSlug;
return typeof slug === "string" && slug.trim() ? slug.trim() : null;
}
/** The broker a child hangs off, for linking the chip back to the Services tab. */
export function composioChildParentConnectionId(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): string | null {
if (!connection) return null;
const config = configOf(connection);
if (config.provider !== "composio") return null;
const parentId = config.parentConnectionId;
return typeof parentId === "string" && parentId.trim() ? parentId.trim() : null;
}