feat(connections): deploy and configure Supabase and Vercel (#17217)

Add governed Supabase and Vercel deployment/configuration connectors with vaulted credentials, agent access policies, approvals, audit, and bounded provider operations.

Deliver reviewed REST actions to native agents and guard Vercel preview intent on empty projects. Preserve secret-safe request identity and durable retry behavior.

Verified by current-head CI, fresh Greptile 5/5 review, and real-agent deployment/configuration walkthroughs on disposable provider resources. Test resources and temporary provider tokens were removed after verification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-23 11:37:29 -07:00
1 parent 59d1b7b43d
commit 9c442cf48e
45 files changed
+3604 -180

No files matched your search

+5 -3
View File
@@ -156,9 +156,11 @@ docker compose -f docker/docker-compose.yml up --build
PostgreSQL data persists in a named Docker volume (`pgdata`). Paperclip data persists in `paperclip-data`.
Both Compose files pass `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to the server.
Set it to enable signed tool-action approvals, and keep it independent from
`BETTER_AUTH_SECRET`. Leaving it unset preserves startup without approval-gated
tool actions; those calls report `signing_secret_unconfigured`.
When configured, keep it independent from `BETTER_AUTH_SECRET`. When unset,
the server derives a separate signing key from its persisted secrets master key.
Keep that key with database backups, and share the signing key or master key
across processes serving the same instance. Approvals fail closed if valid key
material cannot be loaded; rotating it can invalidate approvals and retry matching.
### Untrusted PR review
+1 -1
View File
@@ -4,7 +4,7 @@ This runbook covers Paperclip Tools & Access runtime slots for MCP connections.
Do not print raw bearer tokens, gateway session tokens, credential headers, environment variables, or secret values while following this runbook. The APIs below return redacted state and audit metadata; keep shell tracing disabled when exporting credentials.
Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. `paperclipai onboard` generates it for local instances, and worktree setup propagates or generates an independent value in the worktree `.env`; operator-managed deployments must set it explicitly. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation.
Tool action approvals and sensitive request identities use a durable key independent from auth/JWT secrets. `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` takes precedence when configured. `paperclipai onboard` generates it for local instances, and worktree setup propagates or generates an independent value in the worktree `.env`. When unset, the server derives a separate key from the persisted secrets master key, using its configured environment value or durable key file. Processes serving the same instance must share that signing key or master key, and operators must back it up with the database. Rotation can invalidate outstanding approvals and retry matching; drain or reject pending approvals and reconcile completed writes before rotating.
## Support Matrix
+3 -2
View File
@@ -14,8 +14,9 @@ Long-term memory: [Experimental memory connectors](./MEMORY.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Braintrust](./BRAINTRUST.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [Ditto](./DITTO.md),
[Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
[Enterpret](./ENTERPRET.md). Optional credential custody:
[Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md),
[Enterpret](./ENTERPRET.md), [Supabase](./SUPABASE.md), and
[Vercel](./vercel.md). Optional credential custody:
[Vercel Connect](./VERCEL-CONNECT.md).
Post-read action: classify a new integration request, pick the right Paperclip
+254
View File
@@ -0,0 +1,254 @@
# Supabase
Reviewed 2026-10-22. Paperclip agents can deploy Supabase Edge Functions and
configure application Auth and the Data API through a governed native Management
API connection. Hosted MCP supplies project creation, database migrations and
other provider tools. These are distinct connection methods and credentials are
never forwarded from MCP OAuth into the Management API.
Neither Paperclip ID nor Paperclip Connect participates. OAuth discovery,
registration, credentials and callbacks are instance-local. Cloud and self-hosted
instances use the same path and the normal connection vault, grants, policies,
run gateway and audit.
## Methods and capability boundary
| Method | Authentication | Capabilities | Scope |
| --- | --- | --- | --- |
| `mcp-oauth` | Supabase browser consent and DCR | Provider MCP project/account, SQL/migration, Edge Function, branching and optional storage tools | Consented organization, optionally pinned project |
| `mcp-api-key` | Supabase personal access token | Same hosted MCP tool surface, limited by token permissions | Token authorization, optionally pinned project |
| `management-api` | Supabase personal access token | Eleven reviewed project/configuration/function actions below | Required saved project reference |
For MCP, **Pin to project reference** is optional under Advanced. An unpinned
connection can create projects. Supplying `project_ref` disables account tools,
including project creation. The read-only option stays under Advanced and
defaults off. The optional feature-group list accepts `docs`, `account`,
`database`, `debugging`, `development`, `functions`, `branching`, and `storage`.
Storage requires explicit selection. Existing saved project pins are preserved.
See the [official MCP configuration and tool list](https://supabase.com/docs/guides/ai-tools/mcp).
Native actions use these fixed paths under `https://api.supabase.com/v1`:
| Action | Request | Risk |
| --- | --- | --- |
| `supabase_get_project` | `GET /projects/{savedRef}` | Read |
| `supabase_get_auth_config` | `GET /projects/{savedRef}/config/auth` | Read |
| `supabase_update_auth_config` | `PATCH /projects/{savedRef}/config/auth` | Write |
| `supabase_get_api_config` | `GET /projects/{savedRef}/postgrest` | Read |
| `supabase_update_api_config` | `PATCH /projects/{savedRef}/postgrest` | Write |
| `supabase_list_edge_functions` | `GET /projects/{savedRef}/functions` | Read |
| `supabase_get_edge_function` | `GET /projects/{savedRef}/functions/{slug}` | Read |
| `supabase_deploy_edge_function` | `POST /projects/{savedRef}/functions/deploy?slug={slug}` | Write |
| `supabase_delete_edge_function` | `DELETE /projects/{savedRef}/functions/{slug}` | Destructive |
| `supabase_list_edge_function_secrets` | `GET /projects/{savedRef}/secrets` | Read |
| `supabase_upsert_edge_function_secret` | `POST /projects/{savedRef}/secrets` | Write |
Auth configuration includes `site_url`, `uri_allow_list`, `disable_signup`,
`external_email_enabled`, `mailer_autoconfirm`, `jwt_exp`, and
`password_min_length`. Data API configuration includes `db_schema`,
`db_extra_search_path`, `max_rows`, `db_pool`, and
`db_pool_acquisition_timeout`. Unknown fields and empty updates are rejected.
Reads and write responses expose only these non-secret fields. In particular,
Auth provider client secrets, SMTP passwords and PostgREST signing secrets are
not returned. See the official [Auth configuration](https://supabase.com/docs/reference/api/v1-update-auth-service-config)
and [Data API configuration](https://supabase.com/docs/reference/api/v1-update-postgrest-service-config)
contracts.
Deployment sends UTF-8 source files as multipart `file` parts with a `metadata`
part, matching the [deployment endpoint](https://supabase.com/docs/reference/api/v1-deploy-a-function)
and [provider deployment example](https://supabase.com/changelog/33720-deploy-and-update-edge-functions-using-the-management-api).
The supplied entrypoint and optional import map must exist among unique relative
file names. JWT verification defaults on; callers can explicitly disable it for
an application that handles authentication itself. The request is bounded to
2 MiB and 50 files. The connector never reads server filesystem paths, runs a
shell, or executes source code in Paperclip.
Native REST lists Edge Function secret names and update times and accepts
`{name, secretValue}` to create or replace one environment secret. Secret values
are never read back. Gateway argument summaries redact `secretValue`, and the
provider response is discarded on writes. Each value is bounded to 64 KiB.
See [secret writes](https://supabase.com/docs/reference/api/v1-bulk-create-secrets)
and [secret inventory](https://supabase.com/docs/reference/api/v1-list-all-secrets).
Use the Dashboard when a human needs private entry; this action accepts a value
the agent already has authority to use.
Native REST does not expose raw SQL, project creation, database credentials,
SMTP passwords or OAuth client secrets. Use MCP for project creation and
migrations. MCP Edge Function secret entry depends on provider and
client URL elicitation support; this connector change does not claim that flow
is qualified. Branching and other paid provider features retain Supabase's
entitlements and cost handling.
## Connection flow
```mermaid
sequenceDiagram
participant User
participant Paperclip
participant Vault
participant Supabase
User->>Paperclip: Select Supabase method and agent access
alt Hosted MCP browser sign-in
Paperclip->>Supabase: Read protected resource and issuer metadata
Paperclip->>Supabase: Register instance OAuth client
Paperclip->>User: Open authorization URL
User->>Supabase: Choose organization and consent
Supabase->>Paperclip: Instance callback with authorization code
Paperclip->>Supabase: Exchange code with PKCE
else Personal access token
User->>Paperclip: Enter token and optional or required project reference
end
Paperclip->>Vault: Store selected grant credential
Paperclip->>Supabase: MCP discovery or native project probe
User->>Paperclip: Set action permissions
Paperclip->>Paperclip: Authorize agent run and selected grant
Paperclip->>Vault: Resolve selected invocation credential
Paperclip->>Supabase: Invoke permitted action
Paperclip->>Paperclip: Record bounded result and audit
```
The editable diagram is also in [supabase-flow.mmd](./supabase-flow.mmd).
Public metadata observed on 2026-10-22 advertised:
- MCP: `https://mcp.supabase.com/mcp`
- Protected resource: `https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp`
- Issuer metadata: `https://api.supabase.com/.well-known/oauth-authorization-server`
- Authorization: `https://api.supabase.com/v1/oauth/authorize`
- Token: `https://api.supabase.com/v1/oauth/token`
- DCR: `https://api.supabase.com/platform/oauth/apps/register`
- Paperclip callback: `<instance-origin>/api/tools/oauth/callback`
The existing thirteen reviewed MCP scopes match the public metadata. Native PAT
permissions are separate: `project_admin_read`, `edge_functions_read`,
`edge_functions_write`, `auth_config_read`, `auth_config_write`,
`project_admin_write` for Auth changes, `data_api_config_read`, and
`data_api_config_write`, plus `edge_functions_secrets_read/write` for function
secrets. Equivalent endpoint OAuth scopes are `projects:read`,
`edge_functions:read/write`, `secrets:read/write`, `auth:read/write`, and `rest:read/write`, but the
native method uses a PAT. Permissions remain authoritative at Supabase. See
[project access](https://supabase.com/docs/reference/api/v1-get-project),
[function access](https://supabase.com/docs/reference/api/v1-list-all-functions),
[Auth access](https://supabase.com/docs/reference/api/v1-get-auth-service-config),
and [Data API access](https://supabase.com/docs/reference/api/v1-get-postgrest-service-config).
## Administrator setup and verification
1. Open Apps → Supabase, choose the connection identity and agents, then choose
the method. Browser sign-in requires no manually registered OAuth app.
2. For token methods, create a scoped PAT in
[Supabase access tokens](https://supabase.com/dashboard/account/tokens) with
permissions for the intended actions. A project publishable, anon,
`service_role`, or secret API key cannot replace this PAT.
For all native actions, scope it to the selected project and grant Read-write
for **Project Settings**, **Auth Config**, **Data API Config**, **Edge Functions**,
and **Edge Function Secrets**. Auth updates need both Project Settings and
Auth Config. Data API JWT Secret access is unnecessary for the non-secret
configuration fields. Use a short expiry and revoke the token after temporary
verification. See the [scoped PAT permission guide](https://supabase.com/docs/guides/platform/personal-access-tokens).
3. For native deployment/configuration, copy the existing project's reference
from Project Settings. The saved reference is the execution boundary and
cannot be overridden by tool arguments. For MCP project creation, leave the
Advanced project pin empty and retain the account feature group.
4. Complete consent or token setup. For browser consent, honor provider delays
and real pointer/keyboard interaction before diagnosing an OAuth problem.
A disabled consent button alone is not evidence of callback failure.
5. On Permissions, inspect discovered actions and set Allowed, Ask first or Off.
Test project/function inspection, then deploy a disposable function in a
development project, read its version, and delete it. To verify Auth changes,
preserve the prior site URL and restore it after a test change.
6. Verify an agent read/write through its run gateway, denial under an Off
policy, catalog refresh/reconnect, and removal. Confirm the token is absent
from responses and audit. Provider errors are mapped to fixed messages;
401/403 require checking token permissions, 402 requires account/billing
action, and 429 requires waiting. Writes are never automatically retried
after an uncertain transport result.
## Evidence and remaining qualification
The provider fixture suite is
`server/src/services/connectors/supabase/index.test.ts`. It covers multipart
deployment, configuration, credential/header projection, project containment,
malformed paths/files, schema and risk drift, deletion, secret input summary and output redaction,
provider failures, bounded reads and uncertain-write behavior. Shared connection
tests cover the vault and governed run gateway. Fixtures do not establish
provider account authorization.
Public metadata preflight passed on 2026-10-22. Live source verification then used
an isolated `worktree init --empty` instance on macOS arm64, Node 26.4.0, with
synthetic company `DEP` at `http://127.0.0.1:4391`. The normal CLI started the
instance with its own embedded PostgreSQL. Its supported worktree run setting
was explicitly enabled for the proof; no default or production database was used.
The Apps UI showed all three methods, optional Advanced project pinning for MCP,
and the required project pin for native REST. Real browser OAuth consent completed
and discovery returned 29 MCP actions (18 read and 11 write). A real process-agent
run through the governed gateway created one disposable project after the provider
quoted $0/month. The project reached `ACTIVE_HEALTHY`; successful run ID:
`560aa4eb-237d-4b52-8d44-d23d9aab3f57`.
The first live harness run misread the provider's wrapped organization result;
normalizing that result resolved the harness failure. A duplicate queued wake
later attempted the same project creation and was rejected by the provider. The
harness gained a one-shot guard, and the Dashboard confirmed exactly one new
proof project. These earlier failures remain part of the verification evidence.
Native setup then vaulted a 24-hour scoped PAT limited to that project and the
five documented permission groups. Discovery exposed eleven actions. Real
process-agent run `9eede77a-2360-4710-b473-4688242062e3` changed and read back Auth
and Data API settings, wrote an environment secret without returning its value,
deployed an Edge Function, and read its `ACTIVE` status. Its public HTTP response
was 200 with the expected hello marker and `secretConfigured: true`.
That run remained failed because its configuration cleanup received a provider
server error and an immediate stale read. Subsequent read-only connector calls
confirmed the original `site_url` and `max_rows` were restored, without another
write. The connector now marks provider server errors after writes as uncertain
outcomes and tells callers to inspect state before retrying. The regression test
also covers an applied configuration change followed by a server error; the
provider suite passes all 30 tests. A subsequent actual process-agent cleanup run,
`ba60f6a7-bade-42d6-9a82-1cc12144589b`, succeeded: it confirmed both original
settings, deleted the function, and verified its absence from the function list.
The original failed run and successful reconciliation are retained separately.
The operator deleted the disposable project in the Dashboard, removing its
database and proof secret, and confirmed its absence from the organization list.
The temporary scoped PAT was deleted; the provider showed no remaining PATs.
Both local connections and the unconnected setup draft were removed. Fresh
process agents received no tools and prior tool names returned 404 for both
connections. A final MCP list confirmed the proof project was absent. Forced refresh and reconnect with
fresh provider credentials were not live-tested. No paid upgrade or existing
production resource was created or modified.
A subsequent browser-only walkthrough on 2026-10-23 used a real native Codex
agent rather than the process gateway harness. It exposed a delivery gap:
connection readiness, native assignments, and runtime preparation accepted only
their existing transports and omitted reviewed REST connections. The saved
credential passed a project-read test as the same agent, but the task could not
discover its actions and rejected the offered identity as conflicting. The
three filters now recognize the reviewed REST registry while retaining existing
installation, identity, health, and action-policy checks. Four database-backed
regressions failed before the fix; all 33 deployment lifecycle cases passed after.
After a separate source repair and isolated-instance restart, the same Codex
task deployed a JWT-protected function, set a synthetic environment marker,
changed the Auth site URL and Data API row limit, and read all four back. The
Dashboard independently showed JWT verification enabled and the saved Auth URL.
Its built-in function tester returned HTTP 200 with the exact synthetic marker.
The earlier failed browser runs remain separate evidence; this retry does not
retroactively qualify their missing agent path.
Sensitive tool arguments such as `secretValue` are encrypted in newly created
approval payloads with the instance tool-action signing secret. Approval summaries
and audit still use redacted values. Existing signed approval records remain
readable and are not rewritten. Sensitive request identities also use this key
to distinguish secret replacements without storing guessable secret hashes.
The configured `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` takes precedence. When unset,
Paperclip derives a separate signing key from its persisted secrets master key,
using the configured master-key environment value or durable key file. Key rotation
can invalidate retry matching; back up the configured signing key or master key
with the database, and share that key across processes serving the same instance.
New default retry keys are versioned and actor-scoped. Older completed requests
replay only when their arguments can be verified exactly. An unverifiable legacy
request returns HTTP 409; inspect its previous effect before intentionally using
a new explicit idempotency key.
+23
View File
@@ -0,0 +1,23 @@
sequenceDiagram
participant User
participant Paperclip
participant Vault
participant Supabase
User->>Paperclip: Select Supabase method and agent access
alt Hosted MCP browser sign-in
Paperclip->>Supabase: Read protected resource and issuer metadata
Paperclip->>Supabase: Register instance OAuth client
Paperclip->>User: Open authorization URL
User->>Supabase: Choose organization and consent
Supabase->>Paperclip: Instance callback with authorization code
Paperclip->>Supabase: Exchange code with PKCE
else Personal access token
User->>Paperclip: Enter token and optional or required project reference
end
Paperclip->>Vault: Store selected grant credential
Paperclip->>Supabase: MCP discovery or native project probe
User->>Paperclip: Set action permissions
Paperclip->>Paperclip: Authorize agent run and selected grant
Paperclip->>Vault: Resolve selected invocation credential
Paperclip->>Supabase: Invoke permitted action
Paperclip->>Paperclip: Record bounded result and audit
@@ -2162,13 +2162,13 @@
"storage:write"
],
"capability": "write",
"supportedActions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative.",
"supportedActions": "Unpinned connections can list/create/pause/restore projects and use account cost tools. Hosted MCP supports SQL/migrations, Edge Function deployment, branches and opt-in storage configuration. A project_ref disables account tools. Read-only mode and token permissions remain authoritative. Auth and Data API configuration use the separate Management API method.",
"evidence": [
"https://supabase.com/docs/guides/ai-tools/mcp",
"https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp"
],
"reviewedAt": "2026-09-30",
"liveProof": "not-run"
"reviewedAt": "2026-10-22",
"liveProof": "2026-10-22: Real browser OAuth in an isolated Paperclip instance discovered 29 MCP tools. An actual process-agent gateway run listed organizations, obtained a zero-monthly-cost quote and confirmation, and created one disposable project that reached ACTIVE_HEALTHY. A read-only reconciliation found exactly one proof project. Dashboard cleanup deleted that project. Earlier harness parsing/duplicate-wake failures are documented in SUPABASE.md. MCP token authentication and all SQL/branch/storage capabilities were not live-tested."
},
{
"app": "supabase",
@@ -2177,14 +2177,14 @@
"policy": "provider-key",
"requestedScopes": [],
"capability": "provider-controlled",
"supportedActions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative.",
"supportedActions": "Unpinned connections can list/create/pause/restore projects and use account cost tools. Hosted MCP supports SQL/migrations, Edge Function deployment, branches and opt-in storage configuration. A project_ref disables account tools. Read-only mode and token permissions remain authoritative. Auth and Data API configuration use the separate Management API method.",
"evidence": [
"https://supabase.com/docs/guides/ai-tools/mcp",
"https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp"
],
"reviewedAt": "2026-09-30",
"reviewedAt": "2026-10-22",
"liveProof": "not-run",
"keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions."
"keyPermissions": "Use a scoped personal access token (sbp_...) with permissions for the enabled MCP tools and selected projects. Project publishable, anon, service_role and secret API keys are not supported. Project creation needs account access; Paperclip cannot increase an issued token\u2019s permissions."
},
{
"app": "superagent",
@@ -2288,23 +2288,6 @@
"reviewedAt": "2026-09-30",
"liveProof": "not-run"
},
{
"app": "vercel",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "explicit",
"requestedScopes": [
"openid"
],
"capability": "provider-controlled",
"supportedActions": "Account/team-authorized project and deployment actions; identity scope is not an admin permission.",
"evidence": [
"https://mcp.vercel.com/.well-known/oauth-protected-resource"
],
"reviewedAt": "2026-09-30",
"liveProof": "not-run",
"evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding."
},
{
"app": "webflow",
"method": "mcp-oauth",
@@ -2841,7 +2824,7 @@
"reviewedAt": "2026-10-21",
"liveProof": "not-run",
"evidenceLimit": "Public provider documentation and OAuth metadata only. Account-bound connect, discovery, read/write execution, run-scoped gateway, refresh/reconnect, removal, and secret handling remain to be qualified for this method.",
"keyPermissions": "Create a Braintrust API key in Settings → API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
"keyPermissions": "Create a Braintrust API key in Settings \u2192 API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
},
{
"app": "braintrust",
@@ -2878,7 +2861,7 @@
"reviewedAt": "2026-10-21",
"liveProof": "not-run",
"evidenceLimit": "Public provider documentation and OAuth metadata only. Account-bound connect, discovery, read/write execution, run-scoped gateway, refresh/reconnect, removal, and secret handling remain to be qualified for this method.",
"keyPermissions": "Create a Braintrust API key in Settings → API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
"keyPermissions": "Create a Braintrust API key in Settings \u2192 API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
},
{
"app": "unikraft",
@@ -2897,6 +2880,49 @@
"reviewedAt": "2026-10-15",
"liveProof": "not-run",
"keyPermissions": "Create an API token in the Unikraft Cloud console. A token acts for its whole organization in every metro, including running sandbox commands and deleting resources. Unikraft does not offer narrower token permissions, so choose trusted agents and use action permissions to limit what they can do."
},
{
"app": "supabase",
"method": "management-api",
"auth": "api_key",
"policy": "provider-key",
"requestedScopes": [],
"capability": "write",
"supportedActions": "Project-scoped project metadata, Edge Function list/get/deploy/delete, Edge Function secret name inventory and write-only secret upsert, selected non-secret Auth configuration and Data API configuration. No project creation, raw SQL, secret-value export, database credentials, OAuth client secrets or SMTP password actions. All requests use the saved project reference.",
"evidence": [
"https://supabase.com/docs/reference/api/v1-get-project",
"https://supabase.com/docs/reference/api/v1-deploy-a-function",
"https://supabase.com/docs/reference/api/v1-list-all-functions",
"https://supabase.com/docs/reference/api/v1-get-auth-service-config",
"https://supabase.com/docs/reference/api/v1-update-auth-service-config",
"https://supabase.com/docs/reference/api/v1-get-postgrest-service-config",
"https://supabase.com/docs/reference/api/v1-update-postgrest-service-config",
"https://supabase.com/docs/reference/api/v1-bulk-create-secrets",
"https://supabase.com/docs/reference/api/v1-list-all-secrets",
"https://supabase.com/docs/guides/platform/personal-access-tokens"
],
"reviewedAt": "2026-10-22",
"liveProof": "2026-10-22: Approved 24-hour project-scoped PAT connected through the real Apps UI and discovered 11 native actions. Actual process-agent gateway calls changed and read back Auth and Data API settings, wrote a synthetic function secret with redacted responses, deployed an ACTIVE Edge Function, and received HTTP200 with the expected marker and secretConfigured:true. A failed cleanup response was preserved; read-only reconciliation confirmed restored original settings, then a successful cleanup run deleted the function and confirmed absence. Dashboard project deletion removed its secret; the temporary PAT and local connections were removed. Forced refresh/reconnect were not live-tested.",
"keyPermissions": "Create a scoped Supabase personal access token for the selected project. Set Project Settings, Auth Config, Data API Config, Edge Functions, and Edge Function Secrets to Read-write. Leave API Key Secrets, Auth Signing Keys, and Data API JWT Secret at None. Use a short expiration. Project API keys are not supported; Paperclip cannot increase an issued token\u2019s permissions."
},
{
"app": "vercel",
"method": "rest-api-key",
"auth": "api_key",
"policy": "provider-key",
"requestedScopes": [],
"capability": "write",
"supportedActions": "Create and deploy projects from inline files, GitHub repositories or existing deployments; inspect status; configure build settings, environment variables and owned domains; governed project/deployment/environment/domain cleanup.",
"keyPermissions": "Create a Vercel access token scoped to the configured team. The token owner role must allow project/deployment reads and requested writes, environment variables and domain management; deletion requires matching provider rights. Paperclip cannot increase existing token permissions.",
"evidence": [
"https://vercel.com/docs/rest-api",
"https://vercel.com/docs/rest-api/deployments/create-a-new-deployment",
"https://vercel.com/docs/rest-api/projects/create-one-or-more-environment-variables",
"https://openapi.vercel.sh"
],
"reviewedAt": "2026-10-22",
"liveProof": "2026-10-22: Approved one-hour team token connected in an isolated Paperclip instance with 17 actions. Actual process-agent gateway runs listed and created disposable projects, wrote a synthetic preview environment secret, read metadata without its value, and deployed a preview that reached READY. The signed-in browser displayed the exact deployed marker; anonymous access retained the existing protection redirect. Gateway cleanup deleted both disposable projects and confirmed absence. The temporary provider token and local connection were removed; a fresh agent saw no tools and an old-name call returned404. Production, GitHub-source deployments, domains, forced refresh, and all account plans were not live-tested.",
"evidenceLimit": "Official documentation/OpenAPI plus local fixtures. No authenticated account-bound deployment/configuration/revocation proof; no disposable project credential supplied."
}
]
}
+212
View File
@@ -0,0 +1,212 @@
# Vercel deployment connection
Reviewed 2026-10-22 against Vercel's [REST reference](https://vercel.com/docs/rest-api)
and [official OpenAPI contract](https://openapi.vercel.sh).
The Vercel app gives agents governed deployment and project configuration tools
through a team-scoped access token. This is a Vercel resource connection; the
separate [Vercel Connect](VERCEL-CONNECT.md) feature brokers credentials for other
providers and does not supply this connection's token.
## Transport and authentication
| Axis | Selection |
| --- | --- |
| Method | `rest-api-key` |
| Transport | Reviewed `rest_api` adapter, delivered by the normal run-scoped tool gateway |
| API origin | `https://api.vercel.com` with fixed versioned routes |
| Credential | Vercel access token, sent only as `Authorization: Bearer …` |
| Ownership | Customer credential in the Paperclip vault; personal grants keep user-owned secret references |
| Resource scope | Required `teamId`; targeted project actions verify the returned project's `accountId`, and deployment actions verify `projectId` |
| Permission | Provider team/token role plus Paperclip agent access and individual action policy |
Vercel's token team selection, expiration and the account's permissions determine
provider access. Paperclip cannot expand an issued token. See
[REST authentication](https://vercel.com/docs/rest-api#authentication).
The key must support project reads and the desired deployment/configuration writes.
No OAuth scopes are requested for this method. Reconnect with a fresh token when
it expires, is revoked, or lacks access.
## Why use the REST adapter
Vercel's official [MCP reference](https://vercel.com/docs/agent-resources/vercel-mcp/tools)
now includes deployment and configuration tools, but explicitly says availability
and approval can differ by client. Paperclip's earlier MCP entry was unavailable
pending client approval. Public metadata demonstrates OAuth protocol support; it
does not prove Paperclip client acceptance or account-bound writes. The reviewed
REST adapter provides a self-serve token path without assuming MCP client approval.
The historical MCP method is not advertised as a second setup choice.
Public preflight on 2026-10-22, without registering a client or signing in:
| Observation | Result |
| --- | --- |
| Endpoint | `https://mcp.vercel.com` |
| Unauthenticated GET | HTTP 401; Bearer `invalid_token` challenge |
| Protected resource metadata | `https://mcp.vercel.com/.well-known/oauth-protected-resource` |
| Resource | `https://mcp.vercel.com/` |
| Authorization server metadata | `https://mcp.vercel.com/.well-known/oauth-authorization-server` |
| Issuer / authorization | `https://vercel.com` / `https://vercel.com/oauth/authorize` |
| Token / revocation | `https://vercel.com/api/login/oauth/token` / `https://vercel.com/api/login/oauth/token/revoke` |
| Registration | `https://vercel.com/api/login/oauth/register` advertised; not called |
| PKCE / token authentication | S256 / `none` |
| Protected-resource scopes | `openid` |
| Authorization-server advertised scopes | `openid`, `email`, `offline_access`, `profile`; not automatically requested |
These observations are public protocol evidence only. MCP transport negotiation,
client approval, redirects, consent, token lifetime and account entitlements were
not qualified by this preflight.
## Connection flow
1. In Apps, choose **Vercel → Connect Vercel**.
2. Enter an access token from [Vercel account tokens](https://vercel.com/account/tokens).
3. Enter the Team ID from Vercel Team Settings → General. The token must have
access to this team. Agents cannot override the team in tool arguments.
4. Finish setup. Discovery performs a safe team read before publishing the
reviewed catalog. A failed credential or team read fails setup visibly.
5. Use the regular Permissions page to select agents and allowed actions. Existing
action restrictions remain authoritative on refresh or reconnect.
The existing connection setup, credential writer, grant resolver, policies,
approval flow and audit own this lifecycle. The provider adapter receives resolved
headers and makes bounded requests; it never reads local CLI credentials or
exports a provider token to an agent.
## Administrator setup
No Vercel OAuth application registration is needed for the access-token method.
Use an account and team where deployments are authorized and select that team
when creating the token. Vercel plan limits, role restrictions, repository access
and usage billing still apply. GitHub deployments need the repository available
to Vercel; this connector does not install or authorize Vercel's GitHub integration.
To revoke, delete the token in Vercel account settings and remove the connection
in Paperclip. A retained Paperclip grant cannot make a revoked token usable.
Replacing the token through reconnect preserves connection identity and explicit
action policies. The normal removal path revokes access and cleans credential refs.
## Reviewed actions
All names below have the `vercel_` prefix.
| Capability | Actions | Risk |
| --- | --- | --- |
| Project inventory | `list_projects`, `get_project` | Read |
| Project build configuration | `create_project`, `update_project` | Write |
| Deployment inventory/status | `list_deployments`, `get_deployment` | Read |
| Deploy | `create_deployment` | Write |
| Environment metadata | `list_environment_variables` | Read |
| Set environment variables | `upsert_environment_variable` | Write |
| Domain inventory | `list_domains` | Read |
| Attach/verify owned domains | `add_domain`, `verify_domain` | Write |
| Cleanup and interruption | `delete_project`, `delete_deployment`, `cancel_deployment`, `delete_environment_variable`, `remove_domain` | Destructive |
Unknown tool names fail closed. Changed catalog schemas or risks require refresh
and review through the shared catalog contract. The adapter does not expose a
generic HTTP operation, token management, billing purchases, DNS administration,
domain registration or project transfers.
For a deployment, first select or create a project, optionally set environment
variables and build settings, then call `create_deployment` with exactly one of:
- Inline `files: [{file, content, encoding?}]`. Paths must be unique and relative;
aggregate request size is limited to 4 MiB. No server filesystem path is read.
- `gitSource: {type: "github", repoId, ref}` for larger repositories.
- `deploymentId` to redeploy an existing deployment in that same project.
Preview is the default target; `target: "production"` must be explicit. Vercel
[makes a project's first deployment production](https://vercel.com/docs/deployments/environments#first-deployment),
even when preview is requested. Before a preview, Paperclip checks that the
project has at least one existing deployment. Failed and canceled deployments
count. An empty project is rejected before submission with
`vercel_preview_requires_existing_deployment`; initialize it with an explicitly
authorized production deployment before requesting preview.
The check cannot prevent an external actor from deleting every deployment
between inspection and submission. If a preview response has a production or
unrecognized target, the adapter preserves its deployment ID in a
`reconciliationRequired` receipt with `vercel_deployment_target_mismatch`.
Inspect that deployment before any retry. Paperclip does not automatically retry,
delete the result, or change deployment protection.
The adapter submits one deployment request and returns its ID, URL and state. A
`QUEUED` or `BUILDING` response is not success evidence: inspect with
`get_deployment` until a terminal state is observed. Provider mutations are never
retried automatically after a timeout or ambiguous transport failure.
Configuration includes framework, build/install/dev commands, root/output
directories and Node version. Deployment settings fall back to the selected
project's current settings, including for a first deployment. Other provider
settings remain available in Vercel's dashboard.
Environment writes use `secretValue`, a redacted audit field, and explicit
`target` values (`preview`, `production`, `development`). They default to encrypted
provider storage. `sensitive` supports preview/production only. Metadata reads set
`decrypt=false`; responses omit values, ciphertext and internal credential hints.
Vercel's HTTP-200 response can contain failed writes; those are surfaced as failure.
Changed environment variables apply to subsequent deployments.
## Validation and remaining proof
Acceptance criteria: actionable setup; vaulted token; 17 reviewed tools; safe
team/project reads; a policy-governed preview deployment and configuration write;
cross-company, foreign-team and foreign-project rejection; reconnect and removal;
and no token or environment value in responses/audit.
Provider unit coverage lives in
`server/src/services/connectors/vercel/index.test.ts`. It covers fixed routes,
strict schemas, ownership checks before writes, preview/production payloads,
GitHub deployment, secret projection, environment partial failure, catalogue
drift, and cancellation of a stalled prerequisite without a late mutation.
The database-backed lifecycle suite is
`server/src/__tests__/deployment-connections.test.ts`.
Account-bound live proof passed on 2026-10-22 with an approved, one-hour token
and disposable projects in an isolated local Paperclip instance. Actual process
agent runs used the run-scoped gateway to list projects, create a project, write
a synthetic preview environment secret, read back metadata without its value,
and create a preview deployment that reached `READY`. The signed-in browser
loaded the deployed page and showed the exact synthetic marker. Anonymous access
returned 302 under the team's existing deployment protection, which was retained.
Gateway cleanup deleted both disposable projects and confirmed their absence.
The temporary provider token and local connection were removed. A fresh process
agent received no Vercel tools, and calling the prior tool name returned 404.
This proves the token method's preview deployment and environment configuration
path for this account. Production publishing, GitHub-source deployment, custom
domains, forced credential refresh, and all provider plan combinations were not
live-tested. Their fixture coverage does not establish account entitlement.
Keep receipts free of credentials, environment values, and raw provider errors.
A browser-only retest on 2026-10-23 used a real native Codex agent. Initial
connection discovery and task-card reuse failed despite correct saved access.
Readiness, native assignment, and runtime preparation now admit reviewed REST
connections through the existing registry and governance checks. Four new
database-backed cases reproduced the failure; all 33 lifecycle cases passed
after the fix. The real agent then created a disposable project, set an encrypted
preview environment marker, and read its metadata and deployment settings back.
The signed-in browser loaded the READY preview and displayed the expected marker.
The first deployment in that run became production despite its preview request.
The agent retained that result for review and made one subsequent preview, which
was independently loaded in the browser. This observed failure prompted the
first-deployment guard above. Keep the failed run and corrective preview separate;
successful gateway fixtures alone did not reveal the provider's first-deployment
exception or prove native Codex tool delivery.
Sensitive tool arguments such as `secretValue` are encrypted in newly created
approval payloads with the instance tool-action signing secret. Approval summaries
and audit still use redacted values. Existing signed approval records remain
readable and are not rewritten. Sensitive request identities also use this key
to distinguish secret replacements without storing guessable secret hashes.
The configured `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` takes precedence. When unset,
Paperclip derives a separate signing key from its persisted secrets master key,
using the configured master-key environment value or durable key file. Key rotation
can invalidate retry matching; back up the configured signing key or master key
with the database, and share that key across processes serving the same instance.
New default retry keys are versioned and actor-scoped. Older completed requests
replay only when their arguments can be verified exactly. An unverifiable legacy
request returns HTTP 409; inspect its previous effect before intentionally using
a new explicit idempotency key.
+24
View File
@@ -284,3 +284,27 @@ remaining operations before qualifying the whole capability.
Gaps: Error reporting and human repair exist; no generic agent permission to reconnect
accounts.
Supabase and Vercel deployment methods (2026-10-22) reuse human-authorized setup
and assigned run-scoped tools. Native Supabase calls use the saved project;
native Vercel calls use the saved team and validate project ownership. Tokens
remain in the selected personal/company vault grant and cannot be exported by
`mintConnectionTokenForAgent`. Action policies, changed-schema quarantine,
responsible-user access and immediate removal apply to deployment/configuration
calls. Database/provider-fixture evidence lives in
[deployment connector governance](../../server/src/__tests__/deployment-connections.test.ts).
It does not establish authenticated provider deployment, OAuth consent or remote
revocation. See [Supabase](../../doc/connections/SUPABASE.md) and
[Vercel](../../doc/connections/vercel.md) for the remaining live-proof matrix.
Native delivery (2026-10-23): reviewed REST connections are admitted in connection
readiness, native runtime snapshots, and runtime gateway preparation. The real
Codex walkthrough initially failed at all three despite a successful board test;
the database-backed lifecycle regression now covers ready search/request,
existing-card completion, assigned-tool search/call, and inactive/unrecognized
connection denial for both providers. After repair, a real Codex agent deployed
and configured both services. Supabase's Dashboard invocation returned the marker,
and Vercel's signed-in browser served the preview. The Vercel first-deployment
production exception remains recorded as a separate failed outcome; default
preview now requires an existing deployment and reports target mismatches for
reconciliation. See the provider documents for the external-deletion race limit.
+19
View File
@@ -228,3 +228,22 @@ Slack setup does not qualify Discord, Telegram, GitHub, email, or iMessage.
### Documentation scope: App catalog and connection setup
Provider-specific authentication/revocation, credential-only accounts, and aggregator administrative variants still need their own detailed matrices.
Supabase and Vercel deployment connections (2026-10-22): Apps offers Supabase
browser sign-in, hosted MCP personal access token, and project-scoped Management
API methods, plus Vercel's team-scoped access token method. Supabase's optional
MCP project pin leaves project creation available when unset; the native method
requires a project and offers Edge Function deployment and selected configuration.
Vercel supports preview/production deployment, build settings, environment
variables and existing domain attachment. Personal/shared credential ownership,
action permissions, reconnect and removal use the regular setup flow. See
[Supabase](../../doc/connections/SUPABASE.md) and
[Vercel](../../doc/connections/vercel.md) for method boundaries and qualification.
For these native methods, continue past a successful connection test into an
actual native agent task: readiness, assigned-tool delivery, and task-card reuse
must all work. The 2026-10-23 walkthrough exposed and repaired omitted REST
admission at those boundaries. Verify a served deployment and provider settings,
not only the catalog action count. Vercel preview requires an existing deployment;
its provider makes the first deployment production, so an empty-project preview
must fail before submission and explain the explicit production prerequisite.
+36 -8
View File
@@ -305,7 +305,7 @@ describe("AppDefinition catalog", () => {
(entry) => !connectableSlugs.has(entry.slug),
),
).toEqual([]);
for (const entry of BLOCKED_MCP_PROVIDERS)
for (const entry of BLOCKED_MCP_PROVIDERS.filter(entry => entry.slug !== "vercel"))
expect(getAppStoreDefinition(entry.slug)).toBeNull();
});
it("separates GitHub tools from the review bot without changing the provider identity", () => {
@@ -886,7 +886,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(86);
expect(APP_STORE_DEFINITIONS).toHaveLength(87);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
@@ -953,8 +953,12 @@ describe("AppDefinition catalog", () => {
expect(appSupportsCatalogSetup(definitions.get(candidate.slug))).toBe(
true,
);
for (const blocked of BLOCKED_MCP_PROVIDERS)
expect(getAppStoreDefinition(blocked.slug)).toBeNull();
for (const blocked of BLOCKED_MCP_PROVIDERS) {
// MCP client eligibility does not apply to Vercel's reviewed REST adapter.
if (blocked.slug === "vercel") {
expect(getAppStoreDefinition(blocked.slug)?.methods).toMatchObject([{ key: "rest-api-key", transport: "rest_api" }]);
} else expect(getAppStoreDefinition(blocked.slug)).toBeNull();
}
});
it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => {
expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual(
@@ -1434,8 +1438,8 @@ describe("AppDefinition catalog", () => {
"make:mcp-api-token:zone",
"shopify:storefront-mcp:storeDomain",
"shopify:ucp-commerce:storeDomain",
"supabase:mcp-api-key:projectRef",
"supabase:mcp-oauth:projectRef",
"supabase:management-api:projectRef",
"vercel:rest-api-key:teamId",
]);
});
it("limits Vercel Connect setup to the reviewed pilot methods", () => {
@@ -1477,8 +1481,7 @@ describe("AppDefinition catalog", () => {
)?.review.principalModes,
).toEqual(["app"]);
expect(
APP_DEFINITIONS.find((app) => app.slug === "vercel")?.availability
?.available,
APP_DEFINITIONS.find((app) => app.slug === "vercel")?.methods.some(method => method.credentialSources?.vercelConnect),
).toBe(false);
});
});
@@ -1496,6 +1499,31 @@ describe("Railway provider", () => {
});
describe("Deployment connectors", () => {
it("offers Supabase account-level MCP and project-scoped configuration", () => {
const app = getAppStoreDefinition("supabase")!;
expect(app.methods.map(method => method.key)).toEqual(["mcp-oauth", "mcp-api-key", "management-api"]);
for (const method of app.methods.filter(method => method.transport === "mcp_remote")) {
expect(method.tenantFields?.find(field => field.key === "projectRef")).toMatchObject({ advanced: true });
expect(method.tenantFields?.find(field => field.key === "projectRef")?.required).not.toBe(true);
}
expect(app.methods.find(method => method.key === "management-api")).toMatchObject({
transport: "rest_api", auth: "api_key", defaults: { serverUrl: "https://api.supabase.com/v1" },
tenantFields: [expect.objectContaining({ key: "projectRef", required: true })],
});
});
it("offers Vercel deployment setup independently of MCP eligibility", () => {
const app = getAppStoreDefinition("vercel")!;
expect(app.availability?.available).toBe(true);
expect(getAvailableConnectionMethods(app)).toMatchObject([{
key: "rest-api-key", auth: "api_key", transport: "rest_api", riskTier: "S4",
defaults: { serverUrl: "https://api.vercel.com" },
tenantFields: [expect.objectContaining({ key: "teamId", required: true })],
}]);
expect(getAvailableConnectionMethod(app, "mcp-oauth")).toBeNull();
});
});
describe("tool method permission review", () => {
const audit = JSON.parse(fs.readFileSync(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8")) as {
methods: { app: string; method: string; auth: string; policy: string; requestedScopes: string[]; providerDefaultReason?: string; supportedActions: string; evidence: string[]; reviewedAt: string }[];
+1
View File
@@ -10,6 +10,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"browser-use-cloud",
"daytona",
"unikraft",
"vercel",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"monday",
@@ -2,16 +2,18 @@
"schemaVersion": 1,
"slug": "supabase",
"name": "Supabase",
"description": "Connect Supabase's provider-hosted MCP server.",
"description": "Create Supabase projects, deploy Edge Functions, manage databases, and configure application authentication and APIs.",
"categories": [
"data"
"data",
"developer"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/supabase.svg"
},
"urlPatterns": [
"https://mcp.supabase.com/*"
"https://mcp.supabase.com/*",
"https://api.supabase.com/*"
],
"docsUrl": "https://supabase.com/docs/guides/ai-tools/mcp",
"redirectConstraints": "https-or-loopback-http",
@@ -42,7 +44,7 @@
"storage:write"
]
},
"guidanceMd": "Connect Supabase in the browser and scope the connection to one development project. Write tools start enabled and remain governed by Paperclip's action policies.",
"guidanceMd": "Connect the Supabase organization agents should use. Leave Project reference empty to create and manage projects; pin a project to restrict access and disable account tools. Database migrations and Edge Function deployment are available through MCP. Use the Management API method for Auth and Data API configuration.",
"riskTier": "S4",
"label": "Sign in with Supabase",
"consoleLinks": {
@@ -55,14 +57,18 @@
"tenantFields": [
{
"key": "projectRef",
"label": "Project reference",
"label": "Pin to project reference",
"type": "text",
"required": true,
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Scope the connection to one development project.",
"helperMd": "Optional. Restrict access to one existing project. Leave empty to create projects and use account tools.",
"transport": {
"location": "query",
"name": "project_ref"
},
"advanced": true,
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
},
{
@@ -84,16 +90,17 @@
"type": "textarea",
"advanced": true,
"placeholder": "database,docs",
"helperMd": "Optional comma-separated feature groups.",
"helperMd": "Optional comma-separated groups: docs, account, database, debugging, development, functions, branching, storage. Storage is disabled unless selected. Include account for project creation and functions for deployment.",
"transport": {
"location": "query",
"name": "features",
"format": "csv"
},
"validation": {
"pattern": "^(docs|account|database|debugging|development|functions|branching|storage)(\\s*,\\s*(docs|account|database|debugging|development|functions|branching|storage))*$",
"maxLength": 256
}
}
],
"requiredResourceFilters": [
"project"
]
},
{
@@ -103,22 +110,22 @@
"ownershipModes": [
"customer"
],
"whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.",
"whenToUse": "Use a scoped Supabase personal access token for hosted MCP.",
"defaults": {
"serverUrl": "https://mcp.supabase.com/mcp"
},
"guidanceMd": "Use a customer-created Supabase key scoped to one development project. Write tools start enabled and remain governed by Paperclip's action policies.",
"guidanceMd": "Connect the Supabase organization agents should use. Leave Project reference empty to create and manage projects; pin a project to restrict access and disable account tools. Database migrations and Edge Function deployment are available through MCP. Use the Management API method for Auth and Data API configuration.",
"riskTier": "S4",
"label": "Use an API key",
"label": "Use a personal access token",
"credentialFields": [
{
"key": "authorization",
"label": "Supabase API key",
"label": "Supabase personal access token",
"type": "password",
"required": true,
"placeholder": "sbp_...",
"secret": true,
"helperMd": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
"helperMd": "Use a scoped personal access token (sbp_...) with permissions for the enabled MCP tools and selected projects. Project publishable, anon, service_role and secret API keys are not supported. Project creation needs account access; Paperclip cannot increase an issued token’s permissions."
}
],
"keyPlacement": {
@@ -127,7 +134,7 @@
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://supabase.com/docs/guides/ai-tools/mcp",
"keys": "https://supabase.com/dashboard/account/tokens",
"docs": "https://supabase.com/docs/guides/ai-tools/mcp"
},
"warnings": [
@@ -137,14 +144,18 @@
"tenantFields": [
{
"key": "projectRef",
"label": "Project reference",
"label": "Pin to project reference",
"type": "text",
"required": true,
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Scope the connection to one development project.",
"helperMd": "Optional. Restrict access to one existing project. Leave empty to create projects and use account tools.",
"transport": {
"location": "query",
"name": "project_ref"
},
"advanced": true,
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
},
{
@@ -166,16 +177,72 @@
"type": "textarea",
"advanced": true,
"placeholder": "database,docs",
"helperMd": "Optional comma-separated feature groups.",
"helperMd": "Optional comma-separated groups: docs, account, database, debugging, development, functions, branching, storage. Storage is disabled unless selected. Include account for project creation and functions for deployment.",
"transport": {
"location": "query",
"name": "features",
"format": "csv"
},
"validation": {
"pattern": "^(docs|account|database|debugging|development|functions|branching|storage)(\\s*,\\s*(docs|account|database|debugging|development|functions|branching|storage))*$",
"maxLength": 256
}
}
]
},
{
"key": "management-api",
"label": "Deploy and configure with an access token",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Deploy Edge Function source files and secrets, and configure Auth redirect URLs, signup settings, and Data API schemas for an existing project.",
"defaults": {
"serverUrl": "https://api.supabase.com/v1"
},
"riskTier": "S4",
"guidanceMd": "Connect a scoped Supabase personal access token and the project reference. Agents can deploy and remove Edge Functions, set their environment secrets, inspect project details, and configure Auth and the Data API. Use hosted MCP for project creation, SQL migrations, and storage tools. Use the Supabase Dashboard when a human must enter a credential privately.",
"credentialFields": [
{
"key": "authorization",
"label": "Supabase personal access token",
"type": "password",
"required": true,
"placeholder": "sbp_...",
"secret": true,
"helperMd": "Create a scoped Supabase personal access token for the selected project. Set Project Settings, Auth Config, Data API Config, Edge Functions, and Edge Function Secrets to Read-write. Leave API Key Secrets, Auth Signing Keys, and Data API JWT Secret at None. Use a short expiration. Project API keys are not supported; Paperclip cannot increase an issued token’s permissions."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"tenantFields": [
{
"key": "projectRef",
"label": "Project reference",
"type": "text",
"required": true,
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Copy the project reference from Supabase Dashboard → Project Settings. Every action is restricted to this project.",
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
}
],
"requiredResourceFilters": [
"project"
],
"consoleLinks": {
"keys": "https://supabase.com/dashboard/account/tokens",
"docs": "https://supabase.com/docs/reference/api/introduction"
},
"warnings": [
"Deployments and configuration changes affect the selected project. Configure action permissions for the agents that should manage it."
]
}
]
+50 -17
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "vercel",
"name": "Vercel",
"description": "Inspect projects, deployments, and runtime logs.",
"description": "Deploy websites and configure projects, environment variables, and domains in your Vercel team.",
"categories": [
"developer"
],
@@ -12,35 +12,68 @@
"darkLogoUrl": "/brands/apps/vercel-dark.svg"
},
"urlPatterns": [
"https://vercel.com/*",
"https://api.vercel.com/*",
"https://mcp.vercel.com/*"
],
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"key": "rest-api-key",
"label": "Connect Vercel",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer",
"dcr"
"customer"
],
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
"whenToUse": "Deploy and configure projects using a Vercel access token scoped to your team.",
"defaults": {
"serverUrl": "https://mcp.vercel.com/mcp",
"scopesHint": [
"openid"
]
"serverUrl": "https://api.vercel.com"
},
"guidanceMd": "Connect the Vercel team and projects agents should operate.",
"riskTier": "S3",
"guidanceMd": "Create a Vercel access token for the team agents should operate, then enter the team ID from Team Settings. The token needs permission to read projects and to create deployments, update project settings, manage environment variables, and attach domains. Deployment and resource usage may incur Vercel charges. Preview deployments are the default; production requires an explicit target.",
"riskTier": "S4",
"credentialFields": [
{
"key": "authorization",
"label": "Vercel access token",
"type": "password",
"required": true,
"placeholder": "Enter Vercel access token",
"secret": true,
"helperMd": "Create a Vercel access token scoped to the configured team. The token owner role must allow project/deployment reads and requested writes, environment variables and domain management; deletion requires matching provider rights. Paperclip cannot increase existing token permissions."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"tenantFields": [
{
"key": "teamId",
"label": "Vercel team ID",
"type": "text",
"required": true,
"placeholder": "team_…",
"helperMd": "Copy the Team ID from Vercel Team Settings → General. Every action stays in this team; agents cannot override it.",
"validation": {
"pattern": "^team_[A-Za-z0-9]+$",
"maxLength": 100
}
}
],
"requiredResourceFilters": [
"team",
"project",
"environment"
]
],
"consoleLinks": {
"keys": "https://vercel.com/account/tokens",
"docs": "https://vercel.com/docs/rest-api"
}
}
],
"availability": {
"available": false,
"reason": "Vercel currently reviews and approves MCP clients."
}
"available": true
},
"docsUrl": "https://vercel.com/docs/rest-api"
}
@@ -0,0 +1,249 @@
{
"schemaVersion": 1,
"slug": "supabase",
"name": "Supabase",
"description": "Create Supabase projects, deploy Edge Functions, manage databases, and configure application authentication and APIs.",
"categories": [
"data",
"developer"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/supabase.svg"
},
"urlPatterns": [
"https://mcp.supabase.com/*",
"https://api.supabase.com/*"
],
"docsUrl": "https://supabase.com/docs/guides/ai-tools/mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://mcp.supabase.com/mcp",
"scopesHint": [
"organizations:read",
"projects:read",
"projects:write",
"database:write",
"database:read",
"analytics:read",
"secrets:read",
"edge_functions:read",
"edge_functions:write",
"environment:read",
"environment:write",
"storage:read",
"storage:write"
]
},
"guidanceMd": "Connect the Supabase organization agents should use. Leave Project reference empty to create and manage projects; pin a project to restrict access and disable account tools. Database migrations and Edge Function deployment are available through MCP. Use the Management API method for Auth and Data API configuration.",
"riskTier": "S4",
"label": "Sign in with Supabase",
"consoleLinks": {
"docs": "https://supabase.com/docs/guides/ai-tools/mcp"
},
"warnings": [
"A Supabase account; use a development project and review write actions before connecting production data.",
"Do not connect production data unless you have reviewed Supabase's MCP security guidance."
],
"tenantFields": [
{
"key": "projectRef",
"label": "Pin to project reference",
"type": "text",
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Optional. Restrict access to one existing project. Leave empty to create projects and use account tools.",
"transport": {
"location": "query",
"name": "project_ref"
},
"advanced": true,
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
},
{
"key": "readOnly",
"label": "Read-only mode",
"type": "checkbox",
"defaultValue": false,
"helperMd": "Enable this to prevent the connection from changing the database.",
"transport": {
"location": "query",
"name": "read_only",
"format": "boolean"
},
"advanced": true
},
{
"key": "features",
"label": "Feature groups",
"type": "textarea",
"advanced": true,
"placeholder": "database,docs",
"helperMd": "Optional comma-separated groups: docs, account, database, debugging, development, functions, branching, storage. Storage is disabled unless selected. Include account for project creation and functions for deployment.",
"transport": {
"location": "query",
"name": "features",
"format": "csv"
},
"validation": {
"pattern": "^(docs|account|database|debugging|development|functions|branching|storage)(\\s*,\\s*(docs|account|database|debugging|development|functions|branching|storage))*$",
"maxLength": 256
}
}
]
},
{
"key": "mcp-api-key",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use a scoped Supabase personal access token for hosted MCP.",
"defaults": {
"serverUrl": "https://mcp.supabase.com/mcp"
},
"guidanceMd": "Connect the Supabase organization agents should use. Leave Project reference empty to create and manage projects; pin a project to restrict access and disable account tools. Database migrations and Edge Function deployment are available through MCP. Use the Management API method for Auth and Data API configuration.",
"riskTier": "S4",
"label": "Use a personal access token",
"credentialFields": [
{
"key": "authorization",
"label": "Supabase personal access token",
"type": "password",
"required": true,
"placeholder": "sbp_...",
"secret": true,
"helperMd": "Create a scoped personal access token (sbp_...) with permissions for the enabled MCP tools. Project publishable, anon, service_role, and secret API keys do not authenticate the Management API or hosted MCP server."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://supabase.com/dashboard/account/tokens",
"docs": "https://supabase.com/docs/guides/ai-tools/mcp"
},
"warnings": [
"A Supabase account; use a development project and review write actions before connecting production data.",
"Do not connect production data unless you have reviewed Supabase's MCP security guidance."
],
"tenantFields": [
{
"key": "projectRef",
"label": "Pin to project reference",
"type": "text",
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Optional. Restrict access to one existing project. Leave empty to create projects and use account tools.",
"transport": {
"location": "query",
"name": "project_ref"
},
"advanced": true,
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
},
{
"key": "readOnly",
"label": "Read-only mode",
"type": "checkbox",
"defaultValue": false,
"helperMd": "Enable this to prevent the connection from changing the database.",
"transport": {
"location": "query",
"name": "read_only",
"format": "boolean"
},
"advanced": true
},
{
"key": "features",
"label": "Feature groups",
"type": "textarea",
"advanced": true,
"placeholder": "database,docs",
"helperMd": "Optional comma-separated groups: docs, account, database, debugging, development, functions, branching, storage. Storage is disabled unless selected. Include account for project creation and functions for deployment.",
"transport": {
"location": "query",
"name": "features",
"format": "csv"
},
"validation": {
"pattern": "^(docs|account|database|debugging|development|functions|branching|storage)(\\s*,\\s*(docs|account|database|debugging|development|functions|branching|storage))*$",
"maxLength": 256
}
}
]
},
{
"key": "management-api",
"label": "Deploy and configure with an access token",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Deploy Edge Function source files and secrets, and configure Auth redirect URLs, signup settings, and Data API schemas for an existing project.",
"defaults": {
"serverUrl": "https://api.supabase.com/v1"
},
"riskTier": "S4",
"guidanceMd": "Connect a scoped Supabase personal access token and the project reference. Agents can deploy and remove Edge Functions, set their environment secrets, inspect project details, and configure Auth and the Data API. Use hosted MCP for project creation, SQL migrations, and storage tools. Use the Supabase Dashboard when a human must enter a credential privately.",
"credentialFields": [
{
"key": "authorization",
"label": "Supabase personal access token",
"type": "password",
"required": true,
"placeholder": "sbp_...",
"secret": true,
"helperMd": "Scope the token to this project. Grant Read-write for Project Settings, Auth Config, Data API Config, Edge Functions, and Edge Function Secrets. Auth updates require both Project Settings and Auth Config. Project API keys (publishable, anon, service_role, and secret keys) are not supported. Paperclip cannot increase token permissions."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"tenantFields": [
{
"key": "projectRef",
"label": "Project reference",
"type": "text",
"required": true,
"placeholder": "abcdefghijklmnopqrst",
"helperMd": "Copy the project reference from Supabase Dashboard \u2192 Project Settings. Every action is restricted to this project.",
"validation": {
"pattern": "^[a-z]{20}$",
"maxLength": 20
}
}
],
"requiredResourceFilters": [
"project"
],
"consoleLinks": {
"keys": "https://supabase.com/dashboard/account/tokens",
"docs": "https://supabase.com/docs/reference/api/introduction"
},
"warnings": [
"Deployments and configuration changes affect the selected project. Configure action permissions for the agents that should manage it."
]
}
]
}
@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"slug": "vercel",
"name": "Vercel",
"description": "Deploy websites and configure projects, environment variables, and domains in your Vercel team.",
"categories": ["developer"],
"featured": false,
"availability": {"available": true},
"branding": {"logoUrl": "/brands/apps/vercel.svg", "darkLogoUrl": "/brands/apps/vercel-dark.svg"},
"urlPatterns": ["https://vercel.com/*", "https://api.vercel.com/*", "https://mcp.vercel.com/*"],
"docsUrl": "https://vercel.com/docs/rest-api",
"methods": [{
"key": "rest-api-key",
"label": "Connect Vercel",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": ["customer"],
"whenToUse": "Deploy and configure projects using a Vercel access token scoped to your team.",
"defaults": {"serverUrl": "https://api.vercel.com"},
"guidanceMd": "Create a Vercel access token for the team agents should operate, then enter the team ID from Team Settings. The token needs permission to read projects and to create deployments, update project settings, manage environment variables, and attach domains. Deployment and resource usage may incur Vercel charges. Preview deployments are the default; production requires an explicit target.",
"riskTier": "S4",
"credentialFields": [{
"key": "authorization",
"label": "Vercel access token",
"type": "password",
"required": true,
"placeholder": "Enter Vercel access token",
"secret": true,
"helperMd": "Create a token at Vercel Account Settings → Tokens. Select the intended team and an appropriate expiration. Your Vercel role controls its permissions; reconnect with a new token if it expires or cannot deploy. The token stays in the Paperclip vault."
}],
"keyPlacement": {"location": "header", "name": "Authorization", "prefix": "Bearer "},
"tenantFields": [{
"key": "teamId",
"label": "Vercel team ID",
"type": "text",
"required": true,
"placeholder": "team_…",
"helperMd": "Copy the Team ID from Vercel Team Settings → General. Every action stays in this team; agents cannot override it.",
"validation": {"pattern": "^team_[A-Za-z0-9]+$", "maxLength": 100}
}],
"requiredResourceFilters": ["team", "project", "environment"],
"consoleLinks": {"keys": "https://vercel.com/account/tokens", "docs": "https://vercel.com/docs/rest-api"}
}]
}
@@ -3375,6 +3375,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
limitations: [...parameters.coverage!.limitations.slice(0, 50), "Changed final limitation"] } };
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: changedCoverage }))
.rejects.toMatchObject({ status: 409, reasonCode: "github_idempotency_conflict" });
const invalidCoverage = await gateway.executeTool({ sessionToken: session.token, tool: tool.name,
parameters: { ...parameters, coverage: { ...parameters.coverage!, reviewedPaths: ["outside-this-pr.ts"] } } })
.then(() => null, error => error);
expect(invalidCoverage).toMatchObject({ status: 409,
message: "Assessment coverage and findings must refer to this PR's allowed changed files" });
expect(invalidCoverage).not.toHaveProperty("reasonCode");
}
await db.insert(toolPolicies).values({ companyId: f.companyId, name: "Revoke publication", policyType: "block", priority: 0,
selectors: { catalogEntryId: tool.catalogEntryId! } });
@@ -0,0 +1,627 @@
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from "vitest";
import {
activityLog, agents, applyPendingMigrations, companies, companyMemberships, companySecrets,
connectionGrants, createDb, ensurePostgresDatabase, heartbeatRuns, issues, issueThreadInteractions,
toolAccessAuditEvents, toolActionRequests, toolApplications, toolCatalogEntries, toolConnections, toolInvocations, userSecretDeclarations,
} from "@paperclipai/db";
import { toolAccessService } from "../services/tool-access.js";
import { createToolGatewayService } from "../services/tool-gateway.js";
import { summarizeToolValue } from "../services/tool-content-guards.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js";
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat/run-preparation.js";
import { createAssignedMcpTools } from "../services/native-runtime/assigned-mcp-tools.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
// These provider fixtures exercise Paperclip's real setup, vault, policy, and
// run gateway. They do not establish live provider permissions or deployment.
const actor = { actorType: "user" as const, actorId: "deployment-connector-owner" };
const projectRef = "abcdefghijklmnopqrst";
const teamId = "team_fixture123";
const projectId = "prj_fixture123";
const deploymentId = "dpl_fixture123";
const environmentSecret = "synthetic-deployment-environment-secret";
const providers = [
{
slug: "supabase",
methodKey: "management-api",
methodConfig: { projectRef },
read: { name: "supabase_get_project", parameters: {}, receipt: projectRef },
deploy: {
name: "supabase_deploy_edge_function",
parameters: {
slug: "hello", entrypoint_path: "index.ts", verify_jwt: true,
files: [{ name: "index.ts", content: 'Deno.serve(() => new Response("hello"))' }],
},
receipt: "hello",
},
configure: {
name: "supabase_update_auth_config",
parameters: { config: { site_url: "https://app.example", uri_allow_list: "https://app.example/**" } },
receipt: "https://app.example",
},
configureSecret: {
name: "supabase_upsert_edge_function_secret",
parameters: { name: "SERVICE_TOKEN", secretValue: environmentSecret },
receipt: "SERVICE_TOKEN",
},
},
{
slug: "vercel",
methodKey: "rest-api-key",
methodConfig: { teamId },
read: { name: "vercel_list_projects", parameters: {}, receipt: projectId },
deploy: {
name: "vercel_create_deployment",
parameters: { projectId, name: "fixture-app", files: [{ file: "index.html", content: "<h1>Hello</h1>" }], target: "preview" },
receipt: deploymentId,
},
configure: {
name: "vercel_upsert_environment_variable",
parameters: { projectId, key: "SERVICE_TOKEN", secretValue: environmentSecret, target: ["preview"] },
receipt: "SERVICE_TOKEN",
},
},
] as const;
type Provider = typeof providers[number];
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("Deployment connector governance", () => {
let db: ReturnType<typeof createDb>;
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
beforeAll(async () => {
if (external) {
const url = new URL(external);
if (process.env.PAPERCLIP_TEST_REUSE_DEPLOYMENT_DATABASE === "1") {
// Only an explicitly named synthetic fixture database may be reused.
// Each test still creates its own company and connection identities.
if (!/^\/deployment_connectors_[a-f0-9]{32}$/.test(url.pathname)) {
throw new Error("Reuse requires an isolated deployment_connectors fixture database");
}
} else {
url.pathname = `/deployment_connectors_${randomUUID().replaceAll("-", "")}`;
}
await ensurePostgresDatabase(external, url.pathname.slice(1));
await applyPendingMigrations(url.toString());
db = createDb(url.toString());
} else {
temp = await startEmbeddedPostgresTestDatabase("deployment-connectors-");
db = createDb(temp.connectionString);
}
}, 30000);
afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); });
async function createCompany() {
const [company] = await db.insert(companies).values({ name: "Deployment fixture", issuePrefix: `D${randomUUID().slice(0, 6)}` }).returning();
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" });
return company;
}
async function fixture(provider: Provider, personal = false, secretValues = [environmentSecret]) {
const company = await createCompany();
let credential = `synthetic-${provider.slug}-credential`;
const request = vi.fn(async (url: string, init: RequestInit) => {
expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${credential}`);
expect(["error", "manual"]).toContain(init.redirect);
const parsed = new URL(url);
const method = init.method ?? "GET";
if (provider.slug === "supabase") {
expect(parsed.origin).toBe("https://api.supabase.com");
const prefix = `/v1/projects/${projectRef}`;
if (parsed.pathname === prefix && method === "GET") return Response.json({ id: projectRef, name: "Fixture project", status: "ACTIVE_HEALTHY", organization_id: "fixture-org", region: "us-east-1" });
if (parsed.pathname === `${prefix}/config/auth`) {
if (method === "PATCH") expect(JSON.parse(String(init.body))).toEqual(providers[0].configure.parameters.config);
return Response.json({ site_url: "https://app.example", uri_allow_list: "https://app.example/**", jwt_secret: credential });
}
if (parsed.pathname === `${prefix}/functions/deploy` && method === "POST") {
expect(parsed.searchParams.get("slug")).toBe("hello");
expect(Buffer.isBuffer(init.body)).toBe(true);
const form = await new Response(init.body, { headers: init.headers }).formData();
expect(JSON.parse(String(form.get("metadata")))).toMatchObject({ entrypoint_path: "index.ts", verify_jwt: true });
expect(await (form.get("file") as Blob).text()).toBe(providers[0].deploy.parameters.files[0].content);
return Response.json({ id: randomUUID(), slug: "hello", name: "hello", status: "ACTIVE", version: 1 });
}
if (parsed.pathname === `${prefix}/secrets` && method === "POST") {
const body = JSON.parse(String(init.body));
expect(body).toEqual([{ name: "SERVICE_TOKEN", value: expect.any(String) }]);
expect(secretValues).toContain(body[0].value);
return Response.json(body);
}
} else {
expect(parsed.origin).toBe("https://api.vercel.com");
if (parsed.pathname === `/v2/teams/${teamId}` && method === "GET") return Response.json({ id: teamId, name: "Fixture team" });
expect(parsed.searchParams.get("teamId")).toBe(teamId);
if (parsed.pathname === "/v10/projects" && method === "GET") return Response.json({ projects: [{ id: projectId, name: "fixture-app", accountId: teamId }], pagination: { count: 1, next: null } });
if (parsed.pathname === `/v9/projects/${projectId}` && method === "GET") return Response.json({ id: projectId, name: "fixture-app", accountId: teamId });
if (parsed.pathname === "/v7/deployments" && method === "GET") {
expect(parsed.searchParams.get("projectId")).toBe(projectId);
expect(parsed.searchParams.get("limit")).toBe("1");
return Response.json({ deployments: [{ uid: "dpl_existing123", projectId, readyState: "ERROR" }] });
}
if (parsed.pathname === "/v13/deployments" && method === "POST") {
expect(JSON.parse(String(init.body))).toMatchObject({ project: projectId, files: [{ file: "index.html", data: "<h1>Hello</h1>", encoding: "utf-8" }] });
return Response.json({ id: deploymentId, projectId, name: "fixture-app", url: "fixture-app.vercel.app", readyState: "BUILDING", target: null });
}
if (parsed.pathname === `/v10/projects/${projectId}/env` && method === "POST") {
expect(parsed.searchParams.get("upsert")).toBe("true");
const body = JSON.parse(String(init.body));
expect(body).toMatchObject({ key: "SERVICE_TOKEN", value: expect.any(String), target: ["preview"], type: "encrypted" });
expect(secretValues).toContain(body.value);
return Response.json({ created: { id: "env_fixture", ...body }, failed: [] });
}
}
throw new Error(`Unexpected ${provider.slug} fixture request: ${method} ${parsed.pathname}`);
});
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
const connected = await service.connectGalleryApp(company.id, {
galleryKey: provider.slug, connectionMethodKey: provider.methodKey,
configValues: provider.methodConfig, ...(personal ? { grantKind: "user" as const } : {}),
credentialValues: { "credentials.authorization": credential },
}, actor);
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Deployer", role: "engineer", adapterType: "process" }).returning();
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
}, actor);
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Deploy fixture app", assigneeAgentId: agent.id }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id,
scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning();
const gateway = createToolGatewayService(db, { remoteHttpRequest: request, toolActionSigningSecret: "synthetic-deployment-action-signing-secret" });
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id });
const tools = await gateway.listToolsForSession(session.token);
const call = (action: { name: string; parameters: unknown }) => {
const tool = tools.find(candidate => candidate.upstreamToolName === action.name);
expect(tool, action.name).toBeTruthy();
return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: action.parameters, idempotencyKey: randomUUID() });
};
return { company, service, connected, agent, issue, run, request, gateway, session, tools, call,
credential: () => credential, rotateCredential: () => { credential = `synthetic-${provider.slug}-replacement`; return credential; } };
}
describe.each(providers)("$slug", provider => {
it("recognizes reviewed REST readiness and completes an existing connection card", async () => {
const f = await fixture(provider, true);
await f.service.putConnectionInstalls(f.connected.connectionId, {
installs: [{ targetType: "agent", targetId: f.agent.id }],
}, actor);
const intents = connectionIntentService(db);
const claims = { sub: f.agent.id, company_id: f.company.id, run_id: f.run.id, responsible_user_id: actor.actorId };
f.request.mockClear();
const found = await intents.search(claims, provider.slug);
expect(found.results).toContainEqual(expect.objectContaining({
service: provider.slug, state: "ready", connectionId: f.connected.connectionId,
}));
expect(await intents.request(claims, provider.slug)).toMatchObject({
state: "ready", connectionId: f.connected.connectionId, interactionId: null,
});
const [card] = await db.insert(issueThreadInteractions).values({
companyId: f.company.id, issueId: f.issue.id, sourceRunId: f.run.id,
kind: "connection_intent", status: "pending", addresseeUserId: actor.actorId,
requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only",
payload: { version: 1, serviceSlug: provider.slug, serviceName: provider.slug,
requestingAgentId: f.agent.id, requestingAgentName: f.agent.name, phase: "requested" },
}).returning();
expect(await intents.complete(card.id, f.connected.connectionId, actor.actorId)).toMatchObject({
status: "accepted", result: { outcome: "connected", connectionId: f.connected.connectionId },
});
expect(await intents.usableConnectionForAgent({ companyId: f.company.id, agentId: f.agent.id,
responsibleUserId: "another-user", serviceSlug: provider.slug })).toBeNull();
expect(f.request).not.toHaveBeenCalled();
});
it("delivers reviewed REST actions through native assignments without admitting unsupported or inactive connections", async () => {
vi.stubEnv("PAPERCLIP_API_URL", "http://127.0.0.1:3100");
onTestFinished(() => vi.unstubAllEnvs());
const f = await fixture(provider, true);
await f.service.putConnectionInstalls(f.connected.connectionId, {
installs: [{ targetType: "agent", targetId: f.agent.id }],
}, actor);
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: f.agent, runId: f.run.id });
expect(snapshot.bindingId).not.toBeNull();
const servers = await buildPaperclipRuntimeMcpServers({ db, agent: f.agent, runId: f.run.id,
expectedAssignmentDigest: snapshot.digest });
expect(servers).toHaveLength(1);
const assigned = await createAssignedMcpTools({ gateway: f.gateway,
gatewayPublicId: new URL(servers[0].url).pathname.split("/").at(-1)!, bearerToken: servers[0].token! });
const found = await assigned.execute({ tool: "paperclip_search_assigned_tools", arguments: { query: provider.read.name } }) as { tools: Array<{ name: string }> };
expect(found.tools).toHaveLength(1);
expect(JSON.stringify(await assigned.execute({ tool: "paperclip_call_assigned_tool",
arguments: { name: found.tools[0].name, arguments: provider.read.parameters } }))).toContain(provider.read.receipt);
const intents = connectionIntentService(db);
const original = f.connected.connection;
f.request.mockClear();
for (const change of [
{ enabled: false },
{ status: "archived" as const },
{ config: { ...original.config, url: "https://unsupported.example" } },
]) {
await db.update(toolConnections).set({ status: "active", enabled: true, config: original.config, ...change })
.where(eq(toolConnections.id, original.id));
expect(await intents.usableConnectionForAgent({ companyId: f.company.id, agentId: f.agent.id,
responsibleUserId: actor.actorId, serviceSlug: provider.slug })).toBeNull();
expect((await resolveNativeRuntimeMcpSnapshot({ db, agent: f.agent, runId: f.run.id })).bindingId).toBeNull();
expect(await buildPaperclipRuntimeMcpServers({ db, agent: f.agent, runId: f.run.id })).toEqual([]);
}
expect(f.request).not.toHaveBeenCalled();
});
it("rejects unknown setup settings before saving credentials or contacting the provider", async () => {
const company = await createCompany();
const request = vi.fn(async () => { throw new Error("Unexpected provider request"); });
const service = toolAccessService(db, { remoteHttpRequest: request });
await expect(service.connectGalleryApp(company.id, {
galleryKey: provider.slug, connectionMethodKey: provider.methodKey,
configValues: { ...provider.methodConfig, serverUrl: "https://attacker.example" },
credentialValues: { "credentials.authorization": "synthetic-rejected-setup-key" },
}, actor)).rejects.toThrow();
expect(request).not.toHaveBeenCalled();
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0);
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
});
it("cleans up failed personal setup without echoing provider credentials", async () => {
const company = await createCompany();
const credential = "synthetic-invalid-provider-token";
const request = vi.fn(async () => Response.json({ error: `Rejected ${credential}` }, { status: 401 }));
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
let failure: unknown;
try {
await service.connectGalleryApp(company.id, {
galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: provider.methodConfig,
grantKind: "user", credentialValues: { "credentials.authorization": credential },
}, actor);
} catch (error) { failure = error; }
expect(failure).toBeInstanceOf(Error);
expect(String(failure)).not.toContain(credential);
expect(request).toHaveBeenCalled();
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0);
const secrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
expect(secrets.every(secret => secret.status !== "active" || secret.deletedAt !== null)).toBe(true);
expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.companyId, company.id))).toHaveLength(0);
});
it.each([false, true])("vaults the selected identity and executes governed deployment/configuration (personal=%s)", async personal => {
const f = await fixture(provider, personal);
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connected.connectionId));
expect(grant).toMatchObject({ kind: personal ? "user" : "organization", status: "active" });
expect(grant.credentialSecretRefs).toHaveLength(1);
expect(grant.credentialSecretRefs[0].configPath).toBe("credentials.authorization");
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
expect(secret).toMatchObject({ scope: personal ? "user" : "company", ownerUserId: personal ? actor.actorId : null });
if (personal) {
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, f.connected.connectionId));
expect(declarations).toEqual(expect.arrayContaining([expect.objectContaining({
configPath: "credentials.authorization", userSecretDefinitionId: secret.userSecretDefinitionId,
})]));
expect(f.connected.connection.credentialSecretRefs).toEqual([]);
}
expect(f.connected.catalog.find(entry => entry.toolName === provider.read.name)).toMatchObject({ isReadOnly: true, riskLevel: "read" });
for (const action of [provider.deploy, provider.configure]) {
expect(f.connected.catalog.find(entry => entry.toolName === action.name)).toMatchObject({ isReadOnly: false });
}
expect(JSON.stringify(f.connected)).not.toContain(f.credential());
f.request.mockClear();
await expect(f.service.mintConnectionTokenForAgent({
connectionId: f.connected.connectionId, companyId: f.company.id, agentId: f.agent.id, runId: f.run.id, body: {},
})).rejects.toMatchObject({ status: 403, message: expect.stringContaining("cannot be exported") });
expect(f.request).not.toHaveBeenCalled();
const actions = [provider.read, provider.deploy, provider.configure, ...(provider.slug === "supabase" ? [provider.configureSecret] : [])];
for (const action of actions) {
const result = await f.call(action);
expect(JSON.stringify(result)).toContain(action.receipt);
expect(JSON.stringify(result)).not.toContain(f.credential());
expect(JSON.stringify(result)).not.toContain(environmentSecret);
}
if (provider.slug === "supabase") {
expect(f.request.mock.calls.some(([url, init]) => new URL(url).pathname === `/v1/projects/${projectRef}/config/auth` && init.method === "PATCH")).toBe(true);
}
const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.connectionId, f.connected.connectionId));
expect(invocations).toHaveLength(actions.length);
expect(invocations.every(invocation => invocation.runId === f.run.id && invocation.issueId === f.issue.id && invocation.agentId === f.agent.id && invocation.status === "succeeded")).toBe(true);
const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
const activity = await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id));
const visible = JSON.stringify({ connection: await f.service.getConnection(f.connected.connectionId), invocations, audits, activity });
expect(visible).not.toContain(f.credential());
expect(visible).not.toContain(environmentSecret);
});
it("enforces changed Off policies and quarantined deployment contracts on an existing run", async () => {
const f = await fixture(provider);
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.toolName !== provider.configure.name).map(entry => entry.id),
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
}, actor);
f.request.mockClear();
await expect(f.call(provider.configure)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
const deployment = f.connected.catalog.find(entry => entry.toolName === provider.deploy.name)!;
await db.update(toolCatalogEntries).set({ schemaHash: "earlier-schema", versionHash: "earlier-version" }).where(eq(toolCatalogEntries.id, deployment.id));
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === provider.deploy.name)).toBe(false);
await expect(f.call(provider.deploy)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
const refreshed = await f.service.refreshCatalog(f.connected.connectionId, actor, { enableAllByDefault: true });
expect(refreshed.catalog.find(entry => entry.toolName === provider.deploy.name)?.status).toBe("quarantined");
f.request.mockClear();
await expect(f.call(provider.deploy)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
});
it("posts an Ask-first deployment request without reaching the provider", async () => {
const f = await fixture(provider);
const deployment = f.connected.catalog.find(entry => entry.toolName === provider.deploy.name)!;
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
enabledCatalogEntryIds: f.connected.catalog.map(entry => entry.id),
askFirstCatalogEntryIds: [deployment.id], access: { agentIds: [f.agent.id] },
}, actor);
f.request.mockClear();
await expect(f.call(provider.deploy)).rejects.toMatchObject({ reasonCode: "approval_required" });
expect(f.request).not.toHaveBeenCalled();
const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({ status: "pending", companyId: f.company.id, issueId: f.issue.id, requestedByAgentId: f.agent.id });
expect(JSON.stringify(requests)).not.toContain(f.credential());
});
it("encrypts pending secret configuration and executes the original value only after approval", async () => {
const f = await fixture(provider);
const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure;
const entry = f.connected.catalog.find(candidate => candidate.toolName === configure.name)!;
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
enabledCatalogEntryIds: f.connected.catalog.map(candidate => candidate.id),
askFirstCatalogEntryIds: [entry.id], access: { agentIds: [f.agent.id] },
}, actor);
f.request.mockClear();
await expect(f.call(configure)).rejects.toMatchObject({ reasonCode: "approval_required" });
expect(f.request).not.toHaveBeenCalled();
const [pending] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
expect(pending.status).toBe("pending");
expect(pending.signedArguments).toEqual(expect.any(String));
expect(Buffer.from(pending.signedArguments!, "base64url").toString("utf8")).not.toContain(environmentSecret);
expect(JSON.stringify(pending)).not.toContain(environmentSecret);
const approved = await f.gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: pending.id, actor: { userId: actor.actorId } });
expect(approved).toMatchObject({ status: "executed" });
// Provider fixture assertions check the decrypted original value exactly.
expect(f.request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1);
const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id));
const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
expect(JSON.stringify({ approved, invocations, audits, requests })).not.toContain(environmentSecret);
expect(JSON.stringify({ approved, invocations, audits, requests })).not.toContain(f.credential());
});
it.each(["allowed", "ask_first", "ask_first_legacy"] as const)("distinguishes changed secrets and replays identical writes without exposing values (%s)", async mode => {
const askFirst = mode !== "allowed";
const secrets = ["synthetic-secret-first-value", "synthetic-secret-second-value"];
const f = await fixture(provider, false, secrets);
const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure;
const tool = f.tools.find(candidate => candidate.upstreamToolName === configure.name)!;
const write = (secretValue: string) => f.gateway.executeTool({
sessionToken: f.session.token, tool: tool.name,
parameters: { ...configure.parameters, secretValue },
});
if (askFirst) {
const entry = f.connected.catalog.find(candidate => candidate.toolName === configure.name)!;
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
enabledCatalogEntryIds: f.connected.catalog.map(candidate => candidate.id),
askFirstCatalogEntryIds: [entry.id], access: { agentIds: [f.agent.id] },
}, actor);
}
const execute = async (secretValue: string) => {
if (askFirst) {
await expect(write(secretValue)).rejects.toMatchObject({ reasonCode: "approval_required" });
const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
const pending = requests.filter(request => request.status === "pending");
expect(pending).toHaveLength(1);
await f.gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: pending[0].id, actor: { userId: actor.actorId } });
if (mode === "ask_first_legacy" && secretValue === secrets[0]) {
// Historical rows used the redacted summary hash. Their signed
// payload must still distinguish a later change to the secret.
await db.update(toolActionRequests).set({
canonicalArgumentsHash: summarizeToolValue({ ...configure.parameters, secretValue }).sha256,
}).where(eq(toolActionRequests.id, pending[0].id));
}
}
return write(secretValue);
};
f.request.mockClear();
const first = await execute(secrets[0]);
const second = await execute(secrets[1]);
const replay = await write(secrets[1]);
expect(first.status).toBe(askFirst ? "replayed" : "completed");
expect(second.status).toBe(askFirst ? "replayed" : "completed");
expect(second.invocationId).not.toBe(first.invocationId);
expect(replay).toMatchObject({ status: "replayed", invocationId: second.invocationId });
const writes = f.request.mock.calls.filter(([, init]) => init.method === "POST");
expect(writes).toHaveLength(2);
expect(writes.map(([, init]) => {
const body = JSON.parse(String(init.body));
return provider.slug === "supabase" ? body[0].value : body.value;
})).toEqual(secrets);
const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id));
expect(invocations).toHaveLength(2);
expect(new Set(invocations.map(invocation => invocation.argumentsHash)).size).toBe(2);
const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
expect(requests).toHaveLength(askFirst ? 2 : 0);
const visible = JSON.stringify({ first, second, replay, invocations, audits, requests });
for (const secret of secrets) expect(visible).not.toContain(secret);
});
it("requires reconciliation before retrying an ambiguous completed legacy secret write", async () => {
const secrets = ["synthetic-legacy-secret-first-value", "synthetic-legacy-secret-second-value"];
const f = await fixture(provider, false, secrets);
const configure = provider.slug === "supabase" ? provider.configureSecret : provider.configure;
const tool = f.tools.find(candidate => candidate.upstreamToolName === configure.name)!;
const write = (secretValue: string, idempotencyKey?: string) => f.gateway.executeTool({
sessionToken: f.session.token, tool: tool.name,
parameters: { ...configure.parameters, secretValue }, idempotencyKey,
});
const first = await write(secrets[0]);
expect(first.status).toBe("completed");
const [saved] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, first.invocationId));
const argumentsHash = saved.argumentsSummary!.sha256;
const legacyScope = {
companyId: saved.companyId, runId: saved.runId, issueId: saved.issueId,
applicationId: saved.applicationId, connectionId: saved.connectionId,
catalogEntryId: saved.catalogEntryId, toolName: saved.toolName, argumentsHash,
};
// This reproduces the persisted default key used before full argument
// identity. Its redacted summary cannot prove which secret was written.
const canonicalScope = Object.fromEntries(Object.keys(legacyScope).sort().map(key => [key, legacyScope[key as keyof typeof legacyScope]]));
const legacyKey = `side_effect:${createHash("sha256").update(JSON.stringify(canonicalScope)).digest("hex")}`;
await db.update(toolInvocations).set({ argumentsHash, idempotencyKey: legacyKey }).where(eq(toolInvocations.id, first.invocationId));
f.request.mockClear();
for (const secretValue of secrets) {
await expect(write(secretValue)).rejects.toMatchObject({
status: 409,
reasonCode: "legacy_tool_idempotency_unverifiable",
details: { reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: first.invocationId },
});
}
expect(f.request).not.toHaveBeenCalled();
const explicitKey = randomUUID();
const second = await write(secrets[1], explicitKey);
const replay = await write(secrets[1], explicitKey);
expect(second).toMatchObject({ status: "completed" });
expect(second.invocationId).not.toBe(first.invocationId);
expect(replay).toMatchObject({ status: "replayed", invocationId: second.invocationId });
const writes = f.request.mock.calls.filter(([, init]) => init.method === "POST");
expect(writes).toHaveLength(1);
const body = JSON.parse(String(writes[0][1].body));
expect(provider.slug === "supabase" ? body[0].value : body.value).toBe(secrets[1]);
const invocations = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, f.company.id));
expect(invocations).toHaveLength(2);
const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id));
const requests = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id));
expect(requests).toHaveLength(0);
const visible = JSON.stringify({ first, second, replay, invocations, audits });
for (const secret of secrets) expect(visible).not.toContain(secret);
});
it("keeps another company and another responsible user outside a personal connection", async () => {
const f = await fixture(provider, true);
const foreign = await createCompany();
await expect(f.service.getConnection(f.connected.connectionId, foreign.id)).rejects.toMatchObject({ status: 404 });
await expect(f.service.reconnectGalleryApp(f.connected.connectionId, foreign.id, {
credentialValues: { "credentials.authorization": "synthetic-foreign-replacement" },
}, actor)).rejects.toMatchObject({ status: 404 });
const [agent] = await db.insert(agents).values({ companyId: foreign.id, name: "Foreign deployer", role: "engineer", adapterType: "process" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: foreign.id, agentId: agent.id,
scopeKind: "company", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId }).returning();
const session = await f.gateway.createSession({ companyId: foreign.id, agentId: agent.id, runId: run.id });
const deploy = f.tools.find(tool => tool.upstreamToolName === provider.deploy.name)!;
expect((await f.gateway.listToolsForSession(session.token)).some(tool => tool.upstreamToolName === provider.deploy.name)).toBe(false);
f.request.mockClear();
await expect(f.gateway.executeTool({ sessionToken: session.token, tool: deploy.name, parameters: provider.deploy.parameters })).rejects.toThrow();
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: "another-user", membershipRole: "admin", status: "active" });
await expect(f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, {
credentialValues: { "credentials.authorization": "synthetic-foreign-replacement" },
}, { actorType: "user", actorId: "another-user" })).rejects.toMatchObject({ status: 403 });
await db.update(heartbeatRuns).set({ responsibleUserId: "another-user" }).where(eq(heartbeatRuns.id, f.run.id));
await expect(f.call(provider.deploy)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
});
it("rotates credentials without widening saved policies and removes access and owned secrets", async () => {
const f = await fixture(provider, true);
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.toolName !== provider.deploy.name).map(entry => entry.id),
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
}, actor);
const replacement = f.rotateCredential();
const reconnected = await f.service.reconnectGalleryApp(f.connected.connectionId, f.company.id, {
credentialValues: { "credentials.authorization": replacement },
}, actor);
expect(reconnected.connection.id).toBe(f.connected.connectionId);
expect(JSON.stringify(reconnected)).not.toContain(replacement);
expect(JSON.stringify(await f.call(provider.read))).toContain(provider.read.receipt);
f.request.mockClear();
await expect(f.call(provider.deploy)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
const removed = await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor);
expect(removed.connection).toMatchObject({ status: "archived", enabled: false });
expect(removed.removal.secretsRevoked).toBeGreaterThan(0);
expect(JSON.stringify(removed)).not.toContain(replacement);
const secrets = await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id));
expect(secrets.every(secret => secret.status !== "active" || secret.deletedAt !== null)).toBe(true);
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === provider.read.name)).toBe(false);
f.request.mockClear();
await expect(f.call(provider.read)).rejects.toThrow();
expect(f.request).not.toHaveBeenCalled();
});
});
it.each(["mcp_remote", "rest_api"] as const)("rejects a transport switch on an active Supabase application before saving credentials (from %s)", async transport => {
const f = await fixture(providers[0]);
const applicationType = transport === "mcp_remote" ? "mcp_http" : "rest_api";
if (transport === "mcp_remote") {
await db.update(toolConnections).set({ transport, config: {
...f.connected.connection.config, url: "https://mcp.supabase.com/mcp", connectionMethodKey: "mcp-api-key",
} }).where(eq(toolConnections.id, f.connected.connectionId));
await db.update(toolApplications).set({ type: applicationType }).where(eq(toolApplications.id, f.connected.application.id));
}
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, f.connected.application.id));
const connectionsBefore = await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id));
const secretsBefore = await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id));
expect(application).toMatchObject({ status: "active", type: applicationType });
expect(connectionsBefore).toHaveLength(1);
expect(connectionsBefore[0]).toMatchObject({ status: "active", transport });
expect(secretsBefore).toHaveLength(1);
f.request.mockClear();
await expect(f.service.connectGalleryApp(f.company.id, {
galleryKey: "supabase", applicationId: application.id,
connectionMethodKey: transport === "mcp_remote" ? "management-api" : "mcp-api-key",
configValues: { projectRef },
credentialValues: { "credentials.authorization": "synthetic-rejected-transport-switch-token" },
}, actor)).rejects.toMatchObject({
status: 409, details: { code: "connection_transport_change_not_supported" },
});
expect(f.request).not.toHaveBeenCalled();
const [applicationAfter] = await db.select().from(toolApplications).where(eq(toolApplications.id, application.id));
expect(applicationAfter).toEqual(application);
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id))).toEqual(connectionsBefore);
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, f.company.id))).toEqual(secretsBefore);
});
it("keeps an archived Supabase MCP identity separate when connecting Management API", async () => {
const provider = providers[0];
const f = await fixture(provider);
await f.service.archiveConnection(f.connected.connectionId, f.company.id, actor);
await db.update(toolConnections).set({ transport: "mcp_remote", config: {
...f.connected.connection.config, url: "https://mcp.supabase.com/mcp", connectionMethodKey: "mcp-api-key",
} }).where(eq(toolConnections.id, f.connected.connectionId));
await db.update(toolApplications).set({ type: "mcp_http" }).where(eq(toolApplications.id, f.connected.application.id));
const input = {
galleryKey: provider.slug, connectionMethodKey: provider.methodKey, configValues: provider.methodConfig,
credentialValues: { "credentials.authorization": f.credential() },
};
f.request.mockClear();
for (const retained of [{ reconnectConnectionId: f.connected.connectionId }, { applicationId: f.connected.application.id }]) {
await expect(f.service.connectGalleryApp(f.company.id, { ...input, ...retained }, actor)).rejects.toMatchObject({
status: 409, details: { code: "connection_transport_change_not_supported" },
});
}
expect(f.request).not.toHaveBeenCalled();
const [retained] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connected.connectionId));
expect(retained).toMatchObject({ status: "archived", enabled: false, transport: "mcp_remote", credentialSecretRefs: [] });
const connected = await f.service.connectGalleryApp(f.company.id, input, actor);
expect(connected.connectionId).not.toBe(f.connected.connectionId);
expect(connected.application.id).not.toBe(f.connected.application.id);
expect(connected.application.type).toBe("rest_api");
expect(connected.connection.transport).toBe("rest_api");
await f.service.finishGalleryAppConnection(f.company.id, connected.connectionId, {
enabledCatalogEntryIds: connected.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
}, actor);
const tools = await f.gateway.listToolsForSession(f.session.token);
const read = tools.find(tool => tool.upstreamToolName === provider.read.name)!;
expect(read).toBeTruthy();
expect(JSON.stringify(await f.gateway.executeTool({ sessionToken: f.session.token, tool: read.name, parameters: {} }))).toContain(projectRef);
});
});
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
@@ -32,10 +32,20 @@ import {
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { toolAccessService } from "../services/tool-access.js";
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function legacySideEffectKey(invocation: typeof toolInvocations.$inferSelect, argumentsHash: string) {
const scope = { companyId: invocation.companyId, runId: invocation.runId, issueId: invocation.issueId,
applicationId: invocation.applicationId, connectionId: invocation.connectionId,
catalogEntryId: invocation.catalogEntryId, toolName: invocation.toolName, argumentsHash };
return `side_effect:${createHash("sha256").update(JSON.stringify(Object.fromEntries(
Object.entries(scope).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
))).digest("hex")}`;
}
async function createCompany(db: ReturnType<typeof createDb>) {
return db.insert(companies).values({
name: `Tool Access ${randomUUID()}`,
@@ -892,11 +902,119 @@ describeEmbeddedPostgres("tool access policy service", () => {
const replay = await toolAccessPolicyService(db).recordInvocation(input, decision);
expect(first.replayed).toBe(false);
expect(first.invocation.idempotencyKey).toMatch(/^side_effect:/);
expect(first.invocation.idempotencyKey).toMatch(/^side_effect:v2:/);
expect(replay.replayed).toBe(true);
expect(replay.invocation.id).toBe(first.invocation.id);
});
it.each([
[{ password: "old-value" }, { password: "new-value" }],
[{ body: `${"x".repeat(500)}old` }, { body: `${"x".repeat(500)}new` }],
[{ items: [...Array.from({ length: 50 }, (_, index) => index), "old"] }, { items: [...Array.from({ length: 50 }, (_, index) => index), "new"] }],
])("blocks unverifiable legacy default-key writes until an explicit new operation is requested (%#)", async (original, replacement) => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { connection } = await createTool(db, company.id);
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: "legacy-identity-fixture-key" });
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
request: { connectionId: connection.id, toolName: "send_email", arguments: original, sideEffecting: true } };
const decision = await svc.decide(input);
const first = await svc.recordInvocation(input, decision);
const oldHash = first.invocation.argumentsSummary!.sha256!;
await db.update(toolInvocations).set({ argumentsHash: oldHash,
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
.where(eq(toolInvocations.id, first.invocation.id));
// A complete new request can equal the old shortened preview verbatim.
// That must not bypass the legacy verifier through the current-key lookup.
const previewArguments = JSON.parse(first.invocation.argumentsSummary!.summary);
for (const args of [original, replacement, previewArguments]) {
await expect(svc.recordInvocation({ ...input, request: { ...input.request, arguments: args } }, decision))
.rejects.toMatchObject({ status: 409, details: {
reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: first.invocation.id,
} });
}
expect(await db.select().from(toolInvocations)).toHaveLength(1);
const explicit = await svc.recordInvocation({ ...input, request: { ...input.request, arguments: replacement,
idempotencyKey: "intentional-new-operation" } }, decision);
expect(explicit.replayed).toBe(false);
expect(explicit.invocation.id).not.toBe(first.invocation.id);
});
it.each(["preview", "complete"])("replays a legacy default-key result when its %s identity proves an exact match", async (identityKind) => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { connection } = await createTool(db, company.id);
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: "legacy-identity-fixture-key" });
// Historical sorting was ordinal; the new canonical serializer uses locale order.
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
request: { connectionId: connection.id, toolName: "send_email", arguments: identityKind === "preview"
? { Z: "last", a: "first" } : { password: "saved-secret" }, sideEffecting: true } };
const decision = await svc.decide(input);
const first = await svc.recordInvocation(input, decision);
expect(first.invocation.argumentsHash).not.toBe(first.invocation.argumentsSummary!.sha256);
const oldHash = identityKind === "preview" ? first.invocation.argumentsSummary!.sha256! : first.invocation.argumentsHash!;
const resultSummary = { summary: "saved result" };
await db.update(toolInvocations).set({ argumentsHash: oldHash, resultSummary,
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
.where(eq(toolInvocations.id, first.invocation.id));
expect(await svc.recordInvocation(input, decision)).toMatchObject({ replayed: true,
invocation: { id: first.invocation.id, resultSummary } });
expect(await db.select().from(toolInvocations)).toHaveLength(1);
});
it("uses authenticated legacy arguments to distinguish an exact retry from a new secret operation", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { connection } = await createTool(db, company.id);
await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent));
const signingSecret = "legacy-identity-fixture-key";
const svc = toolAccessPolicyService(db, { toolActionSigningSecret: signingSecret });
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id },
request: { connectionId: connection.id, toolName: "send_email", arguments: { password: "old-value" }, sideEffecting: true } };
const decision = await svc.decide(input);
const first = await svc.recordInvocation(input, decision);
const oldHash = first.invocation.argumentsSummary!.sha256!;
await db.update(toolInvocations).set({ argumentsHash: oldHash,
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
.where(eq(toolInvocations.id, first.invocation.id));
await db.insert(toolActionRequests).values({ companyId: company.id, invocationId: first.invocation.id,
status: "executed", canonicalArgumentsHash: oldHash, canonicalArgumentsSummary: first.invocation.argumentsSummary!, signedArguments: signToolArguments({
invocationId: first.invocation.id, toolName: "send_email", canonicalArguments: canonicalToolArguments(input.request.arguments), signingSecret,
}) });
expect(await svc.recordInvocation(input, decision)).toMatchObject({ replayed: true, invocation: { id: first.invocation.id } });
const replacement = await svc.recordInvocation({ ...input, request: { ...input.request, arguments: { password: "new-value" } } }, decision);
expect(replacement.replayed).toBe(false);
expect(await db.select().from(toolInvocations)).toHaveLength(2);
});
it("separates no-run default keys by actor and never exposes another actor's legacy result", async () => {
const company = await createCompany(db);
const firstAgent = await createAgent(db, company.id);
const secondAgent = await createAgent(db, company.id);
const { connection } = await createTool(db, company.id);
await db.insert(principalPermissionGrants).values([sendEmailGrantRow(company, firstAgent), sendEmailGrantRow(company, secondAgent)]);
const svc = toolAccessPolicyService(db);
const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: firstAgent.id, agentId: firstAgent.id },
request: { connectionId: connection.id, toolName: "send_email", arguments: { body: "same request" }, sideEffecting: true } };
const secondInput = { ...input, actor: { actorType: "agent" as const, actorId: secondAgent.id, agentId: secondAgent.id } };
const first = await svc.recordInvocation(input, await svc.decide(input));
const second = await svc.recordInvocation(secondInput, await svc.decide(secondInput));
expect(second.replayed).toBe(false);
expect(second.invocation.idempotencyKey).not.toBe(first.invocation.idempotencyKey);
const oldHash = first.invocation.argumentsSummary!.sha256!;
await db.update(toolInvocations).set({ argumentsHash: oldHash, resultSummary: { summary: "first actor's result" },
idempotencyKey: legacySideEffectKey(first.invocation, oldHash), status: "succeeded" })
.where(eq(toolInvocations.id, first.invocation.id));
await db.delete(toolInvocations).where(eq(toolInvocations.id, second.invocation.id));
const error = await svc.recordInvocation(secondInput, await svc.decide(secondInput)).then(() => null, failure => failure);
expect(error).toMatchObject({ status: 409 });
expect(error.details).toEqual({ reasonCode: "legacy_tool_idempotency_unverifiable" });
expect(JSON.stringify(error)).not.toContain(first.invocation.id);
expect(await db.select().from(toolInvocations)).toHaveLength(1);
});
it("enforces rate-limit policies before explicit grants", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -1,6 +1,8 @@
import * as fs from "node:fs/promises";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import request from "supertest";
import {
@@ -3906,7 +3908,7 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it("cancels an ask-first test request when approval signing is unavailable", async () => {
it("cancels an ask-first test request when approval signing is unavailable", async ({ onTestFinished }) => {
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
@@ -3925,6 +3927,12 @@ describeEmbeddedPostgres("tool access service", () => {
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: " " }),
);
const keyDirectory = await fs.mkdtemp(join(tmpdir(), "tool-access-signing-failure-"));
onTestFinished(() => fs.rm(keyDirectory, { recursive: true, force: true }));
const keyFile = join(keyDirectory, "master.key");
await fs.writeFile(keyFile, "corrupt-fixture-master-key", { mode: 0o600 });
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
@@ -5359,6 +5367,7 @@ describeEmbeddedPostgres("tool access service", () => {
"github-code-review-bot",
"youcom",
"enterpret",
"vercel",
"openrouter",
"bedrock",
"responses-api",
@@ -5399,7 +5408,7 @@ describeEmbeddedPostgres("tool access service", () => {
[...APP_STORE_HIDDEN_SLUGS].filter((slug) => gallerySlugs.has(slug)),
).toEqual([]);
expect(
["g2", "vercel", "zomato"].filter((slug) => gallerySlugs.has(slug)),
["g2", "zomato"].filter((slug) => gallerySlugs.has(slug)),
).toEqual([]);
expect(res.body.apps).toEqual(
expect.arrayContaining([
@@ -6652,7 +6661,7 @@ describeEmbeddedPostgres("tool access service", () => {
connectionMethodKey: "mcp-oauth",
name,
configValues: {
projectRef: "firstprojectref12345",
projectRef: "abcdefghijklmnopqrst",
readOnly: true,
},
},
@@ -6666,7 +6675,7 @@ describeEmbeddedPostgres("tool access service", () => {
connectionMethodKey: "mcp-oauth",
name,
configValues: {
projectRef: "secondprojectref1234",
projectRef: "tsrqponmlkjihgfedcba",
readOnly: true,
features: "database",
},
@@ -6680,11 +6689,11 @@ describeEmbeddedPostgres("tool access service", () => {
status: "draft",
enabled: false,
config: {
url: "https://mcp.supabase.com/mcp?project_ref=secondprojectref1234&read_only=true&features=database",
url: "https://mcp.supabase.com/mcp?project_ref=tsrqponmlkjihgfedcba&read_only=true&features=database",
sourceTemplateKey: "supabase",
connectionMethodKey: "mcp-oauth",
methodConfig: {
projectRef: "secondprojectref1234",
projectRef: "tsrqponmlkjihgfedcba",
readOnly: true,
features: "database",
},
@@ -6714,7 +6723,7 @@ describeEmbeddedPostgres("tool access service", () => {
connectionMethodKey: "mcp-oauth",
name: "Supabase for the company",
configValues: {
projectRef: "firstprojectref12345",
projectRef: "abcdefghijklmnopqrst",
readOnly: true,
},
},
@@ -6733,7 +6742,7 @@ describeEmbeddedPostgres("tool access service", () => {
resumeConnectionId: first.connectionId,
name: "Supabase for the company",
configValues: {
projectRef: "secondprojectref1234",
projectRef: "tsrqponmlkjihgfedcba",
readOnly: true,
},
},
@@ -6746,7 +6755,7 @@ describeEmbeddedPostgres("tool access service", () => {
sourceTemplateKey: "supabase",
connectionMethodKey: "mcp-oauth",
methodConfig: {
projectRef: "secondprojectref1234",
projectRef: "tsrqponmlkjihgfedcba",
readOnly: true,
},
});
@@ -12956,7 +12965,7 @@ describeEmbeddedPostgres("tool access service", () => {
connectionMethodKey: "mcp-oauth",
name: "Supabase confidential DCR",
configValues: {
projectRef: "supabaseproject12345",
projectRef: "abcdefghijklmnopqrst",
readOnly: true,
features: "database",
},
@@ -13028,7 +13037,7 @@ describeEmbeddedPostgres("tool access service", () => {
}
if (
href ===
"https://mcp.supabase.com/mcp?project_ref=supabaseproject12345&read_only=true&features=database"
"https://mcp.supabase.com/mcp?project_ref=abcdefghijklmnopqrst&read_only=true&features=database"
) {
expect(init?.headers).toEqual(
expect.objectContaining({
+197 -11
View File
@@ -1,9 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHash, createHmac } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { deriveLocalEncryptedInstanceSecret } from "../secrets/local-encrypted-provider.js";
import {
canonicalToolArguments,
readSignedToolArguments,
readSignedToolArgumentsPayload,
resolveToolActionSigningSecret,
signToolArguments,
summarizeToolValue,
toolRequestIdentityHash,
ToolActionSigningSecretMissingError,
ToolContentValidationError,
validateToolContent,
@@ -12,6 +24,53 @@ import {
describe("tool content guards", () => {
const signingSecret = "test-tool-action-signing-secret";
const temporaryDirectories: string[] = [];
function isolatedKeyFile() {
const directory = mkdtempSync(join(tmpdir(), "tool-signing-key-"));
temporaryDirectories.push(directory);
return join(directory, "master.key");
}
afterEach(() => {
vi.unstubAllEnvs();
for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
it("keeps secret replacements distinct while redacted summaries stay identical", () => {
const first = { key: "SERVICE_PASSWORD", secretValue: "1234" };
const second = { ...first, secretValue: "5678" };
expect(summarizeToolValue(first)).toEqual(summarizeToolValue(second));
const identity = toolRequestIdentityHash(first, signingSecret);
expect(identity).toMatch(/^[a-f0-9]{64}$/);
expect(identity).toBe(toolRequestIdentityHash({ secretValue: "1234", key: "SERVICE_PASSWORD" }, signingSecret));
expect(identity).not.toBe(toolRequestIdentityHash(second, signingSecret));
expect(identity).not.toBe(toolRequestIdentityHash(first, "another-instance-key"));
expect(identity).not.toBe(createHash("sha256").update(canonicalToolArguments(first)).digest("hex"));
expect(identity).not.toBe(createHmac("sha256", signingSecret).update(canonicalToolArguments(first)).digest("hex"));
});
it("includes values beyond audit preview truncation in request identities", () => {
const prefix = "x".repeat(5000);
expect(toolRequestIdentityHash({ body: `${prefix}first` })).not.toBe(toolRequestIdentityHash({ body: `${prefix}second` }));
const entries = Array.from({ length: 50 }, (_, index) => index);
expect(toolRequestIdentityHash([...entries, "first"])).not.toBe(toolRequestIdentityHash([...entries, "second"]));
});
it("uses explicit, configured, or durable derived keys for sensitive identities, never an unkeyed digest", () => {
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", isolatedKeyFile());
try {
const derived = toolRequestIdentityHash({ secretValue: "1234" });
expect(derived).toBe(toolRequestIdentityHash({ secretValue: "1234" }));
expect(derived).not.toBe(createHash("sha256").update(canonicalToolArguments({ secretValue: "1234" })).digest("hex"));
expect(toolRequestIdentityHash([{ password: "1234" }])).toMatch(/^[a-f0-9]{64}$/);
const explicit = toolRequestIdentityHash({ secretValue: "1234" }, signingSecret);
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", signingSecret);
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "invalid-master-key");
expect(toolRequestIdentityHash({ secretValue: "1234" })).toBe(explicit);
expect(toolRequestIdentityHash({ secretValue: "1234" }, "other-key")).not.toBe(explicit);
} finally { vi.unstubAllEnvs(); }
});
it("signs canonical arguments and rejects tampered arguments", () => {
const canonicalArguments = canonicalToolArguments({ body: "hello", noteId: "n1" });
@@ -48,16 +107,143 @@ describe("tool content guards", () => {
})).toEqual({ body: "hello", noteId: "n1" });
});
it("requires a dedicated tool action signing secret", () => {
expect(() =>
resolveToolActionSigningSecret({
PAPERCLIP_AGENT_JWT_SECRET: "agent-jwt-secret",
BETTER_AUTH_SECRET: "auth-secret",
}),
).toThrow(ToolActionSigningSecretMissingError);
expect(() =>
resolveToolActionSigningSecret({}),
).toThrow("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET");
it("derives a separate instance key independently of authentication secrets and honors explicit configuration", () => {
const keyFile = isolatedKeyFile();
const env = { PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile,
PAPERCLIP_AGENT_JWT_SECRET: "agent-jwt-secret", BETTER_AUTH_SECRET: "auth-secret" };
const derived = resolveToolActionSigningSecret(env);
expect(derived).toBe(resolveToolActionSigningSecret({ ...env, PAPERCLIP_AGENT_JWT_SECRET: "rotated-jwt", BETTER_AUTH_SECRET: "rotated-auth" }));
expect(derived).not.toBe(readFileSync(keyFile, "utf8").trim());
expect(derived).not.toBe(deriveLocalEncryptedInstanceSecret("another-purpose", env));
expect(resolveToolActionSigningSecret({ ...env, PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: ` ${signingSecret} `,
PAPERCLIP_SECRETS_MASTER_KEY: "invalid-master-key" })).toBe(signingSecret);
const masterKey = Buffer.alloc(32, 7).toString("base64");
expect(resolveToolActionSigningSecret({ ...env, PAPERCLIP_SECRETS_MASTER_KEY: masterKey })).toBe(
resolveToolActionSigningSecret({ PAPERCLIP_SECRETS_MASTER_KEY: masterKey }));
expect(resolveToolActionSigningSecret({ ...env, PAPERCLIP_SECRETS_MASTER_KEY: masterKey })).not.toBe(derived);
});
it("fails closed without replacing invalid persisted master-key material", () => {
const keyFile = isolatedKeyFile();
writeFileSync(keyFile, "invalid-master-key");
expect(() => resolveToolActionSigningSecret({ PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile }))
.toThrow(ToolActionSigningSecretMissingError);
expect(readFileSync(keyFile, "utf8")).toBe("invalid-master-key");
expect(() => resolveToolActionSigningSecret({ PAPERCLIP_SECRETS_MASTER_KEY: "invalid-master-key" }))
.toThrow(ToolActionSigningSecretMissingError);
});
it("shares a durable derived signing key across competing processes and a later restart", async () => {
const keyFile = isolatedKeyFile();
const modulePath = fileURLToPath(new URL("../services/tool-content-guards.ts", import.meta.url));
const loader = createRequire(import.meta.url).resolve("tsx/esm");
const script = `import {toolRequestIdentityHash, signToolArguments, readSignedToolArguments, canonicalToolArguments} from ${JSON.stringify(modulePath)};
const args = {secretValue:'synthetic-process-proof'};
const input = {invocationId:'fixture',toolName:'fixture',canonicalArguments:canonicalToolArguments(args)};
const signed = process.env.VERIFY_PAYLOAD || signToolArguments(input);
process.stdout.write(JSON.stringify({identity:toolRequestIdentityHash(args),signed,
verified:readSignedToolArguments({...input,signedArguments:signed})?.secretValue === args.secretValue}));`;
const run = (signed?: string) => promisify(execFile)(process.execPath,
["--import", loader, "--input-type=module", "-e", script], {
env: { ...process.env, PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "", PAPERCLIP_SECRETS_MASTER_KEY: "",
PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile, VERIFY_PAYLOAD: signed ?? "" },
}).then(result => JSON.parse(result.stdout) as { identity: string; signed: string; verified: boolean });
const results = await Promise.all(Array.from({ length: 6 }, () => run()));
expect(new Set(results.map(result => result.identity)).size).toBe(1);
expect(results.every(result => result.verified)).toBe(true);
expect(Buffer.from(readFileSync(keyFile, "utf8"), "base64")).toHaveLength(32);
expect(statSync(keyFile).mode & 0o777).toBe(0o600);
const restarted = await run(results[0].signed);
expect(restarted).toEqual({ ...results[0], verified: true });
}, 30_000);
it("encrypts secret-bearing approvals at rest and round trips the complete approval binding", () => {
const args = { projectId: "prj_fixture", key: "API_KEY", secretValue: "fixture-private-environment-value", target: ["preview"] };
const input = {
invocationId: "invocation-sensitive", toolName: "vercel_upsert_environment_variable",
canonicalArguments: canonicalToolArguments(args), approvalSnapshot: { connectionId: "connection-1", revision: 3 },
executionOnApprove: true, identityContextId: "identity-1", signingSecret,
};
const signedArguments = signToolArguments(input);
const decoded = Buffer.from(signedArguments, "base64url").toString("utf8");
const envelope = JSON.parse(decoded);
expect(envelope).toMatchObject({ version: 2, alg: "A256GCM" });
expect(envelope.payload).toBeUndefined();
expect(decoded).not.toContain(args.secretValue);
expect(decoded).not.toContain("canonicalArguments");
expect(Buffer.from(envelope.ciphertext, "base64url").toString("utf8")).not.toContain(args.secretValue);
expect(signToolArguments(input)).not.toBe(signedArguments);
expect(readSignedToolArgumentsPayload({ ...input, signedArguments })).toEqual({
arguments: args, approvalSnapshot: input.approvalSnapshot, executionOnApprove: true, identityContextId: input.identityContextId,
});
expect(verifyToolArgumentsSignature({ ...input, signedArguments })).toBe(true);
expect(verifyToolArgumentsSignature({ ...input, signedArguments, canonicalArguments: canonicalToolArguments({ ...args, secretValue: "tampered" }) })).toBe(false);
expect(verifyToolArgumentsSignature({ ...input, signedArguments, approvalSnapshot: { connectionId: "connection-2", revision: 3 } })).toBe(false);
expect(readSignedToolArgumentsPayload({ ...input, signedArguments, invocationId: "another-invocation" })).toBeNull();
expect(readSignedToolArgumentsPayload({ ...input, signedArguments, toolName: "another-tool" })).toBeNull();
});
it.each([
{ body: { credentials: { apiKey: "nested-private-value" } } },
[{ secretValue: "root-array-private-value" }],
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456",
])("encrypts sensitive nested or root arguments: %j", args => {
const input = { invocationId: "invocation-1", toolName: "fixture", canonicalArguments: canonicalToolArguments(args), signingSecret };
const signedArguments = signToolArguments(input);
expect(JSON.parse(Buffer.from(signedArguments, "base64url").toString()).version).toBe(2);
expect(readSignedToolArguments({ ...input, signedArguments })).toEqual(args);
});
it("rejects the wrong encryption key, including when the environment contains the correct key", () => {
const input = { invocationId: "invocation-1", toolName: "fixture", canonicalArguments: canonicalToolArguments({ secretValue: "private-value" }), signingSecret };
const signedArguments = signToolArguments(input);
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", signingSecret);
try {
expect(readSignedToolArguments({ ...input, signedArguments, signingSecret: "wrong-key" })).toBeNull();
expect(verifyToolArgumentsSignature({ ...input, signedArguments, signingSecret: "wrong-key" })).toBe(false);
expect(readSignedToolArguments({ ...input, signedArguments, signingSecret: undefined })).toEqual({ secretValue: "private-value" });
} finally { vi.unstubAllEnvs(); }
});
it.each(["iv", "ciphertext", "tag", "version", "alg"])("rejects tampering with the encrypted %s field", field => {
const input = { invocationId: "invocation-1", toolName: "fixture", canonicalArguments: canonicalToolArguments({ secretValue: "private-value" }), signingSecret };
const encoded = signToolArguments(input);
const envelope = JSON.parse(Buffer.from(encoded, "base64url").toString());
if (field === "version") envelope.version = 1;
else if (field === "alg") envelope.alg = "HS256";
else {
const bytes = Buffer.from(envelope[field], "base64url");
bytes[0] ^= 1;
envelope[field] = bytes.toString("base64url");
}
const signedArguments = Buffer.from(JSON.stringify(envelope)).toString("base64url");
expect(readSignedToolArguments({ ...input, signedArguments })).toBeNull();
expect(verifyToolArgumentsSignature({ ...input, signedArguments })).toBe(false);
});
it("keeps ordinary new approvals as v1 and verifies historical v1 approvals containing secrets", () => {
const input = { invocationId: "invocation-legacy", toolName: "fixture", canonicalArguments: canonicalToolArguments({ secretValue: "legacy-private-value" }), signingSecret };
// This fixture uses the historical writer contract independently of the
// current signToolArguments, which now encrypts this input.
const payload = canonicalToolArguments({ invocationId: input.invocationId, toolName: input.toolName, canonicalArguments: input.canonicalArguments });
const signature = createHmac("sha256", signingSecret).update(payload).digest("base64url");
const signedArguments = Buffer.from(JSON.stringify({ version: 1, alg: "HS256", payload, signature })).toString("base64url");
expect(readSignedToolArguments({ ...input, signedArguments })).toEqual({ secretValue: "legacy-private-value" });
expect(verifyToolArgumentsSignature({ ...input, signedArguments })).toBe(true);
expect(verifyToolArgumentsSignature({ ...input, signedArguments, signingSecret: "wrong-key" })).toBe(false);
const ordinary = signToolArguments({ ...input, canonicalArguments: canonicalToolArguments({ projectId: "prj_fixture", target: "preview" }) });
expect(JSON.parse(Buffer.from(ordinary, "base64url").toString()).version).toBe(1);
});
it("does not decrypt an explicit-key approval using the durable fallback key", () => {
const input = { invocationId: "invocation-1", toolName: "fixture", canonicalArguments: canonicalToolArguments({ secretValue: "private-value" }), signingSecret };
const signedArguments = signToolArguments(input);
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", isolatedKeyFile());
try {
expect(readSignedToolArguments({ ...input, signedArguments, signingSecret: undefined })).toBeNull();
} finally { vi.unstubAllEnvs(); }
});
it("redacts sensitive argument values before summarizing them", async () => {
@@ -1,6 +1,9 @@
import { subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
@@ -915,12 +918,18 @@ describeEmbeddedPostgres("tool gateway service", () => {
expect(settledInvocation.idempotencyKey).not.toBeNull();
});
it("does not leave unsigned action requests pending when signing is unavailable", async () => {
it("does not leave unsigned action requests pending when signing is unavailable", async ({ onTestFinished }) => {
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
const { company, agent, run } = await createRunFixture(db);
await db.insert(toolPolicies).values(reviewNotePolicyRow(company));
const gateway = createTestToolGatewayService(db, { toolActionSigningSecret: " " });
const session = await gateway.createSession(runSessionInput(company, agent, run));
const keyDirectory = await mkdtemp(join(tmpdir(), "tool-gateway-signing-failure-"));
onTestFinished(() => rm(keyDirectory, { recursive: true, force: true }));
const keyFile = join(keyDirectory, "master.key");
await writeFile(keyFile, "corrupt-fixture-master-key", { mode: 0o600 });
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile);
await expect(gateway.executeTool({
sessionToken: session.token,
+48
View File
@@ -661,6 +661,54 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
}
});
it("returns a JSON-RPC reconciliation error instead of repeating an unverifiable legacy write", async () => {
const company = await createCompany(db);
const remote = await startFakeRemoteMcpServer(({ body }) => ({
body: { jsonrpc: "2.0", id: body?.id, result: { content: [{ type: "text", text: "saved" }] } },
}));
try {
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, { url: remote.url });
const toolName = expectedConnectedToolName({ applicationKey: application.applicationKey,
connectionId: connection.id, toolName: catalogEntry.toolName });
const [profile] = await db.insert(toolProfiles).values({ companyId: company.id,
profileKey: `legacy-${randomUUID()}`, name: "Legacy write test", defaultAction: "deny" }).returning();
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id,
selectorType: "tool_name", effect: "include", toolName });
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow fixture writes",
policyType: "allow", selectors: { toolName } });
const gateway = createTestToolGatewayService(db);
const named = await gateway.createNamedGateway({ companyId: company.id,
body: { name: "Legacy write test", profileId: profile.id } });
const token = await gateway.createNamedGatewayToken({ companyId: company.id,
gatewayId: named.id, body: { name: "Test" } });
const app = createGatewayRouteApp(db, gateway);
const parameters = { key: "large-note", value: `${"x".repeat(500)}original` };
await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
.send({ jsonrpc: "2.0", id: "initial-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(200);
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id));
expect(invocation.status).toBe("succeeded");
const oldHash = invocation.argumentsSummary!.sha256!;
const scope = { companyId: invocation.companyId, runId: invocation.runId, issueId: invocation.issueId,
applicationId: invocation.applicationId, connectionId: invocation.connectionId,
catalogEntryId: invocation.catalogEntryId, toolName: invocation.toolName, argumentsHash: oldHash };
const oldKey = `side_effect:${createHash("sha256").update(JSON.stringify(Object.fromEntries(
Object.entries(scope).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
))).digest("hex")}`;
await db.update(toolInvocations).set({ argumentsHash: oldHash, idempotencyKey: oldKey })
.where(eq(toolInvocations.id, invocation.id));
const requestCount = remote.requests.length;
const replay = await request(app).post(named.endpointPath).set("authorization", `Bearer ${token.token}`)
.send({ jsonrpc: "2.0", id: "legacy-write", method: "tools/call", params: { name: toolName, arguments: parameters } }).expect(409);
expect(replay.body).toMatchObject({ jsonrpc: "2.0", id: "legacy-write", error: {
code: -32000, data: { reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: invocation.id },
} });
expect(replay.body.error.message).toContain("Reconcile");
expect(remote.requests).toHaveLength(requestCount);
} finally {
await remote.close();
}
});
it("exposes a named gateway with scoped bearer-token auth and revocation", async () => {
const company = await createCompany(db);
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
+17 -7
View File
@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
@@ -19,8 +19,12 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
ciphertext: string;
}
function resolveMasterKeyFilePath() {
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
export type LocalEncryptedKeyEnv = Partial<Record<
"PAPERCLIP_SECRETS_MASTER_KEY" | "PAPERCLIP_SECRETS_MASTER_KEY_FILE", string | undefined
>>;
function resolveMasterKeyFilePath(env: LocalEncryptedKeyEnv = process.env) {
const fromEnv = env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
return resolveDefaultSecretsKeyFilePath();
}
@@ -46,8 +50,8 @@ function decodeMasterKey(raw: string): Buffer | null {
return null;
}
function loadOrCreateMasterKey(allowCreate = true): Buffer {
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
function loadOrCreateMasterKey(allowCreate = true, env: LocalEncryptedKeyEnv = process.env): Buffer {
const envKeyRaw = env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKeyRaw && envKeyRaw.trim().length > 0) {
const fromEnv = decodeMasterKey(envKeyRaw);
if (!fromEnv) {
@@ -58,7 +62,7 @@ function loadOrCreateMasterKey(allowCreate = true): Buffer {
return fromEnv;
}
const keyPath = resolveMasterKeyFilePath();
const keyPath = resolveMasterKeyFilePath(env);
if (existsSync(keyPath)) {
enforceKeyFilePermissionsBestEffort(keyPath);
const raw = readFileSync(keyPath, "utf8");
@@ -86,7 +90,13 @@ function loadOrCreateMasterKey(allowCreate = true): Buffer {
} finally {
unlinkSync(temporaryPath);
}
return loadOrCreateMasterKey(false);
return loadOrCreateMasterKey(false, env);
}
/** Derive a purpose-specific instance secret without exposing or reusing the vault key. */
export function deriveLocalEncryptedInstanceSecret(purpose: string, env: LocalEncryptedKeyEnv = process.env): string {
return Buffer.from(hkdfSync("sha256", loadOrCreateMasterKey(true, env), Buffer.alloc(0),
`paperclip:instance-secret:v1\0${purpose}`, 32)).toString("base64url");
}
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
@@ -511,6 +511,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
)
throw conflict(
"This publication key was already used for different content",
{ reasonCode: "github_idempotency_conflict" },
);
return existing.id;
}
@@ -842,6 +843,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
)
throw conflict(
"This run already submitted its assessment. Start another review to revise it.",
{ reasonCode: "github_idempotency_conflict" },
);
await tx
.update(chatGitHubReviews)
+2 -1
View File
@@ -58,6 +58,7 @@ import { lockToolConnectionRuntimeInputs, toolAccessService } from "./tool-acces
import { toolAccessPolicyService } from "./tool-access-policy.js";
import { captureRunIdentity } from "./run-identity.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
import { restConnectorFor } from "./connectors/rest-registry.js";
type ConnectionRunClaims = Pick<RuntimeToolsTokenClaims, "sub" | "company_id" | "run_id" | "responsible_user_id">;
@@ -296,7 +297,7 @@ export function connectionIntentService(db: Db) {
const usable = (connection: ToolConnection | undefined) => connection
&& installedIds.has(connection.id) && permittedIds.has(connection.id)
&& connection.status === "active" && connection.enabled
&& ["mcp_remote", "local_stdio"].includes(connection.transport)
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || Boolean(restConnectorFor(connection)))
&& !isToolConnectionAttentionHealth(connection.healthStatus) ? connection : null;
if (input.serviceSlug === "github") {
const selection = await resolveManagedGitHubIdentitySelection(db, input.companyId, {
@@ -0,0 +1,47 @@
import type { ToolRiskLevel } from "@paperclipai/shared";
import { SUPABASE_TOOLS, isSupabaseConnection, isCurrentSupabaseCatalogEntry, supabaseClient, supabaseRisk } from "./supabase/index.js";
import { VERCEL_TOOLS, isVercelConnection, isCurrentVercelCatalogEntry, vercelClient, vercelRisk } from "./vercel/index.js";
type Connection = { transport: string; config?: Record<string, unknown> };
type CatalogEntry = { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string };
type Request = (url: string, init: RequestInit) => Promise<Response>;
interface RestConnector {
sourceTemplateKey: string;
name: string;
matches(connection: Connection): boolean;
tools: Array<{ name: string; title?: string | null; description?: string | null; inputSchema?: Record<string, unknown>; annotations?: Record<string, unknown> }>;
risk(name: string): ToolRiskLevel;
isCurrent(entry: CatalogEntry): boolean;
client(connection: Connection, headers: Record<string, string>, request: Request, signal?: AbortSignal, deadlineAt?: number): {
probe(): Promise<unknown>;
invoke(name: string, parameters: unknown): Promise<unknown>;
};
}
function settings(connection: Connection): Record<string, unknown> {
const value = connection.config?.methodConfig;
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
// Only reviewed adapters enter the REST gateway. Provider code owns endpoints,
// schemas and payloads; the existing gateway owns grants, policy and audit.
export const REST_CONNECTORS: readonly RestConnector[] = [
{
sourceTemplateKey: "supabase", name: "Supabase",
matches: isSupabaseConnection, tools: SUPABASE_TOOLS,
risk: supabaseRisk, isCurrent: isCurrentSupabaseCatalogEntry,
client: (connection, headers, request, signal, deadlineAt) =>
supabaseClient(headers, request, settings(connection).projectRef, signal, deadlineAt),
},
{
sourceTemplateKey: "vercel", name: "Vercel",
matches: isVercelConnection, tools: VERCEL_TOOLS,
risk: vercelRisk, isCurrent: isCurrentVercelCatalogEntry,
client: (connection, headers, request, signal, deadlineAt) =>
vercelClient(headers, request, settings(connection).teamId, signal, deadlineAt),
},
];
export function restConnectorFor(connection: Connection): RestConnector | undefined {
return REST_CONNECTORS.find(connector => connector.matches(connection));
}
@@ -0,0 +1,274 @@
import { describe, expect, it, vi } from "vitest";
import { appDefinitionSchema } from "@paperclipai/shared";
import { HttpError } from "../../../errors.js";
import sourceDefinition from "../../../../../scripts/app-definition-overrides/supabase.json" with { type: "json" };
import { descriptorHash, stableHash } from "../../tool-catalog-hash.js";
import { validateToolContent } from "../../tool-content-guards.js";
import { SUPABASE_API_URL, SUPABASE_METHOD, SUPABASE_TOOLS, isCurrentSupabaseCatalogEntry,
isSupabaseConnection, supabaseClient, supabaseRisk } from "./index.js";
const projectRef = "abcdefghijklmnopqrst";
const definition = appDefinitionSchema.parse(sourceDefinition);
const token = "sbp_supabase-canary-never-log";
const headers = { Authorization: `Bearer ${token}` };
const deployment = { slug: "hello", entrypoint_path: "index.ts", files: [{ name: "index.ts", content: "Deno.serve(() => new Response('hello'))" }] };
async function failure(promise: Promise<unknown>): Promise<HttpError> {
try { await promise; }
catch (error) { if (error instanceof HttpError) return error; throw error; }
throw new Error("Expected a failed provider request");
}
describe("Supabase deployment and configuration", () => {
it("keeps account MCP setup optional and requires a native deployment project", () => {
expect(appDefinitionSchema.safeParse(definition).success).toBe(true);
for (const method of definition.methods.filter(method => method.transport === "mcp_remote")) {
expect(method.tenantFields?.find(field => field.key === "projectRef")).toMatchObject({ advanced: true });
expect(method.tenantFields?.find(field => field.key === "projectRef")?.required).not.toBe(true);
expect(method.tenantFields?.find(field => field.key === "readOnly")?.defaultValue).toBe(false);
}
expect(definition.methods.find(method => method.key === SUPABASE_METHOD)).toMatchObject({
transport: "rest_api", auth: "api_key", defaults: { serverUrl: SUPABASE_API_URL },
tenantFields: [expect.objectContaining({ key: "projectRef", required: true })],
});
});
it("requires the reviewed REST endpoint and rejects stale risk or schema contracts", () => {
const connection = { transport: "rest_api", config: { sourceTemplateKey: "supabase", connectionMethodKey: SUPABASE_METHOD, url: SUPABASE_API_URL } };
expect(isSupabaseConnection(connection)).toBe(true);
expect(isSupabaseConnection({ ...connection, config: { ...connection.config, url: "https://attacker.example" } })).toBe(false);
for (const tool of SUPABASE_TOOLS) {
const entry = { toolName: tool.name, schemaHash: stableHash(tool.inputSchema), versionHash: descriptorHash(tool, supabaseRisk(tool.name)), riskLevel: supabaseRisk(tool.name) };
expect(isCurrentSupabaseCatalogEntry(entry)).toBe(true);
expect(isCurrentSupabaseCatalogEntry({ ...entry, schemaHash: "stale" })).toBe(false);
expect(isCurrentSupabaseCatalogEntry({ ...entry, riskLevel: "unknown" })).toBe(false);
}
expect(supabaseRisk("supabase_deploy_edge_function")).toBe("write");
expect(supabaseRisk("supabase_delete_edge_function")).toBe("destructive");
expect(supabaseRisk("unknown_action")).toBe("destructive");
expect(SUPABASE_TOOLS.find(tool => tool.name === "supabase_deploy_edge_function")?.inputSchema.required).not.toContain("verify_jwt");
});
it("pins every request to the saved project and sends only selected credential headers", async () => {
const request = vi.fn(async (url: string, init: RequestInit) => {
expect(url).toBe(`${SUPABASE_API_URL}/projects/${projectRef}`);
expect(init.redirect).toBe("error");
expect(new Headers(init.headers).get("Authorization")).toBe(headers.Authorization);
expect(new Headers(init.headers).get("X-Unrelated-Secret")).toBeNull();
return Response.json({ ref: projectRef, name: "Fixture", database: { password: "hidden" }, secret: token });
});
const client = supabaseClient({ ...headers, "X-Unrelated-Secret": "not-forwarded" }, request, projectRef);
expect(await client.probe()).toEqual({ ref: projectRef, name: "Fixture" });
await expect(client.invoke("supabase_get_project", { projectRef: "different-project" })).rejects.toMatchObject({ status: 422 });
expect(request).toHaveBeenCalledTimes(1);
expect(() => supabaseClient(headers, request, "../foreign")).toThrow("project reference");
});
it("deploys in-memory source files with matching multipart metadata", async () => {
const request = vi.fn(async (url: string, init: RequestInit) => {
expect(url).toBe(`${SUPABASE_API_URL}/projects/${projectRef}/functions/deploy?slug=hello`);
expect(init.method).toBe("POST");
expect(Buffer.isBuffer(init.body)).toBe(true);
const form = await new Response(init.body, { headers: init.headers }).formData();
expect(JSON.parse(String(form.get("metadata")))).toEqual({ name: "hello", entrypoint_path: "index.ts", verify_jwt: true });
const files = form.getAll("file") as File[];
expect(files.map(file => file.name)).toEqual(["index.ts"]);
expect(await files[0].text()).toBe(deployment.files[0].content);
return Response.json({ slug: "hello", status: "ACTIVE", version: 1, verify_jwt: true, credentials: { token } });
});
expect(await supabaseClient(headers, request, projectRef).invoke("supabase_deploy_edge_function", deployment))
.toEqual({ slug: "hello", status: "ACTIVE", version: 1, verify_jwt: true });
expect(request).toHaveBeenCalledTimes(1);
});
it("preserves an explicit function authentication choice and import map", async () => {
const request = vi.fn(async (_url: string, init: RequestInit) => {
const form = await new Response(init.body, { headers: init.headers }).formData();
expect(JSON.parse(String(form.get("metadata")))).toMatchObject({ verify_jwt: false, import_map_path: "deno.json" });
expect(form.getAll("file")).toHaveLength(2);
return Response.json({ slug: "hello", verify_jwt: false });
});
await supabaseClient(headers, request, projectRef).invoke("supabase_deploy_edge_function", {
...deployment, verify_jwt: false, import_map_path: "deno.json", files: [...deployment.files, { name: "deno.json", content: "{}" }],
});
});
it.each([
{ ...deployment, files: [{ name: "../index.ts", content: "bad" }] },
{ ...deployment, entrypoint_path: "missing.ts" },
{ ...deployment, import_map_path: "missing.json" },
{ ...deployment, files: [...deployment.files, ...deployment.files] },
{ ...deployment, slug: "../other-project" },
{ ...deployment, files: [{ name: "/etc/passwd", content: "bad" }] },
])("rejects invalid source/path inputs before network access", async args => {
const request = vi.fn();
await expect(supabaseClient(headers, request, projectRef).invoke("supabase_deploy_edge_function", args)).rejects.toMatchObject({ status: 422 });
expect(request).not.toHaveBeenCalled();
});
it("projects reviewed Auth/Data API fields and strips provider secrets from read and write results", async () => {
const request = vi.fn(async (url: string, init: RequestInit) => {
if (url.endsWith("/config/auth")) {
if (init.method === "PATCH") expect(JSON.parse(init.body as string)).toEqual({ site_url: "https://fixture.example", uri_allow_list: "https://fixture.example/**" });
return Response.json({ site_url: "https://fixture.example", smtp_pass: "smtp-canary", external_github_secret: "oauth-canary", jwt_secret: "jwt-canary", uri_allow_list: token });
}
expect(url).toBe(`${SUPABASE_API_URL}/projects/${projectRef}/postgrest`);
if (init.method === "PATCH") expect(JSON.parse(init.body as string)).toEqual({ db_schema: "public", max_rows: 1000 });
return Response.json({ db_schema: "public", max_rows: 1000, jwt_secret: "jwt-canary" });
});
const client = supabaseClient(headers, request, projectRef);
for (const [name, args] of [
["supabase_get_auth_config", {}],
["supabase_update_auth_config", { config: { site_url: "https://fixture.example", uri_allow_list: "https://fixture.example/**" } }],
] as const) expect(await client.invoke(name, args)).toEqual({ site_url: "https://fixture.example", uri_allow_list: "[REDACTED]" });
for (const [name, args] of [
["supabase_get_api_config", {}], ["supabase_update_api_config", { config: { db_schema: "public", max_rows: 1000 } }],
] as const) expect(await client.invoke(name, args)).toEqual({ db_schema: "public", max_rows: 1000 });
await expect(client.invoke("supabase_update_auth_config", { config: { smtp_pass: "must-not-send" } })).rejects.toMatchObject({ status: 422 });
await expect(client.invoke("supabase_update_auth_config", { config: {} })).rejects.toMatchObject({ status: 422 });
await expect(client.invoke("supabase_update_api_config", { config: { jwt_secret: "must-not-send" } })).rejects.toMatchObject({ status: 422 });
expect(request).toHaveBeenCalledTimes(4);
});
it("lists, inspects and deletes functions without leaking unreviewed provider fields", async () => {
const request = vi.fn(async (url: string, init: RequestInit) => init.method === "DELETE" ? new Response(null, { status: 204 })
: Response.json(url.endsWith("/functions") ? [{ slug: "hello", status: "ACTIVE", access_token: token }] : { slug: "hello", status: "ACTIVE", environment: { SECRET: "hidden" } }));
const client = supabaseClient(headers, request, projectRef);
expect(await client.invoke("supabase_list_edge_functions", {})).toEqual([{ slug: "hello", status: "ACTIVE" }]);
expect(await client.invoke("supabase_get_edge_function", { slug: "hello" })).toEqual({ slug: "hello", status: "ACTIVE" });
expect(await client.invoke("supabase_delete_edge_function", { slug: "hello" })).toEqual({ deleted: true, slug: "hello" });
expect(request.mock.calls[2][0]).toBe(`${SUPABASE_API_URL}/projects/${projectRef}/functions/hello`);
});
it("configures function secrets, omits values from inventory, and redacts argument summaries", async () => {
const secretValue = "application-secret-canary";
const parameters = { name: "APP_KEY", secretValue };
const guarded = await validateToolContent({ value: parameters, direction: "arguments", sensitiveMode: "redact", promptInjectionMode: "ignore" });
expect(JSON.stringify(guarded)).not.toContain(secretValue);
expect(guarded.value).toMatchObject({ name: "APP_KEY", secretValue: expect.any(String) });
const request = vi.fn(async (url: string, init: RequestInit) => {
expect(url).toBe(`${SUPABASE_API_URL}/projects/${projectRef}/secrets`);
if (init.method === "POST") {
expect(JSON.parse(init.body as string)).toEqual([{ name: "APP_KEY", value: secretValue }]);
return Response.json({ name: secretValue, value: secretValue, [token]: secretValue });
}
return Response.json([{ name: "APP_KEY", updated_at: "2026-10-22T00:00:00Z", value: secretValue, digest: secretValue, unexpected: token }]);
});
const client = supabaseClient(headers, request, projectRef);
expect(await client.invoke("supabase_upsert_edge_function_secret", parameters)).toEqual({ configured: true, name: "APP_KEY" });
// An explicit second invocation dispatches once again; the client performs
// no hidden automatic retry, reads, or extra verification requests.
expect(await client.invoke("supabase_upsert_edge_function_secret", parameters)).toEqual({ configured: true, name: "APP_KEY" });
expect(await client.invoke("supabase_list_edge_function_secrets", {})).toEqual([{ name: "APP_KEY", updated_at: "2026-10-22T00:00:00Z" }]);
expect(request).toHaveBeenCalledTimes(3);
await expect(client.invoke("supabase_upsert_edge_function_secret", { name: "APP_KEY", secretValue: "x".repeat(65537) })).rejects.toMatchObject({ status: 422 });
await expect(client.invoke("supabase_upsert_edge_function_secret", { ...parameters, projectRef: "other" })).rejects.toMatchObject({ status: 422 });
expect(request).toHaveBeenCalledTimes(3);
});
it("does not return or retry a provider error containing an application secret", async () => {
const secretValue = "application-secret-canary";
const request = vi.fn(async () => new Response(secretValue, { status: 400 }));
const result = await failure(supabaseClient(headers, request, projectRef).invoke("supabase_upsert_edge_function_secret", { name: "APP_KEY", secretValue }));
expect(result.status).toBe(400);
expect(JSON.stringify(result)).not.toContain(secretValue);
expect(result.message).not.toContain(secretValue);
expect(request).toHaveBeenCalledTimes(1);
});
it.each([400, 401, 402, 403, 404, 409, 422, 429, 500, 302])("does not echo provider error bodies or follow redirects (%i)", async status => {
const request = vi.fn(async () => new Response(`upstream ${token}`, { status, headers: { Location: "https://attacker.example" } }));
const result = await failure(supabaseClient(headers, request, projectRef).probe());
expect(result.status).toBe(status >= 500 || status === 302 ? 502 : status);
expect(result.message).not.toContain(token);
expect(JSON.stringify(result.details)).not.toContain(token);
expect(request).toHaveBeenCalledTimes(1);
});
it("does not retry an ambiguous deployment", async () => {
const request = vi.fn(async () => { throw new Error(`transport ${token}`); });
await expect(supabaseClient(headers, request, projectRef).invoke("supabase_deploy_edge_function", deployment))
.rejects.toMatchObject({ status: 502, details: { code: "supabase_outcome_unknown" } });
expect(request).toHaveBeenCalledTimes(1);
});
it.each(["transport", "body"])("does not expose an untrusted HttpError from the %s", async stage => {
const untrusted = new HttpError(504, `provider error ${token}`, { code: "supabase_deadline_exceeded", token });
const request = vi.fn(async () => {
if (stage === "transport") throw untrusted;
return new Response(new ReadableStream({ pull(controller) { controller.error(untrusted); } }));
});
const result = await failure(supabaseClient(headers, request, projectRef).probe());
expect(result).toMatchObject({ status: 502, details: { code: "supabase_outcome_unknown" } });
expect(result).not.toBe(untrusted);
expect(result.message).not.toContain(token);
expect(JSON.stringify(result.details)).not.toContain(token);
expect(request).toHaveBeenCalledTimes(1);
});
it("treats a provider server error after an applied configuration write as uncertain", async () => {
let maxRows = 901;
const request = vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PATCH") {
maxRows = JSON.parse(String(init.body)).max_rows;
return new Response(`restart failed ${token}`, { status: 500 });
}
return Response.json({ max_rows: maxRows });
});
const client = supabaseClient(headers, request, projectRef);
const result = await failure(client.invoke("supabase_update_api_config", { config: { max_rows: 1000 } }));
expect(result).toMatchObject({ status: 502, details: { code: "supabase_outcome_unknown" } });
expect(result.message).toContain("inspect its state before retrying");
expect(result.message).not.toContain(token);
expect(request).toHaveBeenCalledTimes(1);
expect(await client.invoke("supabase_get_api_config", {})).toEqual({ max_rows: 1000 });
expect(request.mock.calls.filter(([, init]) => init.method === "PATCH")).toHaveLength(1);
});
it("bounds a stalled body reader without blocking an unrelated request", async () => {
const controller = new AbortController();
const slow = supabaseClient(headers, async () => new Response(new ReadableStream({ pull: () => new Promise(() => {}) })), projectRef, controller.signal, Date.now() + 20);
const pending = slow.probe();
expect(await supabaseClient(headers, async () => Response.json({ ref: projectRef }), projectRef).probe()).toEqual({ ref: projectRef });
await expect(pending).rejects.toMatchObject({ status: 504 });
});
it("bounds a transport that ignores cancellation and rejects already-aborted dispatch", async () => {
const controller = new AbortController();
const request = vi.fn(() => new Promise<Response>(() => {}));
const client = supabaseClient(headers, request, projectRef, controller.signal, Date.now() + 20);
await expect(client.probe()).rejects.toMatchObject({ status: 504 });
expect(request).toHaveBeenCalledTimes(1);
controller.abort();
await expect(client.invoke("supabase_delete_edge_function", { slug: "hello" })).rejects.toMatchObject({ status: 504 });
expect(request).toHaveBeenCalledTimes(1);
});
it("preserves a fired local deadline when wall-clock time is still before its threshold", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
try {
const controller = new AbortController();
const request = vi.fn(() => new Promise<Response>(() => {}));
const client = supabaseClient(headers, request, projectRef, controller.signal, 1_020);
const pending = expect(client.probe()).rejects.toMatchObject({
status: 504, details: { code: "supabase_deadline_exceeded" },
});
await vi.advanceTimersByTimeAsync(20);
await pending;
expect(Date.now()).toBeLessThan(1_020);
expect(controller.signal.aborted).toBe(false);
expect(request).toHaveBeenCalledTimes(1);
} finally {
now.mockRestore();
vi.useRealTimers();
}
});
it("rejects oversized output and invalid JSON without exposing response bytes", async () => {
for (const response of [() => new Response("x".repeat(2 * 1024 * 1024 + 1)), () => new Response(token)]) {
const result = await failure(supabaseClient(headers, async () => response(), projectRef).probe());
expect(result.status).toBe(502);
expect(result.message).not.toContain(token);
}
});
});
@@ -0,0 +1,234 @@
import { z } from "zod";
import { HttpError } from "../../../errors.js";
import { redactSensitiveText } from "../../../redaction.js";
import { descriptorHash, stableHash } from "../../tool-catalog-hash.js";
export const SUPABASE_API_URL = "https://api.supabase.com/v1";
export const SUPABASE_METHOD = "management-api";
export type SupabaseRequest = (url: string, init: RequestInit) => Promise<Response>;
const MAX_BYTES = 2 * 1024 * 1024;
const projectRefSchema = z.string().regex(/^[a-z]{20}$/);
const slugSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/);
// Relative, multipart-safe source names only. Nothing is read from the host.
const sourcePathSchema = z.string().max(200).regex(/^[A-Za-z0-9_-]+(?:[./][A-Za-z0-9_-]+)*$/);
const emptySchema = z.object({}).strict();
const functionSchema = z.object({ slug: slugSchema }).strict();
const secretSchema = z.object({
name: z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/),
secretValue: z.string().min(1).max(65536).describe("The new secret value. This input is redacted from gateway summaries and never returned."),
}).strict();
const authConfigSchema = z.object({
site_url: z.url().max(2048).optional(),
uri_allow_list: z.string().max(10000).optional(),
disable_signup: z.boolean().optional(),
external_email_enabled: z.boolean().optional(),
mailer_autoconfirm: z.boolean().optional(),
jwt_exp: z.number().int().min(300).max(604800).optional(),
password_min_length: z.number().int().min(6).max(128).optional(),
}).strict();
const apiConfigSchema = z.object({
db_schema: z.string().min(1).max(2048).optional(),
db_extra_search_path: z.string().max(2048).optional(),
max_rows: z.number().int().min(1).max(1000000).optional(),
db_pool: z.number().int().min(1).max(1000).optional(),
db_pool_acquisition_timeout: z.number().int().min(1).max(600).optional(),
}).strict();
const deploySchema = z.object({
slug: slugSchema,
name: z.string().min(1).max(100).optional(),
entrypoint_path: sourcePathSchema,
import_map_path: sourcePathSchema.optional(),
verify_jwt: z.boolean().default(true),
files: z.array(z.object({ name: sourcePathSchema, content: z.string().max(MAX_BYTES) }).strict()).min(1).max(50),
}).strict();
type Risk = "read" | "write" | "destructive";
const operations = [
{ name: "supabase_get_project", title: "Get project", description: "Read details of the connection's Supabase project.", risk: "read", schema: emptySchema },
{ name: "supabase_get_auth_config", title: "Get Auth configuration", description: "Read site URL, redirect allow list, signup and password settings. Provider secrets are omitted.", risk: "read", schema: emptySchema },
{ name: "supabase_update_auth_config", title: "Update Auth configuration", description: "Change site URL, redirects, signup and password settings. Disabling confirmation or signup affects application access. Configure SMTP and OAuth secrets in the Supabase Dashboard.", risk: "write", schema: z.object({ config: authConfigSchema }).strict() },
{ name: "supabase_get_api_config", title: "Get Data API configuration", description: "Read exposed Postgres schemas, search path, row and connection limits. Signing secrets are omitted.", risk: "read", schema: emptySchema },
{ name: "supabase_update_api_config", title: "Update Data API configuration", description: "Change exposed Postgres schemas, search path, row and connection limits. Schema exposure remains subject to database permissions and RLS.", risk: "write", schema: z.object({ config: apiConfigSchema }).strict() },
{ name: "supabase_list_edge_functions", title: "List Edge Functions", description: "List deployed Edge Functions in the connection's project.", risk: "read", schema: emptySchema },
{ name: "supabase_get_edge_function", title: "Get Edge Function", description: "Read deployment status and configuration for an Edge Function.", risk: "read", schema: functionSchema },
{ name: "supabase_deploy_edge_function", title: "Deploy Edge Function", description: "Create or replace an Edge Function from supplied UTF-8 source files. Include the entrypoint and optional import map among files. JWT verification defaults on. This replaces the existing function with the same slug; inspect its state before retrying an uncertain result.", risk: "write", schema: deploySchema },
{ name: "supabase_delete_edge_function", title: "Delete Edge Function", description: "Permanently remove the named Edge Function from the connection's project.", risk: "destructive", schema: functionSchema },
{ name: "supabase_list_edge_function_secrets", title: "List Edge Function secret names", description: "List configured Edge Function secret names and update times. Secret values are never returned.", risk: "read", schema: emptySchema },
{ name: "supabase_upsert_edge_function_secret", title: "Set Edge Function secret", description: "Create or replace one Edge Function environment secret. Existing functions use the new value. The value is never returned. Use the Supabase Dashboard when a human must enter a credential privately.", risk: "write", schema: secretSchema },
] satisfies Array<{ name: string; title: string; description: string; risk: Risk; schema: z.ZodType }>;
export const SUPABASE_TOOLS = operations.map(({ name, title, description, risk, schema }) => ({
name, title, description, inputSchema: z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>,
annotations: { readOnlyHint: risk === "read", destructiveHint: risk === "destructive", idempotentHint: risk === "read", openWorldHint: true },
}));
export function supabaseRisk(name: string): Risk {
return operations.find(operation => operation.name === name)?.risk ?? "destructive";
}
export function isSupabaseConnection(connection: { transport: string; config?: Record<string, unknown> }) {
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "supabase"
&& connection.config.connectionMethodKey === SUPABASE_METHOD && connection.config.url === SUPABASE_API_URL;
}
const contracts = new Map(SUPABASE_TOOLS.map(tool => [tool.name, {
versionHash: descriptorHash(tool, supabaseRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: supabaseRisk(tool.name),
}]));
export function isCurrentSupabaseCatalogEntry(entry: { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }) {
const current = contracts.get(entry.toolName);
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
}
const fail = (status: number, code: string, message: string) => new HttpError(status, message, { code });
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const projectFields = ["id", "ref", "name", "organization_id", "organization_slug", "region", "status", "created_at"];
const functionFields = ["id", "slug", "name", "status", "version", "created_at", "updated_at", "verify_jwt", "import_map", "entrypoint_path", "import_map_path"];
/** The governed transport supplies a selected grant's headers and its guarded
* HTTP request function. This provider never resolves credentials or host files. */
export function supabaseClient(headers: Record<string, string>, request: SupabaseRequest, projectRef: unknown,
signal: AbortSignal = AbortSignal.timeout(30_000), deadlineAt: number = Date.now() + 30_000) {
if (!projectRefSchema.safeParse(projectRef).success) throw fail(422, "supabase_project_invalid", "Reconnect Supabase with a valid project reference.");
const authorization = new Headers(headers).get("Authorization");
if (!authorization || !/^Bearer \S+$/.test(authorization)) throw fail(422, "supabase_credential_missing", "Reconnect Supabase with a personal access token.");
const secret = authorization.slice(7);
const base = `${SUPABASE_API_URL}/projects/${projectRef}`;
const deadlineError = fail(504, "supabase_deadline_exceeded", "The Supabase action deadline expired. Inspect the provider state before retrying a write.");
const outputLimitError = fail(502, "supabase_output_limit", "Supabase returned an oversized response.");
const expired = () => deadlineError;
async function bounded<T>(work: () => Promise<T>): Promise<T> {
const remaining = deadlineAt - Date.now();
if (signal.aborted || remaining <= 0) throw expired();
let timer: ReturnType<typeof setTimeout> | undefined;
let abort: () => void = () => {};
try {
return await Promise.race([
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }),
new Promise<never>((_resolve, reject) => {
abort = () => reject(expired());
signal.addEventListener("abort", abort, { once: true });
timer = setTimeout(abort, remaining);
}),
]);
} finally { if (timer) clearTimeout(timer); signal.removeEventListener("abort", abort); }
}
function sanitize(value: unknown): unknown {
if (typeof value === "string") return redactSensitiveText(value.split(secret).join("[REDACTED]"));
if (Array.isArray(value)) return value.map(sanitize);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [redactSensitiveText(key.split(secret).join("[REDACTED]")), sanitize(item)]));
return value;
}
// Auth and PostgREST responses can include secrets. Positive field selection
// happens before the result reaches gateway/audit serialization.
function select(value: unknown, fields: readonly string[]) {
const data = record(value);
return sanitize(Object.fromEntries(fields.filter(key => data[key] === null
|| ["string", "number", "boolean"].includes(typeof data[key])).map(key => [key, data[key]])));
}
async function send(path: string, method = "GET", body?: BodyInit, contentType?: string): Promise<unknown> {
let response: Response;
try {
response = await bounded(() => request(base + path, {
method, body, redirect: "error", signal,
headers: { Authorization: authorization!, Accept: "application/json", ...(contentType ? { "Content-Type": contentType } : {}) },
}));
} catch (error) {
// The local timer is authoritative even if wall-clock time moved back or
// the timer fired just before Date.now() reaches the absolute deadline.
if (error === deadlineError || signal.aborted || Date.now() >= deadlineAt) throw expired();
throw fail(502, "supabase_outcome_unknown", method === "GET" ? "Supabase could not complete this request." : "Supabase did not return a result. The operation may have completed; inspect its state before retrying.");
}
if (!response.ok) {
void response.body?.cancel().catch(() => {});
// Configuration may be applied before the provider reports a restart
// failure. A server error is not proof that a write had no effect.
if (method !== "GET" && (response.status >= 500 || response.status === 408)) {
throw fail(502, "supabase_outcome_unknown", "Supabase reported an error after this write. The operation may have completed; inspect its state before retrying.");
}
const status = [400, 401, 402, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
throw fail(status, "supabase_request_failed", status === 401 || status === 403
? "Supabase denied access. Reconnect with a personal access token authorized for this project and action."
: status === 402 ? "Supabase requires an account or billing change for this operation."
: status === 429 ? "Supabase rate limited this request. Wait before retrying."
: status === 404 ? "Supabase could not find this project or function." : "Supabase could not complete this operation.");
}
if (response.status === 204 || !response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const part = await bounded(() => reader.read());
if (part.done) break;
size += part.value.byteLength;
if (size > MAX_BYTES) throw outputLimitError;
chunks.push(part.value);
}
} catch (error) {
if (error === deadlineError || error === outputLimitError) throw error;
throw fail(502, "supabase_outcome_unknown", "Supabase's response was interrupted. Inspect the operation before retrying.");
} finally { void reader.cancel().catch(() => {}); }
if (!size) return null;
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
catch { throw fail(502, "supabase_invalid_response", "Supabase returned an invalid response."); }
}
async function invoke(name: string, parameters: unknown): Promise<unknown> {
const operation = operations.find(candidate => candidate.name === name);
if (!operation) throw fail(404, "supabase_tool_not_found", "Supabase action not found.");
const parsed = operation.schema.safeParse(parameters);
if (!parsed.success) throw fail(422, "supabase_arguments_invalid", "Invalid Supabase action arguments.");
const args = record(parsed.data);
switch (name) {
case "supabase_get_project": return select(await send(""), projectFields);
case "supabase_get_auth_config": return select(await send("/config/auth"), Object.keys(authConfigSchema.shape));
case "supabase_get_api_config": return select(await send("/postgrest"), Object.keys(apiConfigSchema.shape));
case "supabase_update_auth_config":
case "supabase_update_api_config": {
if (!Object.keys(record(args.config)).length) throw fail(422, "supabase_arguments_invalid", "Provide at least one configuration field.");
const auth = name === "supabase_update_auth_config";
const response = await send(auth ? "/config/auth" : "/postgrest", "PATCH", JSON.stringify(args.config), "application/json");
return select(response, Object.keys(auth ? authConfigSchema.shape : apiConfigSchema.shape));
}
case "supabase_list_edge_functions": {
const response = await send("/functions");
if (!Array.isArray(response)) throw fail(502, "supabase_invalid_response", "Supabase returned an invalid function list.");
return response.map(item => select(item, functionFields));
}
case "supabase_get_edge_function": return select(await send(`/functions/${args.slug}`), functionFields);
case "supabase_delete_edge_function": {
await send(`/functions/${args.slug}`, "DELETE");
return { deleted: true, slug: args.slug };
}
case "supabase_list_edge_function_secrets": {
const response = await send("/secrets");
if (!Array.isArray(response)) throw fail(502, "supabase_invalid_response", "Supabase returned an invalid secret list.");
// The API's field is named `value`; do not assume that it is a digest
// or expose it if the provider ever changes the response contract.
return response.map(item => select(item, ["name", "updated_at"]));
}
case "supabase_upsert_edge_function_secret": {
const input = parsed.data as z.infer<typeof secretSchema>;
await send("/secrets", "POST", JSON.stringify([{ name: input.name, value: input.secretValue }]), "application/json");
// Do not trust an upstream echo, even in fields that appear harmless.
return { configured: true, name: sanitize(input.name.split(input.secretValue).join("[REDACTED]")) };
}
case "supabase_deploy_edge_function": {
const deployment = parsed.data as z.infer<typeof deploySchema>;
const names = new Set(deployment.files.map(file => file.name));
if (names.size !== deployment.files.length || !names.has(deployment.entrypoint_path)
|| (deployment.import_map_path && !names.has(deployment.import_map_path))) {
throw fail(422, "supabase_files_invalid", "Provide unique source files including the entrypoint and optional import map.");
}
if (deployment.files.reduce((sum, file) => sum + Buffer.byteLength(file.content), 0) > MAX_BYTES) {
throw fail(422, "supabase_input_limit", "Supabase source files exceed the upload limit.");
}
const form = new FormData();
form.append("metadata", JSON.stringify({ name: deployment.name ?? deployment.slug, entrypoint_path: deployment.entrypoint_path,
...(deployment.import_map_path ? { import_map_path: deployment.import_map_path } : {}), verify_jwt: deployment.verify_jwt }));
for (const file of deployment.files) form.append("file", new Blob([file.content], { type: "application/octet-stream" }), file.name);
// The guarded, DNS-pinned HTTP transport accepts bytes, not FormData.
const encoded = new Request(SUPABASE_API_URL, { method: "POST", body: form });
const bytes = Buffer.from(await bounded(() => encoded.arrayBuffer()));
if (bytes.byteLength > MAX_BYTES) throw fail(422, "supabase_input_limit", "Supabase source files exceed the upload limit.");
return select(await send(`/functions/deploy?slug=${deployment.slug}`, "POST", bytes, encoded.headers.get("Content-Type")!), functionFields);
}
}
throw fail(404, "supabase_tool_not_found", "Supabase action not found.");
}
return { invoke, probe: () => invoke("supabase_get_project", {}) };
}
@@ -1,3 +1,4 @@
import { REST_CONNECTORS } from "./rest-registry.js";
import type { ToolRiskLevel } from "@paperclipai/shared";
import { NATIVE_PROVIDER_CATALOGS } from "../native-provider-catalogs.js";
import { classifyRailwayToolRisk, isRailwayEndpoint } from "../railway.js";
@@ -25,6 +26,12 @@ interface ReviewedToolPolicy {
// Provider selection belongs here; authorization, catalog transitions, and
// operator-selected action policies remain owned by the shared tool service.
const reviewedPolicies: ReviewedToolPolicy[] = [
...REST_CONNECTORS.map(connector => ({
sourceTemplateKey: connector.sourceTemplateKey,
quarantineNewEntries: true,
classifyRisk: (tool: ToolDescriptor) => connector.tools.some(candidate => candidate.name === tool.name) ? connector.risk(tool.name) : null,
matchesConnection: connector.matches,
})),
{
sourceTemplateKey: "railway",
quarantineNewEntries: true,
@@ -0,0 +1,73 @@
import { z } from "zod";
import { descriptorHash, stableHash } from "../../tool-catalog-hash.js";
export const VERCEL_API_URL = "https://api.vercel.com";
export const VERCEL_METHOD = "rest-api-key";
export const vercelTeamIdSchema = z.string().regex(/^team_[A-Za-z0-9]+$/).max(100);
const identifier = z.string().regex(/^[A-Za-z0-9_-]+$/).max(100);
const project = { projectId: identifier.describe("Vercel project ID or name in this connection's team.") };
const deployment = { ...project, deploymentId: identifier };
const domain = z.string().max(253).regex(/^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/);
const page = { limit: z.number().int().min(1).max(100).optional(), from: z.string().max(100).optional() };
const settings = z.object({
framework: z.string().max(100).nullable().optional(),
buildCommand: z.string().max(256).nullable().optional(),
installCommand: z.string().max(256).nullable().optional(),
devCommand: z.string().max(256).nullable().optional(),
outputDirectory: z.string().max(256).nullable().optional(),
rootDirectory: z.string().max(256).nullable().optional(),
nodeVersion: z.string().max(30).optional(),
}).strict();
function operation(name: string, title: string, description: string, risk: "read" | "write" | "destructive", schema: z.ZodType) {
return { name: `vercel_${name}`, title, description, risk, schema };
}
export const vercelOperations = [
operation("list_projects", "List projects", "List projects in the connected Vercel team. Use pagination.next as from for another page.", "read", z.object(page).strict()),
operation("get_project", "Get project", "Inspect project build configuration. Environment values and credentials are excluded.", "read", z.object(project).strict()),
operation("create_project", "Create project", "Create a Vercel project in the connected team. Provider plan and usage charges apply.", "write", z.object({ name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).max(100), settings: settings.optional() }).strict()),
operation("update_project", "Update project settings", "Change build/install commands, framework, directories or Node version for a project.", "write", z.object({ ...project, settings }).strict()),
operation("delete_project", "Delete project", "Permanently delete a project and its deployments and configuration.", "destructive", z.object(project).strict()),
operation("list_deployments", "List deployments", "List deployments of a project. Use pagination.next as until for another page.", "read", z.object({ ...project, limit: page.limit, until: z.number().int().nonnegative().optional() }).strict()),
operation("get_deployment", "Get deployment", "Inspect a deployment's URL, state and error code. A queued deployment is not yet a successful deployment.", "read", z.object(deployment).strict()),
operation("create_deployment", "Deploy project", "Deploy one source: inline files (up to 4 MiB total), a GitHub repository accessible to Vercel, or an existing deployment ID. Preview is the default and requires an existing undeleted deployment; failed or canceled deployments count. Vercel makes an empty project's first deployment production. Set target=production explicitly only when production is authorized. A reconciliationRequired receipt means preview was not confirmed; inspect that deployment before retrying.", "write", z.object({
...project, name: identifier, target: z.enum(["preview", "production"]).optional(),
files: z.array(z.object({ file: z.string().min(1).max(500), content: z.string().max(4 * 1024 * 1024), encoding: z.enum(["utf-8", "base64"]).optional() }).strict()).min(1).max(500).optional(),
gitSource: z.object({ type: z.literal("github"), repoId: z.union([z.number().int().positive(), z.string().regex(/^\d+$/)]), ref: z.string().min(1).max(500) }).strict().optional(),
deploymentId: identifier.optional(), projectSettings: settings.optional(),
}).strict()),
operation("cancel_deployment", "Cancel deployment", "Cancel an in-progress deployment in the selected project.", "destructive", z.object(deployment).strict()),
operation("delete_deployment", "Delete deployment", "Permanently delete a deployment in the selected project.", "destructive", z.object(deployment).strict()),
operation("list_environment_variables", "List environment variable names", "List environment variable metadata. Values are never returned and decryption is disabled.", "read", z.object(project).strict()),
operation("upsert_environment_variable", "Set environment variable", "Create or replace an environment variable for explicit targets. secretValue is redacted in audit. Changes apply to new deployments. Encrypted is the default storage type; sensitive is write-only in Vercel and supports preview/production.", "write", z.object({
...project, key: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(256), secretValue: z.string().max(64 * 1024),
target: z.array(z.enum(["preview", "production", "development"])).min(1).max(3),
type: z.enum(["encrypted", "sensitive", "plain"]).optional(), gitBranch: z.string().min(1).max(500).optional(),
}).strict()),
operation("delete_environment_variable", "Delete environment variable", "Remove one environment variable using its ID from the metadata list.", "destructive", z.object({ ...project, environmentVariableId: identifier }).strict()),
operation("list_domains", "List project domains", "List attached project domains and verification state. Use pagination.next as until for another page.", "read", z.object({ ...project, limit: page.limit, until: z.number().int().nonnegative().optional() }).strict()),
operation("add_domain", "Add project domain", "Attach an already-owned domain and return required DNS verification records. This does not purchase a domain or change DNS.", "write", z.object({ ...project, domain, gitBranch: z.string().min(1).max(500).optional() }).strict()),
operation("verify_domain", "Verify project domain", "Check DNS ownership after the required records are configured.", "write", z.object({ ...project, domain }).strict()),
operation("remove_domain", "Remove project domain", "Remove a domain from a project, which can interrupt traffic to that domain.", "destructive", z.object({ ...project, domain }).strict()),
];
export const VERCEL_TOOLS = vercelOperations.map(({ name, title, description, risk, schema }) => ({
name, title, description, inputSchema: z.toJSONSchema(schema) as Record<string, unknown>,
annotations: { readOnlyHint: risk === "read", destructiveHint: risk === "destructive", openWorldHint: true },
}));
export function vercelRisk(name: string): "read" | "write" | "destructive" {
return vercelOperations.find(operation => operation.name === name)?.risk ?? "destructive";
}
const currentContracts = new Map(VERCEL_TOOLS.map(tool => [tool.name, {
versionHash: descriptorHash(tool, vercelRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: vercelRisk(tool.name),
}]));
export function isCurrentVercelCatalogEntry(entry: { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }): boolean {
const current = currentContracts.get(entry.toolName);
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
}
export function isVercelConnection(connection: { transport: string; config?: Record<string, unknown> }) {
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "vercel"
&& connection.config.connectionMethodKey === VERCEL_METHOD
&& (connection.config.url === VERCEL_API_URL || connection.config.url === `${VERCEL_API_URL}/`);
}
@@ -0,0 +1,309 @@
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "../../../errors.js";
import { redactEventPayload } from "../../../redaction.js";
import { descriptorHash, stableHash } from "../../tool-catalog-hash.js";
import { isCurrentVercelCatalogEntry, isVercelConnection, VERCEL_API_URL, VERCEL_METHOD, VERCEL_TOOLS, vercelClient, vercelRisk, type VercelRequest } from "./index.js";
const token = "fixture-vercel-private-token";
const headers = { Authorization: `Bearer ${token}` };
const teamId = "team_fixture123";
const project = { id: "prj_fixture123", name: "fixture", accountId: teamId, framework: null, nodeVersion: "22.x" };
const deployment = { id: "dpl_fixture123", projectId: project.id, url: "fixture.vercel.app", readyState: "READY", target: null };
const deploymentArgs = { projectId: project.id, name: "fixture", files: [{ file: "index.html", content: "<h1>Preview</h1>" }] };
const previousDeployment = { uid: "dpl_previous123", projectId: project.id, state: "READY" };
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } });
function fixture(respond: (url: URL, init: RequestInit) => Response | Promise<Response> = () => json({})) {
const request = vi.fn<VercelRequest>(async (url, init) => {
const parsed = new URL(url);
if (init.method === "GET" && parsed.pathname === `/v9/projects/${project.id}`) return json(project);
if (init.method === "GET" && parsed.pathname === `/v13/deployments/${deployment.id}`) return json(deployment);
return respond(parsed, init);
});
return { request, client: vercelClient(headers, request, teamId) };
}
function previewFixture(existing: unknown, created: unknown = deployment) {
return fixture((url, init) => {
if (url.pathname === "/v7/deployments" && init.method === "GET") return json(existing);
if (url.pathname === "/v13/deployments" && init.method === "POST") return json(created);
throw new Error(`Unexpected fixture request: ${init.method} ${url.pathname}`);
});
}
describe("Vercel deployment connector", () => {
it("admits only the exact reviewed connection and catalog contract", () => {
const config = { sourceTemplateKey: "vercel", connectionMethodKey: VERCEL_METHOD, url: VERCEL_API_URL };
expect(isVercelConnection({ transport: "rest_api", config })).toBe(true);
expect(isVercelConnection({ transport: "rest_api", config: { ...config, url: `${VERCEL_API_URL}/` } })).toBe(true);
expect(isVercelConnection({ transport: "rest_api", config: { ...config, url: `${VERCEL_API_URL}/?teamId=team_other` } })).toBe(false);
expect(isVercelConnection({ transport: "mcp_remote", config })).toBe(false);
expect(isVercelConnection({ transport: "rest_api", config: { ...config, url: "https://evil.invalid" } })).toBe(false);
expect(VERCEL_TOOLS).toHaveLength(17);
for (const tool of VERCEL_TOOLS) {
const entry = { toolName: tool.name, versionHash: descriptorHash(tool, vercelRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: vercelRisk(tool.name) };
expect(isCurrentVercelCatalogEntry(entry)).toBe(true);
expect(isCurrentVercelCatalogEntry({ ...entry, schemaHash: "stale" })).toBe(false);
expect(isCurrentVercelCatalogEntry({ ...entry, riskLevel: "other" })).toBe(false);
}
expect(vercelRisk("vercel_create_deployment")).toBe("write");
expect(vercelRisk("vercel_upsert_environment_variable")).toBe("write");
expect(vercelRisk("vercel_delete_project")).toBe("destructive");
expect(vercelRisk("unknown")).toBe("destructive");
});
it("probes the configured team with the vaulted credential and forbids redirects", async () => {
const { request, client } = fixture(() => json({ id: teamId, name: "Fixture" }));
expect(await client.probe()).toEqual({ teamId });
const [url, init] = request.mock.calls[0]!;
expect(url).toBe(`${VERCEL_API_URL}/v2/teams/${teamId}?teamId=${teamId}`);
expect(init.redirect).toBe("error");
expect(new Headers(init.headers).get("authorization")).toBe(`Bearer ${token}`);
const wrong = fixture(() => json({ id: "team_other" }));
await expect(wrong.client.probe()).rejects.toMatchObject({ status: 403 });
});
it("rejects malformed credentials without echoing them from header validation", () => {
expect(() => vercelClient({ Authorization: `Bearer ${token}\r\nInjected: value` }, vi.fn(), teamId)).toThrow("Reconnect Vercel to restore its access token.");
});
it("lists only team projects, bounds pagination and excludes embedded environment values", async () => {
const { request, client } = fixture(() => json({ projects: [{ ...project, env: [{ value: "hidden" }] }], pagination: { next: "123" } }));
expect(await client.invoke("vercel_list_projects", { limit: 3, from: "99" })).toEqual({ projects: [project], pagination: { next: "123" } });
expect(new URL(request.mock.calls[0]![0]).searchParams.get("teamId")).toBe(teamId);
expect(new URL(request.mock.calls[0]![0]).searchParams.get("from")).toBe("99");
});
it.each([
["vercel_list_projects", { teamId: "team_other" }],
["vercel_get_project", { projectId: "../other" }],
["vercel_create_deployment", { projectId: project.id, name: "fixture" }],
["vercel_create_deployment", { projectId: project.id, name: "fixture", files: [{ file: "index.html", content: "hi" }], deploymentId: deployment.id }],
["vercel_create_deployment", { projectId: project.id, name: "fixture", files: [{ file: "../secret", content: "hi" }] }],
["vercel_create_deployment", { projectId: project.id, name: "fixture", files: [{ file: "image.png", content: "not base64!", encoding: "base64" }] }],
["vercel_create_deployment", { projectId: project.id, name: "fixture", files: [{ file: "index.html", content: "hi" }], target: "custom-production" }],
["vercel_upsert_environment_variable", { projectId: project.id, key: "KEY", secretValue: "fixture", target: ["development"], type: "sensitive" }],
])("rejects invalid %s input before provider requests", async (name, args) => {
const { request, client } = fixture();
await expect(client.invoke(name, args)).rejects.toMatchObject({ status: 422 });
expect(request).not.toHaveBeenCalled();
});
it("checks project ownership before mutation even when the provider accepts a foreign ID", async () => {
const request = vi.fn<VercelRequest>(async () => json({ ...project, accountId: "team_other" }));
await expect(vercelClient(headers, request, teamId).invoke("vercel_update_project", { projectId: project.id, settings: { framework: "nextjs" } })).rejects.toMatchObject({ status: 403 });
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0]![1].method).toBe("GET");
});
it.each([undefined, "preview"])("deploys inline files with target %s after verifying a previous deployment exists", async (target) => {
const { request, client } = previewFixture({ deployments: [previousDeployment] }, { ...deployment, env: ["hidden"], token });
expect(await client.invoke("vercel_create_deployment", { ...deploymentArgs, target })).toEqual(deployment);
expect(request.mock.calls.map(([url, init]) => [new URL(url).pathname, init.method])).toEqual([
[`/v9/projects/${project.id}`, "GET"], ["/v7/deployments", "GET"], ["/v13/deployments", "POST"],
]);
expect(Object.fromEntries(new URL(request.mock.calls[1]![0]).searchParams)).toEqual({ projectId: project.id, limit: "1", teamId });
const [url, init] = request.mock.calls[2]!;
expect(url).toBe(`${VERCEL_API_URL}/v13/deployments?teamId=${teamId}`);
expect(JSON.parse(String(init.body))).toEqual({ name: "fixture", project: project.id, files: [{ file: "index.html", data: "<h1>Preview</h1>", encoding: "utf-8" }], projectSettings: { framework: null, nodeVersion: "22.x" } });
});
it("deploys GitHub sources and sends an explicit production target", async () => {
const { request, client } = fixture(() => json({ ...deployment, target: "production" }));
const gitSource = { type: "github", repoId: "123", ref: "main" };
await client.invoke("vercel_create_deployment", { projectId: project.id, name: "fixture", gitSource, target: "production" });
expect(JSON.parse(String(request.mock.calls[1]![1].body))).toMatchObject({ gitSource, target: "production", project: project.id });
expect(request).toHaveBeenCalledTimes(2);
});
it.each([undefined, "preview"])("rejects target %s for an empty project before a deployment can be promoted", async (target) => {
const { request, client } = previewFixture({ deployments: [] });
await expect(client.invoke("vercel_create_deployment", { ...deploymentArgs, target })).rejects.toMatchObject({
status: 409, details: { code: "vercel_preview_requires_existing_deployment" },
message: expect.stringContaining("target=production"),
});
expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it.each(["ERROR", "CANCELED"])("accepts an existing %s deployment as the preview prerequisite", async (state) => {
const { client } = previewFixture({ deployments: [{ ...previousDeployment, state }] });
expect(await client.invoke("vercel_create_deployment", deploymentArgs)).toEqual(deployment);
});
it.each([
[null, 502],
[{ deployments: {} }, 502],
[{ deployments: [null] }, 502],
[{ deployments: [{ projectId: project.id, uid: "invalid" }] }, 502],
[{ deployments: [{ ...previousDeployment, projectId: "prj_other" }] }, 403],
])("rejects an unverified preview prerequisite %j before mutation", async (existing, status) => {
const { request, client } = previewFixture(existing);
await expect(client.invoke("vercel_create_deployment", deploymentArgs)).rejects.toMatchObject({ status });
expect(request.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it.each(["production", undefined, "staging", { unexpected: token }])("returns a bounded reconciliation receipt for preview response target %j", async (target) => {
const { request, client } = previewFixture({ deployments: [previousDeployment] }, {
...deployment, target, url: token, env: [{ value: token }], unknown: token,
});
expect(await client.invoke("vercel_create_deployment", deploymentArgs)).toEqual({
id: deployment.id, projectId: project.id, requestedTarget: "preview",
target: target === "production" ? "production" : "unknown", reconciliationRequired: true,
code: "vercel_deployment_target_mismatch",
message: "Vercel created a deployment without confirming the requested preview environment. Inspect this deployment before any retry; it may use Production environment variables and domains.",
});
expect(request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1);
expect(request.mock.calls.some(([, init]) => init.method === "DELETE")).toBe(false);
});
it("cancels a preview prerequisite without a late deployment write", async () => {
const controller = new AbortController();
let finish!: (response: Response) => void;
let markStarted!: () => void;
const started = new Promise<void>(resolve => { markStarted = resolve; });
const request = vi.fn<VercelRequest>(async (url) => {
if (new URL(url).pathname === `/v9/projects/${project.id}`) return json(project);
markStarted();
return new Promise(resolve => { finish = resolve; });
});
const outcome = vercelClient(headers, request, teamId, controller.signal).invoke("vercel_create_deployment", deploymentArgs)
.then(() => null, error => error);
await started;
expect(new URL(request.mock.calls[1]![0]).pathname).toBe("/v7/deployments");
controller.abort();
expect(await outcome).toMatchObject({ status: 504 });
finish(json({ deployments: [previousDeployment] }));
await Promise.resolve();
expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it("checks deployment project identity before deleting, cancelling, or redeploying", async () => {
const request = vi.fn<VercelRequest>(async (url) => new URL(url).pathname.startsWith("/v9/projects/") ? json(project) : json({ ...deployment, projectId: "prj_other" }));
await expect(vercelClient(headers, request, teamId).invoke("vercel_delete_deployment", { projectId: project.id, deploymentId: deployment.id })).rejects.toMatchObject({ status: 403 });
expect(request.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it("rejects a deployment list containing another project's data", async () => {
const { client } = fixture(() => json({ deployments: [{ ...deployment, projectId: "prj_other" }] }));
await expect(client.invoke("vercel_list_deployments", { projectId: project.id })).rejects.toMatchObject({ status: 403 });
});
it("updates project configuration and returns only the saved settings", async () => {
const settings = { framework: "vite", buildCommand: "pnpm build", outputDirectory: "dist", rootDirectory: null };
const { request, client } = fixture(() => json({ ...project, ...settings, env: [{ value: "hidden" }] }));
expect(await client.invoke("vercel_update_project", { projectId: project.id, settings })).toEqual({ ...project, ...settings });
const [url, init] = request.mock.calls[1]!;
expect(url).toBe(`${VERCEL_API_URL}/v9/projects/${project.id}?teamId=${teamId}`);
expect(init.method).toBe("PATCH");
expect(JSON.parse(String(init.body))).toEqual(settings);
});
it("returns domain verification records for an attached domain without purchasing or changing DNS", async () => {
const response = { name: "app.example.com", verified: false, verification: [{ type: "TXT", domain: "_vercel.example.com", value: "vc-domain-verify=fixture" }] };
const { request, client } = fixture(() => json(response));
expect(await client.invoke("vercel_add_domain", { projectId: project.id, domain: response.name })).toEqual(response);
expect(new URL(request.mock.calls[1]![0]).pathname).toBe(`/v10/projects/${project.id}/domains`);
expect(JSON.parse(String(request.mock.calls[1]![1].body))).toEqual({ name: response.name });
});
it("sets encrypted environment values without echoing provider secrets or storing them in audit snapshots", async () => {
const secretValue = "fixture-environment-value";
const args = { projectId: project.id, key: "API_KEY", secretValue, target: ["preview"] };
const { request, client } = fixture(() => json({ created: { id: "env_fixture", key: "API_KEY", value: secretValue, vsmValue: "hidden", type: "encrypted", target: ["preview"] }, failed: [] }));
expect(await client.invoke("vercel_upsert_environment_variable", args)).toEqual({ id: "env_fixture", key: "API_KEY", type: "encrypted", target: ["preview"] });
expect(JSON.parse(String(request.mock.calls[1]![1].body))).toEqual({ key: "API_KEY", value: secretValue, type: "encrypted", target: ["preview"] });
expect(JSON.stringify(redactEventPayload(args))).not.toContain(secretValue);
expect(new URL(request.mock.calls[1]![0]).searchParams.get("upsert")).toBe("true");
});
it("treats environment partial-failure responses as failure without exposing their values", async () => {
const { client } = fixture(() => json({ created: [], failed: [{ error: { message: token, value: "private-value" } }] }));
await expect(client.invoke("vercel_upsert_environment_variable", { projectId: project.id, key: "API_KEY", secretValue: "private-value", target: ["preview"] })).rejects.toMatchObject({ status: 422, message: "Vercel rejected the environment variable. Check the key, targets and provider permissions." });
});
it("never decrypts environment variables and strips values from metadata", async () => {
const { request, client } = fixture(() => json({ envs: [{ id: "env_fixture", key: "API_KEY", value: "private-value", decrypted: true }] }));
expect(await client.invoke("vercel_list_environment_variables", { projectId: project.id })).toEqual({ envs: [{ id: "env_fixture", key: "API_KEY" }] });
expect(new URL(request.mock.calls[1]![0]).searchParams.get("decrypt")).toBe("false");
});
it("reports safe provider failures and never retries uncertain writes", async () => {
const { request, client } = fixture(() => { throw new Error(`network error ${token}`); });
await expect(client.invoke("vercel_create_project", { name: "fixture" })).rejects.toMatchObject({ status: 502, message: "Vercel did not return a result. A submitted operation may still complete; inspect its state before retrying." });
expect(request).toHaveBeenCalledTimes(1);
const rejected = fixture(() => json({ error: { message: token } }, 403));
await expect(rejected.client.invoke("vercel_create_project", { name: "fixture" })).rejects.toMatchObject({ status: 403, message: "Vercel denied access. Check the access token's team scope, expiration and role, then reconnect." });
const forged = fixture(() => { throw new HttpError(504, `network error ${token}`, { code: "vercel_deadline_exceeded" }); });
await expect(forged.client.invoke("vercel_create_project", { name: "fixture" })).rejects.toMatchObject({ status: 502,
message: "Vercel did not return a result. A submitted operation may still complete; inspect its state before retrying." });
expect(forged.request).toHaveBeenCalledTimes(1);
});
it("redacts exact connection tokens even if returned in allowed fields or object keys", async () => {
const { client } = fixture(() => json({ domains: [{ name: token, verification: [{ [token]: token }] }] }));
expect(JSON.stringify(await client.invoke("vercel_list_domains", { projectId: project.id }))).not.toContain(token);
});
it("expires slow prerequisites before the wall-clock threshold without a late write and keeps other requests responsive", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const now = Date.now();
const wallClock = vi.spyOn(Date, "now").mockReturnValue(now);
try {
let finish!: (response: Response) => void;
let markStarted!: () => void;
const started = new Promise<void>(resolve => { markStarted = resolve; });
const request = vi.fn<VercelRequest>(() => new Promise(resolve => {
finish = resolve;
markStarted();
}));
const deadlineAt = now + 30;
const pending = vercelClient(headers, request, teamId, new AbortController().signal, deadlineAt)
.invoke("vercel_delete_project", { projectId: project.id });
const outcome = pending.then(() => null, error => error);
await started;
expect(await fixture(() => json({ id: teamId })).client.probe()).toEqual({ teamId });
await vi.advanceTimersByTimeAsync(30);
expect(Date.now()).toBeLessThan(deadlineAt);
expect(await outcome).toMatchObject({ status: 504, details: { code: "vercel_deadline_exceeded" } });
finish(json(project));
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0]![1].method).toBe("GET");
} finally {
wallClock.mockRestore();
vi.useRealTimers();
}
});
it("does not expose arbitrary transport HTTP errors from response streams", async () => {
const { client } = fixture(() => new Response(new ReadableStream({
start(controller) {
controller.error(new HttpError(504, `stream error ${token}`, { code: "vercel_deadline_exceeded" }));
},
})));
await expect(client.probe()).rejects.toMatchObject({ status: 502,
message: "Vercel's response was interrupted. Inspect the operation before retrying." });
});
it("bounds a response stream even if its reader stalls", async () => {
vi.useFakeTimers();
try {
let markStarted!: () => void;
const started = new Promise<void>(resolve => { markStarted = resolve; });
const request = vi.fn<VercelRequest>(async () => new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("{"));
markStarted();
},
})));
const pending = vercelClient(headers, request, teamId, new AbortController().signal, Date.now() + 20).probe();
const assertion = expect(pending).rejects.toMatchObject({ status: 504 });
await started;
await vi.advanceTimersByTimeAsync(20);
await assertion;
expect(request).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,222 @@
import { HttpError } from "../../../errors.js";
import { redactSensitiveText } from "../../../redaction.js";
import { VERCEL_API_URL, vercelOperations, vercelTeamIdSchema } from "./catalog.js";
export { VERCEL_API_URL, VERCEL_METHOD, VERCEL_TOOLS, vercelRisk, isCurrentVercelCatalogEntry, isVercelConnection } from "./catalog.js";
export type VercelRequest = (url: string, init: RequestInit) => Promise<Response>;
const MAX_BYTES = 4 * 1024 * 1024;
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const failure = (status: number, code: string, message: string) => new HttpError(status, message, { code });
const pick = (value: unknown, fields: string[]) => Object.fromEntries(Object.entries(record(value)).filter(([key]) => fields.includes(key)));
const projectFields = ["id", "name", "accountId", "framework", "buildCommand", "installCommand", "devCommand", "outputDirectory", "rootDirectory", "nodeVersion", "createdAt", "updatedAt"];
const deploymentFields = ["id", "uid", "name", "projectId", "url", "readyState", "state", "target", "createdAt", "created", "buildingAt", "ready", "errorCode"];
const envFields = ["id", "key", "type", "target", "gitBranch", "createdAt", "updatedAt"];
const domainFields = ["name", "apexName", "projectId", "verified", "verification", "gitBranch", "redirect", "redirectStatusCode"];
const pagination = (value: unknown) => pick(record(value).pagination, ["count", "next", "prev"]);
function collection(value: Record<string, unknown>, key: string): unknown[] {
if (!Array.isArray(value[key])) throw failure(502, "vercel_invalid_response", "Vercel returned an invalid response.");
return value[key];
}
/** Fixed provider operations only. Credentials come from the connection grant;
* the gateway owns company, run, policy, approval and audit enforcement. */
export function vercelClient(headers: Record<string, string>, request: VercelRequest, configuredTeamId: unknown,
signal: AbortSignal = AbortSignal.timeout(30_000), deadlineAt: number = Date.now() + 30_000) {
// Do not pass unvalidated credentials through Headers: its validation error
// can echo the rejected value into a logged exception.
const authorization = Object.entries(headers).find(([key]) => key.toLowerCase() === "authorization")?.[1];
if (!authorization || !/^Bearer [^\r\n]+$/.test(authorization)) throw failure(422, "vercel_credential_missing", "Reconnect Vercel to restore its access token.");
const parsedTeam = vercelTeamIdSchema.safeParse(configuredTeamId);
if (!parsedTeam.success) throw failure(422, "vercel_team_required", "Configure the Vercel team ID for this connection.");
const teamId = parsedTeam.data;
const token = authorization.slice(7);
const deadlineError = failure(504, "vercel_deadline_exceeded", "The Vercel request timed out. A submitted operation may still complete; inspect its state before retrying.");
async function withinDeadline<T>(work: () => Promise<T>): Promise<T> {
if (signal.aborted || Date.now() >= deadlineAt) throw deadlineError;
let abort!: () => void;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw deadlineError; return work(); }),
new Promise<never>((_resolve, reject) => {
abort = () => reject(deadlineError);
signal.addEventListener("abort", abort, { once: true });
timer = setTimeout(abort, Math.max(0, deadlineAt - Date.now()));
}),
]);
} finally { signal.removeEventListener("abort", abort); if (timer) clearTimeout(timer); }
}
function sanitize(value: unknown): unknown {
if (typeof value === "string") return redactSensitiveText(value.split(token).join("[REDACTED]"));
if (Array.isArray(value)) return value.map(sanitize);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.split(token).join("[REDACTED]"), sanitize(item)]));
return value;
}
async function send(path: string, method = "GET", body?: unknown, query: Record<string, unknown> = {}): Promise<unknown> {
const url = new URL(path, VERCEL_API_URL);
for (const [key, value] of Object.entries(query)) if (value !== undefined) url.searchParams.set(key, String(value));
url.searchParams.set("teamId", teamId);
const serialized = body === undefined ? undefined : JSON.stringify(body);
if (serialized && Buffer.byteLength(serialized) > MAX_BYTES) throw failure(422, "vercel_input_limit", "The Vercel request exceeds 4 MiB. Use a Git deployment for larger projects.");
let response: Response;
try {
response = await withinDeadline(() => request(url.href, { method, headers: { Authorization: authorization!, "Content-Type": "application/json", "User-Agent": "Paperclip" },
redirect: "error", signal, ...(serialized === undefined ? {} : { body: serialized }) }));
} catch (error) {
if (error === deadlineError || signal.aborted || Date.now() >= deadlineAt) throw deadlineError;
throw failure(502, "vercel_outcome_unknown", "Vercel did not return a result. A submitted operation may still complete; inspect its state before retrying.");
}
if (!response.ok) {
void response.body?.cancel().catch(() => {});
const status = [400, 401, 402, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
throw failure(status, "vercel_request_failed", status === 401 || status === 403
? "Vercel denied access. Check the access token's team scope, expiration and role, then reconnect."
: status === 429 ? "Vercel rate limited this request. Wait before retrying."
: status === 404 ? "Vercel could not find this resource in the connected team."
: status === 402 ? "Vercel requires a plan or billing change for this operation."
: "Vercel could not complete this operation. Check the arguments and resource state before retrying.");
}
if (response.status === 204 || !response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
const outputLimitError = failure(502, "vercel_output_limit", "Vercel's response exceeded 4 MiB. Request a smaller page.");
try {
for (;;) {
const part = await withinDeadline(() => reader.read());
if (part.done) break;
length += part.value.byteLength;
if (length > MAX_BYTES) throw outputLimitError;
chunks.push(part.value);
}
} catch (error) {
if (error === deadlineError || error === outputLimitError) throw error;
throw failure(502, "vercel_outcome_unknown", "Vercel's response was interrupted. Inspect the operation before retrying.");
} finally { void reader.cancel().catch(() => {}); }
if (!length) return null;
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
catch { throw failure(502, "vercel_invalid_response", "Vercel returned an invalid response."); }
}
function assertProject(value: unknown, requested?: string) {
const project = record(value);
if (project.accountId !== teamId || typeof project.id !== "string" || !/^prj_[A-Za-z0-9]+$/.test(project.id)
|| (requested && project.id !== requested && project.name !== requested)) {
throw failure(403, "vercel_team_mismatch", "Vercel returned a project outside the configured team or requested target.");
}
return project;
}
async function getDeployment(projectId: string, deploymentId: string) {
const value = record(await send(`/v13/deployments/${encodeURIComponent(deploymentId)}`));
if (value.projectId !== projectId || value.id !== deploymentId) throw failure(403, "vercel_project_mismatch", "Vercel returned a deployment outside the selected project.");
return value;
}
async function probe() {
const value = record(await send(`/v2/teams/${encodeURIComponent(teamId)}`));
if (value.id !== teamId) throw failure(403, "vercel_team_mismatch", "The Vercel token does not identify the configured team.");
return { teamId };
}
async function invoke(name: string, parameters: unknown): Promise<unknown> {
const operation = vercelOperations.find(candidate => candidate.name === name);
if (!operation) throw failure(404, "vercel_tool_not_found", "Vercel action not found.");
const parsed = operation.schema.safeParse(parameters);
if (!parsed.success) throw failure(422, "vercel_arguments_invalid", "Invalid Vercel action arguments.");
const args = record(parsed.data);
// Validate all semantic constraints before even a read is dispatched.
if (name === "vercel_create_deployment") {
if ([args.files, args.gitSource, args.deploymentId].filter(value => value !== undefined).length !== 1) throw failure(422, "vercel_arguments_invalid", "Choose exactly one deployment source: files, gitSource or deploymentId.");
const paths = new Set<string>();
for (const value of (args.files as unknown[] | undefined) ?? []) {
const file = record(value), path = String(file.file);
if (path.startsWith("/") || /[\\\u0000-\u001f]/.test(path) || path.split("/").some(segment => !segment || segment === "." || segment === "..") || paths.has(path)) throw failure(422, "vercel_file_invalid", "Deployment files need unique relative paths without traversal.");
paths.add(path);
if (file.encoding === "base64" && Buffer.from(String(file.content), "base64").toString("base64") !== file.content) throw failure(422, "vercel_file_invalid", "Deployment files must use canonical base64 encoding.");
}
}
if (name === "vercel_upsert_environment_variable" && args.type === "sensitive" && (args.target as string[]).includes("development")) throw failure(422, "vercel_arguments_invalid", "Vercel sensitive variables support preview and production targets only.");
if (Buffer.byteLength(JSON.stringify(args)) > MAX_BYTES) throw failure(422, "vercel_input_limit", "The Vercel request exceeds 4 MiB. Use a Git deployment for larger projects.");
let project: Record<string, unknown> | undefined;
if (typeof args.projectId === "string") project = assertProject(await send(`/v9/projects/${encodeURIComponent(args.projectId)}`), args.projectId);
const projectId = project?.id as string;
const projectPath = `/v9/projects/${encodeURIComponent(projectId ?? "")}`;
const deploymentId = args.deploymentId as string;
let result: unknown;
switch (name) {
case "vercel_list_projects": {
const value = record(await send("/v10/projects", "GET", undefined, { limit: args.limit ?? 20, from: args.from }));
result = { projects: collection(value, "projects").map(item => pick(assertProject(item), projectFields)), pagination: pagination(value) }; break;
}
case "vercel_get_project": result = pick(project, projectFields); break;
case "vercel_create_project": result = pick(assertProject(await send("/v11/projects", "POST", { name: args.name, ...record(args.settings) }), String(args.name)), projectFields); break;
case "vercel_update_project": result = pick(assertProject(await send(projectPath, "PATCH", args.settings), projectId), projectFields); break;
case "vercel_delete_project": await send(projectPath, "DELETE"); result = { deleted: true, projectId }; break;
case "vercel_list_deployments": {
const value = record(await send("/v7/deployments", "GET", undefined, { projectId, limit: args.limit ?? 20, until: args.until }));
result = { deployments: collection(value, "deployments").map(item => {
if (record(item).projectId !== projectId) throw failure(403, "vercel_project_mismatch", "Vercel returned a deployment outside the selected project.");
return pick(item, deploymentFields);
}), pagination: pagination(value) }; break;
}
case "vercel_get_deployment": result = pick(await getDeployment(projectId, deploymentId), deploymentFields); break;
case "vercel_create_deployment": {
if (deploymentId) await getDeployment(projectId, deploymentId);
const preview = args.target !== "production";
if (preview) {
// Vercel promotes the first deployment of an empty project to
// production, including its environment variables. Failed/canceled
// deployments count as history; deleted deployments do not.
const existing = collection(record(await send("/v7/deployments", "GET", undefined, { projectId, limit: 1 })), "deployments");
if (!existing.length) throw failure(409, "vercel_preview_requires_existing_deployment", "Vercel makes the first deployment of an empty project production. Use a project with an existing deployment for preview, or set target=production only when production deployment is authorized.");
for (const item of existing) {
const value = record(item), id = value.uid ?? value.id;
if (typeof id !== "string" || !/^dpl_[A-Za-z0-9]{1,200}$/.test(id)) throw failure(502, "vercel_invalid_response", "Vercel did not confirm an existing deployment. No deployment was submitted.");
if (value.projectId !== projectId) throw failure(403, "vercel_project_mismatch", "Vercel returned a deployment outside the selected project.");
}
}
const files = (args.files as unknown[] | undefined)?.map(value => { const file = record(value); return { file: file.file, data: file.content, encoding: file.encoding ?? "utf-8" }; });
const created = record(await send("/v13/deployments", "POST", { name: args.name, project: projectId,
...(args.target === "production" ? { target: "production" } : {}),
...(files ? { files } : {}), ...(args.gitSource ? { gitSource: args.gitSource } : {}), ...(deploymentId ? { deploymentId } : {}),
projectSettings: args.projectSettings ?? pick(project, ["framework", "buildCommand", "installCommand", "devCommand", "outputDirectory", "rootDirectory", "nodeVersion"]),
}));
if (typeof created.id !== "string" || !/^dpl_[A-Za-z0-9]{1,200}$/.test(created.id) || typeof created.readyState !== "string") throw failure(502, "vercel_invalid_response", "Vercel did not confirm the deployment. Inspect its state before retrying.");
if (created.projectId !== undefined && created.projectId !== projectId) throw failure(403, "vercel_project_mismatch", "Vercel returned a deployment outside the selected project.");
if (preview && created.target !== null) {
// The preflight cannot prevent concurrent external deletion of all
// deployments. Persist the created identity for reconciliation and
// idempotent replay instead of losing it in a post-write exception.
result = { id: created.id, projectId, requestedTarget: "preview",
target: created.target === "production" ? "production" : "unknown", reconciliationRequired: true,
code: "vercel_deployment_target_mismatch",
message: "Vercel created a deployment without confirming the requested preview environment. Inspect this deployment before any retry; it may use Production environment variables and domains." };
break;
}
result = pick(created, deploymentFields); break;
}
case "vercel_cancel_deployment": await getDeployment(projectId, deploymentId); result = pick(await send(`/v12/deployments/${encodeURIComponent(deploymentId)}/cancel`, "PATCH", {}), deploymentFields); break;
case "vercel_delete_deployment": await getDeployment(projectId, deploymentId); await send(`/v13/deployments/${encodeURIComponent(deploymentId)}`, "DELETE"); result = { deleted: true, deploymentId }; break;
case "vercel_list_environment_variables": {
const value = record(await send(`/v10/projects/${encodeURIComponent(projectId)}/env`, "GET", undefined, { decrypt: false }));
result = { envs: collection(value, "envs").map(item => pick(item, envFields)) }; break;
}
case "vercel_upsert_environment_variable": {
const value = record(await send(`/v10/projects/${encodeURIComponent(projectId)}/env`, "POST", { key: args.key, value: args.secretValue, target: args.target, type: args.type ?? "encrypted", ...(args.gitBranch ? { gitBranch: args.gitBranch } : {}) }, { upsert: true }));
if (Array.isArray(value.failed) && value.failed.length) throw failure(422, "vercel_environment_write_failed", "Vercel rejected the environment variable. Check the key, targets and provider permissions.");
const created = record(Array.isArray(value.created) && value.created.length === 1 ? value.created[0] : value.created ?? value);
if (typeof created.id !== "string" || created.key !== args.key) throw failure(502, "vercel_invalid_response", "Vercel did not confirm the environment variable write. Inspect its state before retrying.");
result = pick(created, envFields); break;
}
case "vercel_delete_environment_variable": await send(`/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(String(args.environmentVariableId))}`, "DELETE"); result = { deleted: true, environmentVariableId: args.environmentVariableId }; break;
case "vercel_list_domains": {
const value = record(await send(`/v9/projects/${encodeURIComponent(projectId)}/domains`, "GET", undefined, { limit: args.limit ?? 20, until: args.until }));
result = { domains: collection(value, "domains").map(item => pick(item, domainFields)), pagination: pagination(value) }; break;
}
case "vercel_add_domain": result = pick(await send(`/v10/projects/${encodeURIComponent(projectId)}/domains`, "POST", { name: args.domain, ...(args.gitBranch ? { gitBranch: args.gitBranch } : {}) }), domainFields); break;
case "vercel_verify_domain": result = pick(await send(`/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(String(args.domain))}/verify`, "POST", {}), domainFields); break;
case "vercel_remove_domain": await send(`/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(String(args.domain))}`, "DELETE"); result = { removed: true, domain: args.domain }; break;
}
return sanitize(result);
}
return { probe, invoke };
}
@@ -9,6 +9,7 @@ import { externalConversationStateSql } from "../slack-conversation-state.js";
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
import { boundRunIssueIdSql } from "../run-task-binding.js";
import { isBrowserUseConnection } from "../browser-use-client.js";
import { restConnectorFor } from "../connectors/rest-registry.js";
import { explicitOperatorRunIdentity } from "../run-identity.js";
import {
type DurableChatWakeupRequest,
@@ -995,7 +996,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
.filter(
(connection) =>
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio" || isBrowserUseConnection(connection)) &&
connection.transport === "local_stdio" || isBrowserUseConnection(connection) || restConnectorFor(connection)) &&
!allInstalledConnectionIds.has(connection.id),
)
.map(({ id, name }) => ({ id, name }))
@@ -1021,7 +1022,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
connection.credentialPolicy === "per_user" ||
!isToolConnectionAttentionHealth(connection.healthStatus)) &&
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio" || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id) || spekoConnectionIds.has(connection.id)),
connection.transport === "local_stdio" || isBrowserUseConnection(connection) || restConnectorFor(connection) || githubBotConnectionIds.has(connection.id) || spekoConnectionIds.has(connection.id)),
);
const assignedConnectionIds = new Set(
assignedConnections.map((connection) => connection.id),
@@ -3,6 +3,7 @@ import { spekoToolsForSession } from "../voice/speko-agent-tools.js";
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
import { boundRunIssueIdSql } from "../run-task-binding.js";
import { isBrowserUseConnection } from "../browser-use-client.js";
import { restConnectorFor } from "../connectors/rest-registry.js";
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
@@ -236,7 +237,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
&& (Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|| connection.credentialPolicy === "per_user"
|| !isToolConnectionAttentionHealth(connection.healthStatus))
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id) || spekoConnectionIds.has(connection.id))
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || isBrowserUseConnection(connection) || restConnectorFor(connection) || githubBotConnectionIds.has(connection.id) || spekoConnectionIds.has(connection.id))
).map((connection) => connection.id));
const assignment = {
version: 1,
+71 -9
View File
@@ -49,6 +49,7 @@ import {
profileIdsInBindingOrder,
} from "./tool-profile-binding-precedence.js";
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
import { canonicalToolArguments, readSignedToolArgumentsPayload, toolRequestIdentityHash } from "./tool-content-guards.js";
type ToolAccessContext = {
companyId: string;
@@ -293,8 +294,9 @@ function trustRuleIsActive(policy: typeof toolPolicies.$inferSelect, now = new D
return true;
}
function sideEffectIdempotencyKey(ctx: ToolAccessContext, argumentsHash: string): string {
return `side_effect:${sha256({
function sideEffectIdempotencyKey(ctx: ToolAccessContext, argumentsHash: string, version: 1 | 2 = 2): string {
return `side_effect:${version === 2 ? "v2:" : ""}${sha256({
...(version === 2 ? { actorType: ctx.actorType, actorId: ctx.actorId, agentId: ctx.agentId } : {}),
companyId: ctx.companyId,
runId: ctx.heartbeatRunId,
issueId: ctx.issueId,
@@ -313,7 +315,7 @@ function auditOutcome(accessDecision: ToolAccessDecision): "pending" | "success"
return "denied";
}
function summarizeAndRedact(value: unknown): RedactionResult {
function summarizeAndRedact(value: unknown, normalizeSensitiveKeys = true): RedactionResult {
const redactedFields: string[] = [];
const visit = (current: unknown, path: string): unknown => {
if (typeof current === "string") {
@@ -328,7 +330,7 @@ function summarizeAndRedact(value: unknown): RedactionResult {
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(current)) {
const nestedPath = path ? `${path}.${key}` : key;
if (SENSITIVE_KEY_RE.test(key)) {
if (SENSITIVE_KEY_RE.test(normalizeSensitiveKeys ? key.replace(/([a-z0-9])([A-Z])/g, "$1_$2") : key)) {
redactedFields.push(nestedPath);
out[key] = "[REDACTED]";
} else {
@@ -353,6 +355,21 @@ function summarizeAndRedact(value: unknown): RedactionResult {
};
}
function completeLegacyArguments(invocation: typeof toolInvocations.$inferSelect): { arguments: unknown } | null {
const summary = invocation.argumentsSummary;
if (!summary || summary.redactedFields?.length) return null;
let parsed: unknown;
try { parsed = JSON.parse(summary.summary); } catch { return null; }
// Historical previews did not record array truncation separately. At the
// preview cap, even valid JSON cannot prove that no entries were omitted.
const mayBeTruncated = (value: unknown): boolean => typeof value === "string"
? value.includes("...[truncated]")
: Array.isArray(value) ? value.length >= 50 || value.some(mayBeTruncated)
: isRecord(value) && Object.values(value).some(mayBeTruncated);
return !mayBeTruncated(parsed) && sha256(parsed) === invocation.argumentsHash
? { arguments: parsed } : null;
}
function decision(
kind: ToolPolicyDecision,
reasonCode: ToolAccessReasonCode,
@@ -766,7 +783,7 @@ function scopeAllowsTool(scope: Record<string, unknown> | null, ctx: ToolAccessC
return selectorMatches(selectors, ctx);
}
export function toolAccessPolicyService(db: Db) {
export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecret?: string } = {}) {
async function listPolicies(companyId: string) {
return db
.select()
@@ -1154,7 +1171,9 @@ export function toolAccessPolicyService(db: Db) {
upstreamToolName,
toolName: input.request.toolName,
riskLevel,
argumentsHash: redaction.summary.sha256 ?? sha256(input.request.arguments ?? {}),
argumentsHash: input.request.sideEffecting
? toolRequestIdentityHash(input.request.arguments, options.toolActionSigningSecret)
: redaction.summary.sha256 ?? sha256(input.request.arguments ?? {}),
arguments: input.request.arguments ?? {},
},
};
@@ -1635,9 +1654,12 @@ export function toolAccessPolicyService(db: Db) {
const loaded = await loadContext(input);
if (!loaded.ok) throw new Error("Cannot record invocation for invalid tool access context");
const { ctx, redaction } = loaded;
const argumentsHash = redaction.summary.sha256 ?? sha256(input.request.arguments ?? {});
const requestIdentityHash = input.request.sideEffecting || accessDecision.decision === "require_approval"
? toolRequestIdentityHash(input.request.arguments, options.toolActionSigningSecret)
: null;
const argumentsHash = input.request.sideEffecting ? requestIdentityHash! : ctx.argumentsHash;
const idempotencyKey = input.request.idempotencyKey
?? (input.request.sideEffecting ? sideEffectIdempotencyKey(ctx, argumentsHash) : null);
?? (input.request.sideEffecting ? sideEffectIdempotencyKey(ctx, requestIdentityHash!) : null);
if (idempotencyKey) {
const [existing] = await db.select().from(toolInvocations).where(and(
eq(toolInvocations.companyId, input.companyId),
@@ -1645,6 +1667,46 @@ export function toolAccessPolicyService(db: Db) {
));
if (existing) return { invocation: existing, replayed: true, actionRequest: null };
}
if (input.request.sideEffecting && input.request.idempotencyKey == null) {
// Before complete request identities, default keys used shortened and
// redacted preview hashes. Check both historical key-redaction variants
// so upgrading cannot silently repeat a completed or uncertain write.
const legacyKeys = [...new Set([
requestIdentityHash!,
redaction.summary.sha256!,
summarizeAndRedact(input.request.arguments, false).summary.sha256!,
].map(hash => sideEffectIdempotencyKey(ctx, hash, 1)))];
const legacyInvocations = legacyKeys.length ? await db.select().from(toolInvocations).where(and(
eq(toolInvocations.companyId, input.companyId),
inArray(toolInvocations.idempotencyKey, legacyKeys),
)) : [];
for (const legacy of legacyInvocations) {
if (legacy.actorType !== ctx.actorType || legacy.actorId !== ctx.actorId || legacy.agentId !== ctx.agentId) {
throw conflict("An earlier operation belongs to another actor. Reconcile it before retrying; use a new explicit idempotency key only for an intentional new operation.",
{ reasonCode: "legacy_tool_idempotency_unverifiable" });
}
// An interim complete-identity row may still have the old key prefix.
// Its digest covers the full request when it differs from its preview.
if (legacy.argumentsSummary?.sha256 && legacy.argumentsHash === requestIdentityHash
&& legacy.argumentsHash !== legacy.argumentsSummary.sha256) {
return { invocation: legacy, replayed: true, actionRequest: null };
}
const [actionRequest] = await db.select({ signedArguments: toolActionRequests.signedArguments })
.from(toolActionRequests).where(and(eq(toolActionRequests.companyId, input.companyId),
eq(toolActionRequests.invocationId, legacy.id))).limit(1);
const original = actionRequest?.signedArguments
? readSignedToolArgumentsPayload({ signedArguments: actionRequest.signedArguments,
invocationId: legacy.id, toolName: legacy.toolName, signingSecret: options.toolActionSigningSecret })
: completeLegacyArguments(legacy);
if (!original) throw conflict(
"An earlier operation has incomplete saved arguments. Reconcile its result before retrying; use a new explicit idempotency key only for an intentional new operation.",
{ reasonCode: "legacy_tool_idempotency_unverifiable", invocationId: legacy.id },
);
if (canonicalToolArguments(original.arguments) === canonicalToolArguments(input.request.arguments)) {
return { invocation: legacy, replayed: true, actionRequest: null };
}
}
}
const status = accessDecision.decision === "allow"
? "authorized"
: accessDecision.decision === "require_approval"
@@ -1689,7 +1751,7 @@ export function toolAccessPolicyService(db: Db) {
invocationId: invocation.id,
issueId: ctx.issueId,
status: "pending",
canonicalArgumentsHash: invocation.argumentsHash ?? argumentsHash,
canonicalArgumentsHash: requestIdentityHash!,
canonicalArgumentsSummary: redaction.summary,
requestedByAgentId: ctx.actorType === "agent" ? ctx.agentId : null,
requestedByUserId: ctx.actorType === "user" ? ctx.actorId : null,
+21 -4
View File
@@ -1,3 +1,4 @@
import { restConnectorFor } from "./connectors/rest-registry.js";
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
import { createConnectionGrants } from "./tool-access/connections/grants.js";
import { createConnectionInstalls } from "./tool-access/connections/installs.js";
@@ -1803,7 +1804,24 @@ export function toolAccessService(
.orderBy(desc(toolConnections.updatedAt))
.limit(1)
: [undefined];
const retainedConnection = requestedResumeConnection ?? recoveredConnection;
let retainedConnection = requestedResumeConnection ?? recoveredConnection;
const transport = method?.transport ?? "mcp_remote";
const applicationType = transport === "rest_api" ? "rest_api"
: transport === "mcp_remote" ? "mcp_http"
: transport === "local_stdio" ? "mcp_stdio" : null;
if ((retainedConnection && retainedConnection.transport !== transport)
|| (existingApplication && applicationType && existingApplication.type !== applicationType)) {
if (requestedResumeConnection || input.applicationId) {
throw conflict("Create a separate app connection to change its connection method transport.", {
code: "connection_transport_change_not_supported",
});
}
// A new gallery connection may use another transport for the same app.
// Do not revive an archived MCP identity as REST (or the reverse): the
// application's type and any sibling connections belong to that identity.
retainedConnection = undefined;
existingApplication = null;
}
let applicationName = existingApplication?.name ?? requestedName;
let name = retainedConnection?.name ?? requestedName;
if (!existingApplication) {
@@ -1933,7 +1951,6 @@ export function toolAccessService(
"Dedicated GitHub identity requires an agent in this company",
);
}
const transport = method?.transport ?? "mcp_remote";
const credentialSource: ToolConnectionCredentialSource =
input.credentialSource ?? "paperclip_vault";
if (
@@ -2412,7 +2429,7 @@ export function toolAccessService(
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
name: applicationName,
description: safeApplicationDescription,
type: transport === "rest_api" && ["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
type: transport === "rest_api" && (["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") || Boolean(restConnectorFor({ transport, config }))) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
status: "draft",
metadata: galleryEntry
? {
@@ -4488,7 +4505,7 @@ export function toolAccessService(
companyId,
applicationKey: normalizeKey(input.applicationName ?? input.name),
name: input.applicationName ?? input.name,
type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) || restConnectorFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
status: "active",
metadata: {},
})
@@ -1,4 +1,5 @@
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { googleWorkspaceToolTarget } from "@paperclipai/shared";
import { BROWSER_USE_TOOLS } from "@paperclipai/shared";
import { isMemoryConnectorId } from "@paperclipai/shared";
@@ -291,5 +292,5 @@ export function isGoogleWorkspaceToolAllowed(
export function shouldQuarantineNewEntries(
connection: typeof toolConnections.$inferSelect,
): boolean {
return Boolean(nativeProviderCatalogFor(connection)) || asRecord(connection.config).quarantineNewEntries === true;
return Boolean(nativeProviderCatalogFor(connection) || restConnectorFor(connection)) || asRecord(connection.config).quarantineNewEntries === true;
}
@@ -6,6 +6,7 @@ import { createOAuthDiscovery } from "../oauth/discovery.js";
import { oauthConfig } from "../oauth/metadata.js";
import { projectedConnectionHeaders } from "../connection-methods.js";
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, type GoogleWorkspaceService } from "@paperclipai/shared";
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "../../connection-permission-errors.js";
import { mcpDiscoveryHttpFailure, withMcpConnectionFailure } from "../../mcp-connection-failure.js";
@@ -426,6 +427,12 @@ export function createToolCatalogDiscovery(db: Db, { now, audit, secrets, creden
assertSupportedConnection(connection);
await assertConnectorConnectionSetupEnabled(connection);
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
const restConnector = restConnectorFor(connection);
if (restConnector) {
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
await restConnector.client(connection, headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init)).probe();
return restConnector.tools;
}
const nativeProvider = nativeProviderCatalogFor(connection);
if (nativeProvider) {
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
@@ -4,6 +4,7 @@ import { createGitHubGrantContinuity } from "../github-grant-continuity.js";
import { createToolRuntimeSlots } from "../runtime/slots.js";
import type { ActorInfo } from "../types.js";
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
import { isBrowserUseConnection } from "../../browser-use-client.js";
import { and, asc, eq, isNull, lte, ne, or, sql } from "drizzle-orm";
@@ -121,7 +122,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
});
for (const grant of grantsToCheck)
await refreshManagedGitHubGrantAccess(connection, grant, actor);
} else if (isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) {
} else if (isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection) || restConnectorFor(connection)) {
await discoverTools(connection, undefined, actor);
} else if (isAgentMailConnection(connection)) {
await validateAgentMailConnection(connection);
@@ -157,6 +158,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
: config.sourceTemplateKey === "github" &&
oauth.connectorProfile === "github.code"
? "GitHub account, installation, and repository access are available."
: restConnectorFor(connection)
? `${restConnectorFor(connection)!.name} API access is connected.`
: nativeProviderCatalogFor(connection)
? `${nativeProviderCatalogFor(connection)!.name} API access is connected.`
: isBrowserUseConnection(connection)
@@ -5,6 +5,7 @@ import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
import { connectionMethodForConnection } from "../connection-methods.js";
import { stableHash, descriptorHash } from "../../tool-catalog-hash.js";
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
@@ -115,7 +116,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
const existingByName = new Map(
existingRows.map((entry) => [entry.toolName, entry]),
);
const nativeProvider = Boolean(nativeProviderCatalogFor(connection));
const nativeProvider = Boolean(nativeProviderCatalogFor(connection) || restConnectorFor(connection));
if (connection.config.sourceTemplateKey === "google-workspace"
|| nativeProvider || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
@@ -2,6 +2,7 @@ import { singleFlight } from "../single-flight.js";
import type { ActorInfo } from "../types.js";
import { descriptorHash } from "../../tool-catalog-hash.js";
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolCatalogEntries, toolConnections } from "@paperclipai/db";
@@ -30,6 +31,8 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
// connections too; an earlier catalog must not survive merely because
// REST discovery has no remote MCP cache timer.
const nativeProvider = nativeProviderCatalogFor(connection);
const restConnector = restConnectorFor(connection);
const nativeRest = Boolean(nativeProvider || restConnector);
const rowsByName = new Map(rows.map((row) => [row.toolName, row]));
const nativeCatalogChanged = Boolean(nativeProvider) && connection.status === "active" && (
rows.some((row) => row.status !== "removed" && !nativeProvider!.isToolName(row.toolName))
@@ -38,7 +41,14 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
return !existing || existing.versionHash !== descriptorHash(tool, nativeProvider!.risk(tool.name));
})
);
const cacheExpired = nativeCatalogChanged || (
const restCatalogChanged = restConnector && connection.status === "active" && (
rows.some(row => row.status !== "removed" && !restConnector.tools.some(tool => tool.name === row.toolName))
|| restConnector.tools.some(tool => {
const existing = rowsByName.get(tool.name);
return !existing || !restConnector.isCurrent(existing);
})
);
const cacheExpired = nativeCatalogChanged || restCatalogChanged || (
connection.transport === "mcp_remote" &&
connection.status !== "archived" &&
(rows.length === 0 ||
@@ -48,7 +58,7 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
if (cacheExpired) {
try {
await singleFlight(catalogRefreshFlights, connection.id, () =>
refreshCatalog(connection.id, nativeProvider && actor ? actor : {
refreshCatalog(connection.id, nativeRest && actor ? actor : {
actorType: "system",
actorId: "tool_catalog_cache",
}),
@@ -68,7 +78,9 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
// Even failed discovery must not keep retired native tools visible.
// Their database rows remain available to historical invocation records.
return rows
.filter((row) => !nativeProvider || (row.status !== "removed" && nativeProvider.isToolName(row.toolName)))
.filter((row) => !nativeRest || (row.status !== "removed" && (
nativeProvider ? nativeProvider.isToolName(row.toolName) : restConnector!.tools.some(tool => tool.name === row.toolName)
)))
.map((row) => toCatalogEntryForConnection(row, connection));
}
};
@@ -1,4 +1,5 @@
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
import { restConnectorFor } from "../../connectors/rest-registry.js";
import { isBrowserUseConnection } from "../../browser-use-client.js";
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -60,6 +61,7 @@ export function createConnectionTokenBroker(db: Db, { now, secrets, policySvc, a
);
const nativeProvider = nativeProviderCatalogFor(connection);
if (nativeProvider) throw forbidden(`${nativeProvider.name} credentials stay in the governed tool gateway and cannot be exported.`);
if (restConnectorFor(connection)) throw forbidden("Deployment connection credentials stay in the governed tool gateway and cannot be exported.");
if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported.");
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
const application = await getConnectionApplication(connection);
+114 -28
View File
@@ -1,6 +1,7 @@
import type { PromptInjectionModelOutcome, PromptInjectionDetectorMetadata } from "@paperclipai/shared";
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
import { redactEventPayload, redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
import { deriveLocalEncryptedInstanceSecret, type LocalEncryptedKeyEnv } from "../secrets/local-encrypted-provider.js";
export class ToolContentValidationError extends Error {
constructor(
@@ -20,7 +21,7 @@ const PROMPT_INJECTION_PATTERNS: Array<{ code: string; re: RegExp }> = [
{ code: "secret_exfiltration", re: /\b(exfiltrate|leak|steal|send)\b.{0,40}\b(secret|token|api[-_ ]?key|credential)s?\b/i },
];
type ToolActionSigningSecretEnv = Partial<
type ToolActionSigningSecretEnv = LocalEncryptedKeyEnv & Partial<
Record<"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET" | "PAPERCLIP_AGENT_JWT_SECRET" | "BETTER_AUTH_SECRET", string | undefined>
>;
@@ -47,10 +48,11 @@ function scanPromptInjection(value: unknown): string[] {
}
export class ToolActionSigningSecretMissingError extends Error {
constructor() {
constructor(cause?: unknown) {
super(
"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET is not configured; signed tool action approvals cannot be issued. " +
"Set PAPERCLIP_TOOL_ACTION_SIGNING_SECRET in this instance's environment (worktrees inherit it from .paperclip/.env).",
"The tool action signing key could not be loaded. Restore this instance's secrets master key " +
"or configure PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; existing approvals require their original key.",
{ cause },
);
this.name = "ToolActionSigningSecretMissingError";
}
@@ -63,10 +65,15 @@ export const TOOL_ACTION_REQUEST_SIGNING_GRACE_MS = 2 * 60 * 1000;
export function resolveToolActionSigningSecret(env: ToolActionSigningSecretEnv = process.env as ToolActionSigningSecretEnv) {
const secret = env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim();
if (!secret) {
throw new ToolActionSigningSecretMissingError();
if (secret) return secret;
try {
// Existing deployments already persist this master key with their instance
// data. Reuse its atomic publication and configured path, with a separate
// derived key for tool actions; never fall back to auth keys or process RNG.
return deriveLocalEncryptedInstanceSecret("tool-action-signing", env);
} catch (cause) {
throw new ToolActionSigningSecretMissingError(cause);
}
return secret;
}
function signingSecret(explicitSecret?: string) {
@@ -82,6 +89,86 @@ export function hashToolValue(value: unknown) {
return createHash("sha256").update(stableSerialize(value)).digest("hex");
}
export function toolRequestIdentityHash(value: unknown, explicitSigningSecret?: string) {
const canonical = canonicalToolArguments(value);
// Request identity must cover complete arguments, including secrets and text
// omitted from audit previews. Key sensitive identities so persisted hashes
// cannot be used to guess low-entropy passwords or configuration secrets.
return containsSensitiveToolArguments(canonical)
? createHmac("sha256", signingSecret(explicitSigningSecret))
.update("paperclip:tool-request-identity:v1\0").update(canonical).digest("hex")
: createHash("sha256").update(canonical).digest("hex");
}
type SignedToolArgumentsEnvelope = { payload: string; signature: string };
const TOOL_ARGUMENTS_ENCRYPTION_CONTEXT = "paperclip:tool-action-arguments:v2:A256GCM";
function toolArgumentsEncryptionKey(secret: string) {
// Separate the encryption key from the existing HS256 signing key. The
// dedicated instance signing secret already supplies high-entropy key material.
return Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), TOOL_ARGUMENTS_ENCRYPTION_CONTEXT, 32));
}
function containsSensitiveToolArguments(canonicalArguments: string) {
try {
const value: unknown = JSON.parse(canonicalArguments);
// Wrapping preserves recursive redaction for root arrays and strings too.
const redacted = redactEventPayload({ value })?.value;
return redactSensitiveText(stableSerialize(redacted)) !== stableSerialize(value);
} catch {
// Canonical arguments normally come from canonicalToolArguments. Never
// persist an unparseable caller-supplied string as an unencrypted fallback.
return true;
}
}
function encodeEncryptedToolArguments(envelope: SignedToolArgumentsEnvelope, secret: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", toolArgumentsEncryptionKey(secret), iv);
cipher.setAAD(Buffer.from(TOOL_ARGUMENTS_ENCRYPTION_CONTEXT));
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(envelope), "utf8"), cipher.final()]);
return Buffer.from(JSON.stringify({
version: 2, alg: "A256GCM", iv: iv.toString("base64url"),
ciphertext: ciphertext.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"),
}), "utf8").toString("base64url");
}
function decodeBase64Url(value: unknown): Buffer | null {
if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) return null;
const decoded = Buffer.from(value, "base64url");
return decoded.toString("base64url") === value ? decoded : null;
}
function readToolArgumentsEnvelope(signedArguments: string, explicitSecret?: string): SignedToolArgumentsEnvelope | null {
let parsed: unknown;
try { parsed = JSON.parse(Buffer.from(signedArguments, "base64url").toString("utf8")); }
catch { return null; }
if (!isPlainObject(parsed)) return null;
if (parsed.version === 1 && parsed.alg === "HS256") {
return typeof parsed.payload === "string" && typeof parsed.signature === "string"
? { payload: parsed.payload, signature: parsed.signature } : null;
}
if (parsed.version !== 2 || parsed.alg !== "A256GCM") return null;
const iv = decodeBase64Url(parsed.iv), ciphertext = decodeBase64Url(parsed.ciphertext), tag = decodeBase64Url(parsed.tag);
if (iv?.length !== 12 || !ciphertext?.length || tag?.length !== 16) return null;
// Keep missing-key configuration failures distinct from invalid signatures,
// matching the legacy reader and allowing callers to handle them as before.
const secret = signingSecret(explicitSecret);
try {
const decipher = createDecipheriv("aes-256-gcm", toolArgumentsEncryptionKey(secret), iv);
decipher.setAAD(Buffer.from(TOOL_ARGUMENTS_ENCRYPTION_CONTEXT));
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const envelope: unknown = JSON.parse(plaintext.toString("utf8"));
return isPlainObject(envelope) && typeof envelope.payload === "string" && typeof envelope.signature === "string"
? { payload: envelope.payload, signature: envelope.signature } : null;
} catch {
// Authentication failures must never include ciphertext or decrypted content
// in errors, logs or an approval response.
return null;
}
}
export function signToolArguments(args: {
invocationId: string;
toolName: string;
@@ -104,11 +191,15 @@ export function signToolArguments(args: {
payloadValue.approvalSnapshot = args.approvalSnapshot;
}
const payload = stableSerialize(payloadValue);
const signature = createHmac("sha256", signingSecret(args.signingSecret)).update(payload).digest("base64url");
const secret = signingSecret(args.signingSecret);
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
if (containsSensitiveToolArguments(args.canonicalArguments)) {
return encodeEncryptedToolArguments({ payload, signature }, secret);
}
return Buffer.from(JSON.stringify({ version: 1, alg: "HS256", payload, signature }), "utf8").toString("base64url");
}
export function verifyToolArgumentsSignature(input: {
type ToolArgumentsSignatureInput = {
signedArguments: string | null | undefined;
invocationId: string;
toolName: string;
@@ -117,16 +208,9 @@ export function verifyToolArgumentsSignature(input: {
executionOnApprove?: boolean;
identityContextId?: string;
signingSecret?: string;
}) {
if (!input.signedArguments) return false;
let parsed: { version?: unknown; alg?: unknown; payload?: unknown; signature?: unknown };
try {
parsed = JSON.parse(Buffer.from(input.signedArguments, "base64url").toString("utf8"));
} catch {
return false;
}
if (parsed.version !== 1 || parsed.alg !== "HS256") return false;
if (typeof parsed.payload !== "string" || typeof parsed.signature !== "string") return false;
};
function verifyToolArgumentsEnvelope(parsed: SignedToolArgumentsEnvelope, input: ToolArgumentsSignatureInput) {
const expectedPayloadValue: Record<string, unknown> = {
invocationId: input.invocationId,
toolName: input.toolName,
@@ -147,6 +231,12 @@ export function verifyToolArgumentsSignature(input: {
return left.length === right.length && timingSafeEqual(left, right);
}
export function verifyToolArgumentsSignature(input: ToolArgumentsSignatureInput) {
if (!input.signedArguments) return false;
const parsed = readToolArgumentsEnvelope(input.signedArguments, input.signingSecret);
return parsed !== null && verifyToolArgumentsEnvelope(parsed, input);
}
export function readSignedToolArgumentsPayload(input: {
signedArguments: string | null | undefined;
invocationId: string;
@@ -154,13 +244,8 @@ export function readSignedToolArgumentsPayload(input: {
signingSecret?: string;
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean; identityContextId?: string } | null {
if (!input.signedArguments) return null;
let parsed: { payload?: unknown };
try {
parsed = JSON.parse(Buffer.from(input.signedArguments, "base64url").toString("utf8"));
} catch {
return null;
}
if (typeof parsed.payload !== "string") return null;
const parsed = readToolArgumentsEnvelope(input.signedArguments, input.signingSecret);
if (!parsed) return null;
let payload: {
invocationId?: unknown;
toolName?: unknown;
@@ -174,9 +259,10 @@ export function readSignedToolArgumentsPayload(input: {
} catch {
return null;
}
if (!isPlainObject(payload)) return null;
if (payload.invocationId !== input.invocationId || payload.toolName !== input.toolName) return null;
if (typeof payload.canonicalArguments !== "string") return null;
if (!verifyToolArgumentsSignature({
if (!verifyToolArgumentsEnvelope(parsed, {
signedArguments: input.signedArguments,
invocationId: input.invocationId,
toolName: input.toolName,
+85 -19
View File
@@ -1,3 +1,4 @@
import { restConnectorFor } from "./connectors/rest-registry.js";
import type { runPromptInjectionProvider } from "./prompt-injection-provider.js";
import { promptInjectionPreventionService } from "./prompt-injection-prevention.js";
import { adapterExecutionControls } from "./adapter-execution-control.js";
@@ -183,6 +184,7 @@ import {
readSignedToolArgumentsPayload,
signToolArguments,
summarizeToolValue,
toolRequestIdentityHash,
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS,
ToolActionSigningSecretMissingError,
ToolContentValidationError,
@@ -1235,7 +1237,18 @@ export function createToolGatewayService(
});
const pluginToolDispatcher = options.pluginToolDispatcher;
const interactions = issueThreadInteractionService(db);
const policyService = toolAccessPolicyService(db);
const policyService = toolAccessPolicyService(db, { toolActionSigningSecret: options.toolActionSigningSecret });
async function recordToolInvocation(input: ToolAccessDecisionInput, decision: ToolAccessDecision) {
try {
return await policyService.recordInvocation(input, decision);
} catch (error) {
const details = error instanceof HttpError ? asRecord(error.details) : null;
if (error instanceof HttpError && error.status === 409 && details?.reasonCode === "legacy_tool_idempotency_unverifiable") {
throw new ToolGatewayHttpError(error.status, error.message, details.reasonCode, details);
}
throw error;
}
}
const secrets = secretService(db);
// Authentication produces a new session object for every operation. Keep
// credential acquisition scoped to that object and out of persisted inputs.
@@ -1446,8 +1459,9 @@ export function createToolGatewayService(
(!featureSettings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(application.applicationKey), featureSettings)) &&
!isRetiredComposioConnection(connection) &&
!(nativeProviderCatalogFor(connection) && !nativeProviderCatalogFor(connection)!.isCurrentEntry(catalogEntry)) &&
!(restConnectorFor(connection) && !restConnectorFor(connection)!.isCurrent(catalogEntry)) &&
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
(((isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) && application.type === "rest_api") ||
(((isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection) || restConnectorFor(connection)) && application.type === "rest_api") ||
(connection.transport === "mcp_remote" &&
application.type === "mcp_http") ||
(connection.transport === "local_stdio" &&
@@ -1473,7 +1487,7 @@ export function createToolGatewayService(
({ catalogEntry, connection, application }, index) => {
if (
connection.transport !== "mcp_remote" &&
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection)
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection) && !restConnectorFor(connection)
) {
throw new Error(
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
@@ -2407,7 +2421,7 @@ export function createToolGatewayService(
policyDecision: ToolAccessDecision;
}): Promise<never> {
const canonicalArguments = canonicalToolArguments(input.parameters);
const canonicalArgumentsHash = input.argumentsSummary.sha256 ?? "";
const canonicalArgumentsHash = toolRequestIdentityHash(input.parameters, options.toolActionSigningSecret);
const approvalSnapshot = await connectedRemoteApprovalSnapshot(
input.session,
input.tool,
@@ -3209,8 +3223,20 @@ export function createToolGatewayService(
}
}
if (tool.providerType === "paperclip_github_chat") {
const data = await githubChatReviewService(db).execute(session, tool.upstreamToolName ?? "", parameters, invocationId);
return { content: JSON.stringify(data), data };
try {
const data = await githubChatReviewService(db).execute(session, tool.upstreamToolName ?? "", parameters, invocationId);
return { content: JSON.stringify(data), data };
} catch (error) {
// Complete request identities can reach the publication owner through
// fresh execution instead of receipt replay. Preserve the same conflict
// category for changed content without reclassifying stale-head or
// assessment-coverage conflicts from that owner.
if (error instanceof HttpError && error.status === 409
&& asRecord(error.details)?.reasonCode === "github_idempotency_conflict") {
throw new ToolGatewayHttpError(error.status, error.message, "github_idempotency_conflict", asRecord(error.details) ?? {});
}
throw error;
}
}
if (tool.name === "mcp-remote-fixture:echo") {
return {
@@ -4903,7 +4929,7 @@ export function createToolGatewayService(
),
)
.limit(1);
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection))) {
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection) && !restConnectorFor(connection))) {
throw new ToolGatewayHttpError(
404,
`Tool "${tool.name}" not found`,
@@ -4930,6 +4956,10 @@ export function createToolGatewayService(
if (nativeProvider && !nativeProvider.isCurrentEntry(entry)) {
throw new ToolGatewayHttpError(409, `${nativeProvider.name}'s action catalog changed. Review the updated actions in Permissions.`, `${nativeProvider.slug}_catalog_changed`);
}
const restConnector = restConnectorFor(connection);
if (restConnector && !restConnector.isCurrent(entry)) {
throw new ToolGatewayHttpError(409, `${restConnector.name}'s action catalog changed. Review the updated actions in Permissions.`, "provider_catalog_changed");
}
return { entry, connection };
}
@@ -6070,6 +6100,17 @@ export function createToolGatewayService(
ms = railwayCommandBudgetMs(parameters);
}
const grant = await resolveConnectionGrant(session, connection);
const restConnector = restConnectorFor(connection);
if (restConnector) {
const budgetMs = Math.min(ms, 30_000);
const deadlineAt = Date.now() + budgetMs;
const signal = AbortSignal.timeout(budgetMs);
const client = restConnector.client(connection, await resolveCredentialHeaders(session, connection, grant),
(url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()), signal, deadlineAt);
const data = await client.invoke(entry.toolName, parameters);
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
}
const nativeProvider = nativeProviderCatalogFor(connection);
if (nativeProvider) {
const budgetMs = Math.min(ms, 30_000);
@@ -8178,8 +8219,12 @@ export function createToolGatewayService(
const parameters = signedPayload.arguments;
const canonicalArguments = canonicalToolArguments(parameters);
if (
claimed.canonicalArgumentsHash !==
summarizeToolValue(parameters).sha256 ||
![
toolRequestIdentityHash(parameters, options.toolActionSigningSecret),
// Historical approvals used the redacted preview hash. Their signature
// still binds the complete canonical arguments checked below.
summarizeToolValue(parameters).sha256,
].includes(claimed.canonicalArgumentsHash) ||
!verifyToolArgumentsSignature({
signedArguments: claimed.signedArguments,
invocationId: invocation.id,
@@ -8377,6 +8422,7 @@ export function createToolGatewayService(
session: ToolGatewaySession;
toolName: string;
argumentsHash: string;
parameters: unknown;
}) {
if (!input.session.issueId || !input.session.agentId) return null;
const [match] = await db
@@ -8393,7 +8439,7 @@ export function createToolGatewayService(
and(
eq(toolActionRequests.companyId, input.session.companyId),
eq(toolActionRequests.issueId, input.session.issueId),
eq(toolActionRequests.canonicalArgumentsHash, input.argumentsHash),
inArray(toolActionRequests.canonicalArgumentsHash, [input.argumentsHash, summarizeToolValue(input.parameters).sha256]),
eq(toolInvocations.agentId, input.session.agentId),
eq(toolInvocations.toolName, input.toolName),
inArray(toolActionRequests.status, [
@@ -8406,9 +8452,25 @@ export function createToolGatewayService(
]),
),
)
.orderBy(desc(toolActionRequests.createdAt))
.orderBy(
desc(sql`${toolActionRequests.canonicalArgumentsHash} = ${input.argumentsHash}`),
desc(toolActionRequests.createdAt),
)
.limit(1);
if (!match) return null;
if (match.actionRequest.signedArguments) {
const signed = readSignedToolArgumentsPayload({
signedArguments: match.actionRequest.signedArguments,
invocationId: match.invocation.id,
toolName: match.invocation.toolName,
signingSecret: options.toolActionSigningSecret,
});
// Redacted legacy hashes can collide across different secrets. Only
// replay the exact authenticated arguments, including legacy approvals.
if (!signed || canonicalToolArguments(signed.arguments) !== canonicalToolArguments(input.parameters)) return null;
} else if (match.actionRequest.canonicalArgumentsHash !== input.argumentsHash) {
return null;
}
if (match.actionRequest.status === "failed" && match.invocation.errorCode !== "provider_interaction_required") return null;
// The gateway builds an ask-first request in two steps inside one call: it
// inserts the row with a null signature and a null expiry, then signs the
@@ -8464,6 +8526,7 @@ export function createToolGatewayService(
session: ToolGatewaySession;
toolName: string;
argumentsHash: string;
parameters: unknown;
}) {
const match = await matchingAgentActionRequest(input);
if (!match) return null;
@@ -9417,7 +9480,7 @@ export function createToolGatewayService(
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
const recorded = await policyService.recordInvocation(
const recorded = await recordToolInvocation(
decisionInput,
accessDecision,
);
@@ -9437,7 +9500,7 @@ export function createToolGatewayService(
);
}
const canonicalArguments = canonicalToolArguments(requestedParameters);
const canonicalArgumentsHash = argumentValidation.summary.sha256 ?? "";
const canonicalArgumentsHash = toolRequestIdentityHash(requestedParameters, options.toolActionSigningSecret);
const approvalSnapshot = await connectedRemoteApprovalSnapshot(
session,
tool,
@@ -10146,7 +10209,7 @@ export function createToolGatewayService(
if (typeof args.idempotencyKey === "string") {
const key = `slack:${tool.connectionId}:${session.issueId}:${session.responsibleUserId}:${args.idempotencyKey}`;
const [prior] = await db.select().from(toolInvocations).where(and(eq(toolInvocations.companyId, session.companyId), eq(toolInvocations.idempotencyKey, key)));
const currentHash = (await validateToolContent({ value: args, direction: "arguments", sensitiveMode: "redact", promptInjectionMode: "ignore" })).summary.sha256;
const currentHash = toolRequestIdentityHash(args, options.toolActionSigningSecret);
if (prior && (prior.toolName !== tool.name || prior.argumentsHash !== currentHash)) throw new ToolGatewayHttpError(409, "Idempotency key belongs to a different Slack operation", "slack_idempotency_conflict");
input = { ...input, idempotencyKey: key };
}
@@ -10316,7 +10379,8 @@ export function createToolGatewayService(
const replay = await replayMatchingAgentAction({
session,
toolName: tool.name,
argumentsHash: argumentValidation.summary.sha256 ?? "",
argumentsHash: toolRequestIdentityHash(requestedParameters, options.toolActionSigningSecret),
parameters: requestedParameters,
});
if (replay?.matched) {
let replayResult: unknown = replay.result;
@@ -10649,8 +10713,10 @@ export function createToolGatewayService(
});
const storedCanonical = canonicalToolArguments(storedParameters);
if (
actionRequest.canonicalArgumentsHash !==
summarizeToolValue(storedParameters).sha256 ||
![
toolRequestIdentityHash(storedParameters, options.toolActionSigningSecret),
summarizeToolValue(storedParameters).sha256,
].includes(actionRequest.canonicalArgumentsHash) ||
!verifyToolArgumentsSignature({
signedArguments: actionRequest.signedArguments,
invocationId: storedInvocation.id,
@@ -10731,7 +10797,7 @@ export function createToolGatewayService(
if (accessDecision.allowed && tool.providerType === "paperclip_slack_chat" && SLACK_TOOLS.some(t => t.name === tool.upstreamToolName && t.risk === "approval")) {
accessDecision = { ...accessDecision, allowed: false, decision: "require_approval", reasonCode: "requires_approval_policy", explanation: "Slack destructive actions, channel creation and invitations require approval." };
}
const recorded = await policyService.recordInvocation(
const recorded = await recordToolInvocation(
decisionInput,
accessDecision,
);
@@ -11250,7 +11316,7 @@ export function createToolGatewayService(
consumeRateLimit: true,
});
const accessDecision = await policyService.decide(decisionInput);
const recorded = await policyService.recordInvocation(
const recorded = await recordToolInvocation(
decisionInput,
accessDecision,
);
+1 -1
View File
@@ -479,7 +479,7 @@
{
"slug": "vercel",
"provider": "Vercel",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/vercel.svg",
"darkAsset": "/brands/apps/vercel-dark.svg"
},