diff --git a/doc/DOCKER.md b/doc/DOCKER.md index beb6793b8f5..3c3cf90e04d 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -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 diff --git a/doc/MCP-RUNTIME-OPERATIONS.md b/doc/MCP-RUNTIME-OPERATIONS.md index ee97cd79e78..3ba7b88e040 100644 --- a/doc/MCP-RUNTIME-OPERATIONS.md +++ b/doc/MCP-RUNTIME-OPERATIONS.md @@ -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 diff --git a/doc/connections/README.md b/doc/connections/README.md index bb9022ce2e5..24231865a90 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -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 diff --git a/doc/connections/SUPABASE.md b/doc/connections/SUPABASE.md new file mode 100644 index 00000000000..20ed01c5c24 --- /dev/null +++ b/doc/connections/SUPABASE.md @@ -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: `/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. diff --git a/doc/connections/supabase-flow.mmd b/doc/connections/supabase-flow.mmd new file mode 100644 index 00000000000..d48ae5c0e81 --- /dev/null +++ b/doc/connections/supabase-flow.mmd @@ -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 diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 3d83685bbb9..2824a30f31e 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -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." } ] } diff --git a/doc/connections/vercel.md b/doc/connections/vercel.md new file mode 100644 index 00000000000..e2696ff62bb --- /dev/null +++ b/doc/connections/vercel.md @@ -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. diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md index 36b34ed7d92..e921d392b1f 100644 --- a/maps/agent-access-map/connection-setup.md +++ b/maps/agent-access-map/connection-setup.md @@ -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. diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md index 0485d87af1a..423802cd094 100644 --- a/maps/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -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. diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 5855c9d2c92..136c49378b2 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -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 }[]; diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 278ec99f7eb..aa76c332876 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -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", diff --git a/packages/shared/src/app-definitions/supabase.json b/packages/shared/src/app-definitions/supabase.json index 5580581b0b1..9a92a2f30af 100644 --- a/packages/shared/src/app-definitions/supabase.json +++ b/packages/shared/src/app-definitions/supabase.json @@ -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." ] } ] diff --git a/packages/shared/src/app-definitions/vercel.json b/packages/shared/src/app-definitions/vercel.json index 7a871d48361..b4b5e95003b 100644 --- a/packages/shared/src/app-definitions/vercel.json +++ b/packages/shared/src/app-definitions/vercel.json @@ -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" } diff --git a/scripts/app-definition-overrides/supabase.json b/scripts/app-definition-overrides/supabase.json new file mode 100644 index 00000000000..6c176328741 --- /dev/null +++ b/scripts/app-definition-overrides/supabase.json @@ -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." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/vercel.json b/scripts/app-definition-overrides/vercel.json new file mode 100644 index 00000000000..50b1d6d3d2a --- /dev/null +++ b/scripts/app-definition-overrides/vercel.json @@ -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"} + }] +} diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 4c253f0251c..c2ef1b9a34d 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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! } }); diff --git a/server/src/__tests__/deployment-connections.test.ts b/server/src/__tests__/deployment-connections.test.ts new file mode 100644 index 00000000000..d7356b4098e --- /dev/null +++ b/server/src/__tests__/deployment-connections.test.ts @@ -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: "

Hello

" }], 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; + let temp: Awaited> | 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: "

Hello

", 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); + }); +}); diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index 24f292e7df4..525f05db57d 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -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) { 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); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index be3e12337a9..97183dbc976 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -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({ diff --git a/server/src/__tests__/tool-content-guards.test.ts b/server/src/__tests__/tool-content-guards.test.ts index 94ca39f4ea5..b5e26475ca1 100644 --- a/server/src/__tests__/tool-content-guards.test.ts +++ b/server/src/__tests__/tool-content-guards.test.ts @@ -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 () => { diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 90c083ebdf1..cc804845ff6 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -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, diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index f63add3c1dd..32dcd49bef7 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -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 }) => ({ diff --git a/server/src/secrets/local-encrypted-provider.ts b/server/src/secrets/local-encrypted-provider.ts index 46f07603540..f84c5eeff4f 100644 --- a/server/src/secrets/local-encrypted-provider.ts +++ b/server/src/secrets/local-encrypted-provider.ts @@ -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>; + +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) { diff --git a/server/src/services/chat-github-reviews.ts b/server/src/services/chat-github-reviews.ts index b187982bf70..5fb2be6d1a6 100644 --- a/server/src/services/chat-github-reviews.ts +++ b/server/src/services/chat-github-reviews.ts @@ -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) diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 41cf08ceb90..442bf0155da 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -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; @@ -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, { diff --git a/server/src/services/connectors/rest-registry.ts b/server/src/services/connectors/rest-registry.ts new file mode 100644 index 00000000000..9de8d694d71 --- /dev/null +++ b/server/src/services/connectors/rest-registry.ts @@ -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 }; +type CatalogEntry = { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }; +type Request = (url: string, init: RequestInit) => Promise; +interface RestConnector { + sourceTemplateKey: string; + name: string; + matches(connection: Connection): boolean; + tools: Array<{ name: string; title?: string | null; description?: string | null; inputSchema?: Record; annotations?: Record }>; + risk(name: string): ToolRiskLevel; + isCurrent(entry: CatalogEntry): boolean; + client(connection: Connection, headers: Record, request: Request, signal?: AbortSignal, deadlineAt?: number): { + probe(): Promise; + invoke(name: string, parameters: unknown): Promise; + }; +} + +function settings(connection: Connection): Record { + const value = connection.config?.methodConfig; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +// 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)); +} diff --git a/server/src/services/connectors/supabase/index.test.ts b/server/src/services/connectors/supabase/index.test.ts new file mode 100644 index 00000000000..529252c8906 --- /dev/null +++ b/server/src/services/connectors/supabase/index.test.ts @@ -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): Promise { + 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(() => {})); + 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(() => {})); + 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); + } + }); +}); diff --git a/server/src/services/connectors/supabase/index.ts b/server/src/services/connectors/supabase/index.ts new file mode 100644 index 00000000000..bc54940f180 --- /dev/null +++ b/server/src/services/connectors/supabase/index.ts @@ -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; +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, + 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 }) { + 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 => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +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, 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(work: () => Promise): Promise { + const remaining = deadlineAt - Date.now(); + if (signal.aborted || remaining <= 0) throw expired(); + let timer: ReturnType | undefined; + let abort: () => void = () => {}; + try { + return await Promise.race([ + Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }), + new Promise((_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 { + 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 { + 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; + 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; + 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", {}) }; +} diff --git a/server/src/services/connectors/tool-policy.ts b/server/src/services/connectors/tool-policy.ts index c8444474943..d8ec483fcbb 100644 --- a/server/src/services/connectors/tool-policy.ts +++ b/server/src/services/connectors/tool-policy.ts @@ -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, diff --git a/server/src/services/connectors/vercel/catalog.ts b/server/src/services/connectors/vercel/catalog.ts new file mode 100644 index 00000000000..834a2cdd294 --- /dev/null +++ b/server/src/services/connectors/vercel/catalog.ts @@ -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, + 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 }) { + 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}/`); +} diff --git a/server/src/services/connectors/vercel/index.test.ts b/server/src/services/connectors/vercel/index.test.ts new file mode 100644 index 00000000000..ef08446a386 --- /dev/null +++ b/server/src/services/connectors/vercel/index.test.ts @@ -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: "

Preview

" }] }; +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 = () => json({})) { + const request = vi.fn(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(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: "

Preview

", 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(resolve => { markStarted = resolve; }); + const request = vi.fn(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(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(resolve => { markStarted = resolve; }); + const request = vi.fn(() => 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(resolve => { markStarted = resolve; }); + const request = vi.fn(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(); + } + }); +}); diff --git a/server/src/services/connectors/vercel/index.ts b/server/src/services/connectors/vercel/index.ts new file mode 100644 index 00000000000..ea2ac413cc6 --- /dev/null +++ b/server/src/services/connectors/vercel/index.ts @@ -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; +const MAX_BYTES = 4 * 1024 * 1024; +const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +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, 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, 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(work: () => Promise): Promise { + if (signal.aborted || Date.now() >= deadlineAt) throw deadlineError; + let abort!: () => void; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw deadlineError; return work(); }), + new Promise((_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 = {}): Promise { + 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 { + 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(); + 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 | 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 }; +} diff --git a/server/src/services/heartbeat/run-preparation.ts b/server/src/services/heartbeat/run-preparation.ts index 7c635e5cbae..c5113dd781f 100644 --- a/server/src/services/heartbeat/run-preparation.ts +++ b/server/src/services/heartbeat/run-preparation.ts @@ -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), diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts index 174a6fd4ff8..ee457686486 100644 --- a/server/src/services/native-runtime/runtime-context.ts +++ b/server/src/services/native-runtime/runtime-context.ts @@ -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, diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index ccbf9e094a2..8ebda1efeb6 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -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 = {}; 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 | 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, diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 03b7255ebbd..42645cbf3ce 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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: {}, }) diff --git a/server/src/services/tool-access/catalog/descriptors.ts b/server/src/services/tool-access/catalog/descriptors.ts index f30c518ea6c..a10c277caca 100644 --- a/server/src/services/tool-access/catalog/descriptors.ts +++ b/server/src/services/tool-access/catalog/descriptors.ts @@ -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; } diff --git a/server/src/services/tool-access/catalog/discovery.ts b/server/src/services/tool-access/catalog/discovery.ts index 8dfd90c0c19..340de2e0052 100644 --- a/server/src/services/tool-access/catalog/discovery.ts +++ b/server/src/services/tool-access/catalog/discovery.ts @@ -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); diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts index e67f21ea6ad..c008f20853e 100644 --- a/server/src/services/tool-access/catalog/health.ts +++ b/server/src/services/tool-access/catalog/health.ts @@ -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) diff --git a/server/src/services/tool-access/catalog/publication.ts b/server/src/services/tool-access/catalog/publication.ts index f8236f5c58c..113e227cbf5 100644 --- a/server/src/services/tool-access/catalog/publication.ts +++ b/server/src/services/tool-access/catalog/publication.ts @@ -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)); diff --git a/server/src/services/tool-access/catalog/reader.ts b/server/src/services/tool-access/catalog/reader.ts index 938f5042d87..ec9433bc1f2 100644 --- a/server/src/services/tool-access/catalog/reader.ts +++ b/server/src/services/tool-access/catalog/reader.ts @@ -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)); } }; diff --git a/server/src/services/tool-access/token-broker/service.ts b/server/src/services/tool-access/token-broker/service.ts index 2d914ae2030..3b2db3b1780 100644 --- a/server/src/services/tool-access/token-broker/service.ts +++ b/server/src/services/tool-access/token-broker/service.ts @@ -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); diff --git a/server/src/services/tool-content-guards.ts b/server/src/services/tool-content-guards.ts index 5364af2b37e..8ed7ee1ca31 100644 --- a/server/src/services/tool-content-guards.ts +++ b/server/src/services/tool-content-guards.ts @@ -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 = { 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, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 7bfde3b1b85..f5971156148 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -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 { 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, ); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 110ab305261..70f0ef04614 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -479,7 +479,7 @@ { "slug": "vercel", "provider": "Vercel", - "catalogVisible": false, + "catalogVisible": true, "localAsset": "/brands/apps/vercel.svg", "darkAsset": "/brands/apps/vercel-dark.svg" },