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