From 47bbe1aba4f2eaf46ef491eadbaac5d772e0d817 Mon Sep 17 00:00:00 2001 From: devinfoley <139239+devinfoley@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:42:25 +0000 Subject: [PATCH] feat(apps): add Tailscale connector with OAuth client health check Add the Tailscale catalog entry (rest_api, api_key) that stores an OAuth client in the vault, a server-side API client for token exchange, device listing, and auth key mint/delete, and a health check that proves the client scopes and tag ownership with one ephemeral test key. Store a redacted summary on the connection, map provider failures to actionable codes without echoing provider bodies, refuse credential export, and add brand assets, review registry entry, docs, and fixture tests. Co-Authored-By: Claude Fable 5.1 --- doc/connections/README.md | 4 +- doc/connections/TAILSCALE.md | 181 +++++++++ .../tool-method-permission-reviews.json | 17 + .../shared/src/app-definitions.generated.ts | 137 +++---- packages/shared/src/app-definitions.test.ts | 32 +- packages/shared/src/app-definitions.ts | 1 + .../shared/src/app-definitions/tailscale.json | 98 +++++ packages/shared/src/index.ts | 1 + packages/shared/src/tailscale.ts | 48 +++ scripts/ingest-app-definitions.mjs | 23 ++ server/src/__tests__/tailscale-api.test.ts | 203 ++++++++++ .../__tests__/tailscale-connection.test.ts | 260 +++++++++++++ .../src/__tests__/tool-access-service.test.ts | 2 +- server/src/services/tailscale-api.ts | 351 ++++++++++++++++++ server/src/services/tool-access.ts | 90 ++++- ui/public/brands/apps/manifest.json | 9 + ui/public/brands/apps/tailscale-dark.svg | 16 + ui/public/brands/apps/tailscale.svg | 16 + 18 files changed, 1414 insertions(+), 75 deletions(-) create mode 100644 doc/connections/TAILSCALE.md create mode 100644 packages/shared/src/app-definitions/tailscale.json create mode 100644 packages/shared/src/tailscale.ts create mode 100644 server/src/__tests__/tailscale-api.test.ts create mode 100644 server/src/__tests__/tailscale-connection.test.ts create mode 100644 server/src/services/tailscale-api.ts create mode 100644 ui/public/brands/apps/tailscale-dark.svg create mode 100644 ui/public/brands/apps/tailscale.svg diff --git a/doc/connections/README.md b/doc/connections/README.md index 829ff427b52..2467b434340 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -14,8 +14,8 @@ Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), [Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), -[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and -[Enterpret](./ENTERPRET.md). Optional credential custody: +[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), +[Enterpret](./ENTERPRET.md), and [Tailscale](./TAILSCALE.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). Post-read action: classify a new integration request, pick the right Paperclip diff --git a/doc/connections/TAILSCALE.md b/doc/connections/TAILSCALE.md new file mode 100644 index 00000000000..0784c57e41f --- /dev/null +++ b/doc/connections/TAILSCALE.md @@ -0,0 +1,181 @@ +# Tailscale + +This connection holds a Tailscale OAuth client for one tailnet. Paperclip uses +it server-side to list tailnet devices and to mint short-lived, single-use, +tagged auth keys. Later features build on it: tailnet machines as `ssh` +environments and sandboxes that join the tailnet for one run. In this version +agents get no tool from the connection. They get network reach, not control of +the tailnet. + +Neither Paperclip ID nor Paperclip Connect participates. Cloud and self-hosted +instances use the same server-side path. The instance needs outbound HTTPS to +`api.tailscale.com`. + +## Connect and configure + +Choose **Tailscale** (`tailscale`) in Apps and paste the client ID and client +secret of an OAuth client. Both values go into the Paperclip vault as company +secrets. The grant is organization-wide. The OAuth client, the API tokens +minted from it, and any auth key never reach an agent runtime, the board UI, +a log line, or the credential export path. + +Fields: + +| Field | Default | Meaning | +| --- | --- | --- | +| OAuth client ID, OAuth client secret | required | Created once in the Tailscale admin console. The secret is shown only at creation. | +| Tailnet | `-` | `-` means the tailnet that owns the OAuth client. Enter the tailnet name, such as `example.com` or `tail1234.ts.net`, only when the client belongs to a different tailnet. | +| Agent tag (Advanced) | `tag:paperclip-agent` | The tag that machines and sandboxes carry when they join the tailnet for agents. The OAuth client must be allowed to assign it. | + +## Administrator setup + +1. Add the tags and grants below to the tailnet policy file in + [Access controls](https://login.tailscale.com/admin/acls/file). Adjust the + `tag:dev-box` and `tag:internal-api` examples to the machines and services + agents should reach. + + ```json + { + "tagOwners": { + "tag:paperclip-control": ["autogroup:admin"], + "tag:paperclip-agent": ["autogroup:admin", "tag:paperclip-control"], + "tag:dev-box": ["autogroup:admin"] + }, + "grants": [ + { "src": ["tag:paperclip-control"], "dst": ["tag:dev-box"], "ip": ["tcp:22"] }, + { "src": ["tag:paperclip-agent"], "dst": ["tag:internal-api"], "ip": ["tcp:443"] } + ], + "ssh": [ + { "action": "accept", "src": ["tag:paperclip-control"], "dst": ["tag:dev-box"], "users": ["agent"] } + ] + } + ``` + +2. Create one OAuth client in + [Trust credentials](https://login.tailscale.com/admin/settings/oauth) with + the scope `auth_keys` (tags `tag:paperclip-agent` and + `tag:paperclip-control`) and the scope `devices:core`. Copy the client ID and + the secret. +3. Paste both into Apps > Tailscale > Connect and finish setup. The connection + check below runs during setup and again on every health check. +4. Enable network flow logs in the Tailscale admin console if the tailnet policy + requires an audit trail of agent traffic. + +Scopes and tags are fixed when the OAuth client is created. Paperclip cannot +widen an existing client. When the check reports a missing scope or a tag the +client cannot assign, create a new client and reconnect. + +## Connection check + +```mermaid +sequenceDiagram + participant Board as Board UI + participant Server as Paperclip server + participant Vault + participant TS as api.tailscale.com + Board->>Server: Connect (client ID, client secret, tailnet, agent tag) + Server->>Vault: store credentials.oauthClientId and credentials.oauthClientSecret + Server->>TS: POST /api/v2/oauth/token (client_credentials) + TS-->>Server: access token and granted scopes + Server->>TS: GET /api/v2/tailnet/{tailnet}/devices?fields=all + TS-->>Server: device list + Server->>TS: POST /api/v2/tailnet/{tailnet}/keys (ephemeral, single use, preauthorized, agent tag, 300 s) + TS-->>Server: key id + Server->>TS: DELETE /api/v2/tailnet/{tailnet}/keys/{id} + Server-->>Board: health ok with tailnet, scopes, tags, device count +``` + +The check proves three things in order: the client is valid, it can read +devices (`devices:core`), and it can mint keys with the agent tag (`auth_keys` +plus tag ownership). The test key is reusable `false`, ephemeral `true`, +preauthorized `true`, expiry 300 seconds, and it is deleted immediately. It is +deleted even when a later validation step fails. A failed deletion is reported +with its own code; the key still expires on its own within five minutes. + +The redacted result is stored on the connection as `config.tailscale` +(`tailnet`, `scopes`, `tags`, `deviceCount`, `checkedAt`) and shown as the +health message, for example: + +```text +Tailscale OAuth client is connected to the client's tailnet. Scopes: auth_keys, devices:core. Tags: tag:paperclip-agent. Devices visible: 12. +``` + +Failures map to fixed codes. The message names the scope, tag, or tailnet to +fix. Provider response bodies are discarded unread, because they can name +tailnets, tags, and key identifiers. + +| Code | HTTP | Cause and fix | +| --- | --- | --- | +| `tailscale_client_invalid` | 422 | The token endpoint rejected the client ID or secret. Create a new OAuth client and reconnect. | +| `tailscale_scope_devices_missing` | 422 | The token lacks `devices:core`, or the device list returned 403. Create a client with that scope. | +| `tailscale_scope_auth_keys_missing` | 422 | The token lacks `auth_keys`, or key creation was refused for a client without it. | +| `tailscale_tag_not_owned` | 422 | Key creation was refused or the issued key does not carry the agent tag. Add the tag to the client's `auth_keys` scope and to `tagOwners`. | +| `tailscale_tailnet_not_found` | 422 | The tailnet name is wrong for this client. Use `-` or the exact name. | +| `tailscale_rate_limited` | 502 | Tailscale returned 429. Retry in a minute. | +| `tailscale_unreachable` | 502 | The instance could not reach `api.tailscale.com`. | +| `tailscale_test_key_cleanup_failed` | 502 | The test key was minted but not deleted. It expires within five minutes. | +| `tailscale_request_failed` | 502 | Another provider status or an unexpected response shape. | + +## Boundaries + +- The catalog entry is `transport: rest_api` and `auth: api_key`, matched by + `config.sourceTemplateKey === "tailscale"`. It never enters the MCP gateway + and discovery returns no tools. +- `keyPlacement` is a body placement, not a header. The client is sent only in + the body of the token request, so setup writes vault secret refs and no header + projection. +- The credential export path and the agent token mint refuse this connection. +- Provider error bodies are never read. Response bodies are bounded to 16 MiB. +- Deleting devices is implemented in the API client for later lifecycle work and + is not used by the connection check. + +## Validation and live proof + +Deterministic tests: + +- `server/src/__tests__/tailscale-api.test.ts`: the REST boundary against a + fake Tailscale API. Token exchange shape, device listing, key mint and delete, + every failure code, cached tokens, deletion of the test key after a later + failure, and redaction of the client secret, token, key secret, and provider + body. +- `server/src/__tests__/tailscale-connection.test.ts`: the connection lifecycle + through the real vault and setup path on embedded Postgres. Setup runs the + check, stores the redacted summary, keeps secrets out of config and health + fields, maps a provider refusal to an actionable 422, and refuses credential + export. +- `packages/shared/src/app-definitions.test.ts`: catalog shape, fields, + defaults, placement, and URL match. + +Live proof is not run yet. The connection was built from the official +[OAuth client documentation](https://tailscale.com/kb/1215/oauth-clients) and +the official Go client (`tailscale-client-go-v2`). To record it: + +1. Create the OAuth client described above in a test tailnet. +2. Paste it into Apps > Tailscale > Connect and finish setup. +3. Copy the health message and the `tool_connection.health_check` audit row + into this section, with the tailnet name and device count redacted. +4. Confirm in the Tailscale admin console under Settings > Keys that no + `Paperclip connection check` key remains. + +Record the result as a dated **Verification record** section here and set +`liveProof` for `tailscale/oauth-client` in +`doc/connections/tool-method-permission-reviews.json`. + +## Provider research + +Reviewed on 2026-10-09: + +- [OAuth clients](https://tailscale.com/kb/1215/oauth-clients): client + credentials grant at `POST https://api.tailscale.com/api/v2/oauth/token`, + scopes fixed at creation, tags attached to `auth_keys`. +- [Tailscale API](https://tailscale.com/api): device list with `fields=all`, + key creation body (`capabilities.devices.create` with `reusable`, + `ephemeral`, `preauthorized`, `tags`, plus `expirySeconds` and + `description`), key deletion, device deletion. The key secret is returned + only by the create response. +- [Go client](https://github.com/tailscale/tailscale-client-go-v2): confirms + the request and response shapes above and that `-` names the tailnet that + owns the client. +- Official artwork: the mark from `https://tailscale.com/favicon.svg`, + re-authored as a sanitized local SVG with a dark variant; provenance is in + the app brand manifest. diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index a3618c46d06..a3e943bf324 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -210,6 +210,23 @@ "liveProof": "not-run", "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." }, + { + "app": "tailscale", + "method": "oauth-client", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Server-side only: exchange the OAuth client for a token, list tailnet devices, mint and delete short-lived tagged auth keys, and delete devices Paperclip created. No agent-facing tools in this version.", + "evidence": [ + "https://tailscale.com/kb/1215/oauth-clients", + "https://tailscale.com/api", + "https://github.com/tailscale/tailscale-client-go-v2" + ], + "reviewedAt": "2026-10-09", + "liveProof": "not-run", + "providerRestrictions": "The OAuth client fixes its scopes and tags at creation. Paperclip needs auth_keys (with the tags agents carry) and devices:core; it cannot widen an existing client. Tags must be owned by the client under the tailnet policy tagOwners." + }, { "app": "candid", "method": "mcp-oauth", diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index a3d7fa86773..fddc29bcff0 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -23,72 +23,73 @@ import a21 from "./app-definitions/vercel.json" with { type: "json" }; import a22 from "./app-definitions/enterpret.json" with { type: "json" }; import a23 from "./app-definitions/anthropic.json" with { type: "json" }; import a24 from "./app-definitions/browser-use-cloud.json" with { type: "json" }; -import a25 from "./app-definitions/cognee.json" with { type: "json" }; -import a26 from "./app-definitions/jira.json" with { type: "json" }; -import a27 from "./app-definitions/airtable.json" with { type: "json" }; -import a28 from "./app-definitions/beehiiv.json" with { type: "json" }; -import a29 from "./app-definitions/bitly.json" with { type: "json" }; -import a30 from "./app-definitions/candid.json" with { type: "json" }; -import a31 from "./app-definitions/cloudflare.json" with { type: "json" }; -import a32 from "./app-definitions/cloudinary.json" with { type: "json" }; -import a33 from "./app-definitions/coda.json" with { type: "json" }; -import a34 from "./app-definitions/hugging-face.json" with { type: "json" }; -import a35 from "./app-definitions/kernel.json" with { type: "json" }; -import a36 from "./app-definitions/local-falcon.json" with { type: "json" }; -import a37 from "./app-definitions/make.json" with { type: "json" }; -import a38 from "./app-definitions/manufact.json" with { type: "json" }; -import a39 from "./app-definitions/miro.json" with { type: "json" }; -import a40 from "./app-definitions/netlify.json" with { type: "json" }; -import a41 from "./app-definitions/oreilly.json" with { type: "json" }; -import a42 from "./app-definitions/planetscale.json" with { type: "json" }; -import a43 from "./app-definitions/resend.json" with { type: "json" }; -import a44 from "./app-definitions/ticktick.json" with { type: "json" }; -import a45 from "./app-definitions/todoist.json" with { type: "json" }; -import a46 from "./app-definitions/webflow.json" with { type: "json" }; -import a47 from "./app-definitions/wix.json" with { type: "json" }; -import a48 from "./app-definitions/brex.json" with { type: "json" }; -import a49 from "./app-definitions/clickhouse.json" with { type: "json" }; -import a50 from "./app-definitions/egnyte.json" with { type: "json" }; -import a51 from "./app-definitions/embat.json" with { type: "json" }; -import a52 from "./app-definitions/mixpanel.json" with { type: "json" }; -import a53 from "./app-definitions/postman.json" with { type: "json" }; -import a54 from "./app-definitions/razorpay.json" with { type: "json" }; -import a55 from "./app-definitions/sanity.json" with { type: "json" }; -import a56 from "./app-definitions/stripe.json" with { type: "json" }; -import a57 from "./app-definitions/supabase.json" with { type: "json" }; -import a58 from "./app-definitions/ticket-tailor.json" with { type: "json" }; -import a59 from "./app-definitions/asana.json" with { type: "json" }; -import a60 from "./app-definitions/box.json" with { type: "json" }; -import a61 from "./app-definitions/mem0.json" with { type: "json" }; -import a62 from "./app-definitions/pagerduty.json" with { type: "json" }; -import a63 from "./app-definitions/similarweb.json" with { type: "json" }; -import a64 from "./app-definitions/xero.json" with { type: "json" }; -import a65 from "./app-definitions/youcom.json" with { type: "json" }; -import a66 from "./app-definitions/fireflies.json" with { type: "json" }; -import a67 from "./app-definitions/zep.json" with { type: "json" }; -import a68 from "./app-definitions/supermemory.json" with { type: "json" }; -import a69 from "./app-definitions/honcho.json" with { type: "json" }; -import a70 from "./app-definitions/neon.json" with { type: "json" }; -import a71 from "./app-definitions/superagent.json" with { type: "json" }; -import a72 from "./app-definitions/telem.json" with { type: "json" }; -import a73 from "./app-definitions/gauge.json" with { type: "json" }; -import a74 from "./app-definitions/gmail.json" with { type: "json" }; -import a75 from "./app-definitions/google-drive.json" with { type: "json" }; -import a76 from "./app-definitions/google-docs.json" with { type: "json" }; -import a77 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a78 from "./app-definitions/google-slides.json" with { type: "json" }; -import a79 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a80 from "./app-definitions/google-chat.json" with { type: "json" }; -import a81 from "./app-definitions/google-people.json" with { type: "json" }; -import a82 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a83 from "./app-definitions/openai.json" with { type: "json" }; -import a84 from "./app-definitions/openrouter.json" with { type: "json" }; -import a85 from "./app-definitions/xai.json" with { type: "json" }; -import a86 from "./app-definitions/google.json" with { type: "json" }; -import a87 from "./app-definitions/bedrock.json" with { type: "json" }; -import a88 from "./app-definitions/responses-api.json" with { type: "json" }; -import a89 from "./app-definitions/messages-api.json" with { type: "json" }; -import a90 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a91 from "./app-definitions/local.json" with { type: "json" }; +import a25 from "./app-definitions/tailscale.json" with { type: "json" }; +import a26 from "./app-definitions/cognee.json" with { type: "json" }; +import a27 from "./app-definitions/jira.json" with { type: "json" }; +import a28 from "./app-definitions/airtable.json" with { type: "json" }; +import a29 from "./app-definitions/beehiiv.json" with { type: "json" }; +import a30 from "./app-definitions/bitly.json" with { type: "json" }; +import a31 from "./app-definitions/candid.json" with { type: "json" }; +import a32 from "./app-definitions/cloudflare.json" with { type: "json" }; +import a33 from "./app-definitions/cloudinary.json" with { type: "json" }; +import a34 from "./app-definitions/coda.json" with { type: "json" }; +import a35 from "./app-definitions/hugging-face.json" with { type: "json" }; +import a36 from "./app-definitions/kernel.json" with { type: "json" }; +import a37 from "./app-definitions/local-falcon.json" with { type: "json" }; +import a38 from "./app-definitions/make.json" with { type: "json" }; +import a39 from "./app-definitions/manufact.json" with { type: "json" }; +import a40 from "./app-definitions/miro.json" with { type: "json" }; +import a41 from "./app-definitions/netlify.json" with { type: "json" }; +import a42 from "./app-definitions/oreilly.json" with { type: "json" }; +import a43 from "./app-definitions/planetscale.json" with { type: "json" }; +import a44 from "./app-definitions/resend.json" with { type: "json" }; +import a45 from "./app-definitions/ticktick.json" with { type: "json" }; +import a46 from "./app-definitions/todoist.json" with { type: "json" }; +import a47 from "./app-definitions/webflow.json" with { type: "json" }; +import a48 from "./app-definitions/wix.json" with { type: "json" }; +import a49 from "./app-definitions/brex.json" with { type: "json" }; +import a50 from "./app-definitions/clickhouse.json" with { type: "json" }; +import a51 from "./app-definitions/egnyte.json" with { type: "json" }; +import a52 from "./app-definitions/embat.json" with { type: "json" }; +import a53 from "./app-definitions/mixpanel.json" with { type: "json" }; +import a54 from "./app-definitions/postman.json" with { type: "json" }; +import a55 from "./app-definitions/razorpay.json" with { type: "json" }; +import a56 from "./app-definitions/sanity.json" with { type: "json" }; +import a57 from "./app-definitions/stripe.json" with { type: "json" }; +import a58 from "./app-definitions/supabase.json" with { type: "json" }; +import a59 from "./app-definitions/ticket-tailor.json" with { type: "json" }; +import a60 from "./app-definitions/asana.json" with { type: "json" }; +import a61 from "./app-definitions/box.json" with { type: "json" }; +import a62 from "./app-definitions/mem0.json" with { type: "json" }; +import a63 from "./app-definitions/pagerduty.json" with { type: "json" }; +import a64 from "./app-definitions/similarweb.json" with { type: "json" }; +import a65 from "./app-definitions/xero.json" with { type: "json" }; +import a66 from "./app-definitions/youcom.json" with { type: "json" }; +import a67 from "./app-definitions/fireflies.json" with { type: "json" }; +import a68 from "./app-definitions/zep.json" with { type: "json" }; +import a69 from "./app-definitions/supermemory.json" with { type: "json" }; +import a70 from "./app-definitions/honcho.json" with { type: "json" }; +import a71 from "./app-definitions/neon.json" with { type: "json" }; +import a72 from "./app-definitions/superagent.json" with { type: "json" }; +import a73 from "./app-definitions/telem.json" with { type: "json" }; +import a74 from "./app-definitions/gauge.json" with { type: "json" }; +import a75 from "./app-definitions/gmail.json" with { type: "json" }; +import a76 from "./app-definitions/google-drive.json" with { type: "json" }; +import a77 from "./app-definitions/google-docs.json" with { type: "json" }; +import a78 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a79 from "./app-definitions/google-slides.json" with { type: "json" }; +import a80 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a81 from "./app-definitions/google-chat.json" with { type: "json" }; +import a82 from "./app-definitions/google-people.json" with { type: "json" }; +import a83 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a84 from "./app-definitions/openai.json" with { type: "json" }; +import a85 from "./app-definitions/openrouter.json" with { type: "json" }; +import a86 from "./app-definitions/xai.json" with { type: "json" }; +import a87 from "./app-definitions/google.json" with { type: "json" }; +import a88 from "./app-definitions/bedrock.json" with { type: "json" }; +import a89 from "./app-definitions/responses-api.json" with { type: "json" }; +import a90 from "./app-definitions/messages-api.json" with { type: "json" }; +import a91 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a92 from "./app-definitions/local.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 3687d047dea..5b48ebe9345 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -225,6 +225,36 @@ describe("AppDefinition catalog", () => { expect(APP_DEFINITIONS.some(app => app.slug === "browser-use")).toBe(false); expect(getAppDefinitionForUrl("https://cloud.browser-use.com/agents")?.slug).toBe("browser-use-cloud"); }); + it("ships Tailscale as a server-side REST OAuth-client credential without agent tools", () => { + const tailscale = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "tailscale")!; + expect(tailscale).toMatchObject({ + name: "Tailscale", + branding: { logoUrl: "/brands/apps/tailscale.svg", darkLogoUrl: "/brands/apps/tailscale-dark.svg" }, + }); + expect(tailscale.methods.map((candidate) => candidate.key)).toEqual(["oauth-client"]); + const oauthClient = tailscale.methods[0]!; + expect(oauthClient).toMatchObject({ + transport: "rest_api", + auth: "api_key", + grantKinds: ["organization"], + ownershipModes: ["customer"], + riskTier: "S3", + defaults: { serverUrl: "https://api.tailscale.com/api/v2" }, + }); + expect(oauthClient.credentialFields?.map((field) => [field.key, field.type, field.secret, field.required])).toEqual([ + ["oauthClientId", "password", true, true], + ["oauthClientSecret", "password", true, true], + ]); + // Neither field is a request header: the client is exchanged in the token + // request body, so only vault secret refs are written at setup. + expect(oauthClient.keyPlacement?.location).not.toBe("header"); + expect(oauthClient.tenantFields?.map((field) => [field.key, field.defaultValue, field.advanced ?? false])).toEqual([ + ["tailnet", "-", false], + ["agentTag", "tag:paperclip-agent", true], + ]); + expect(oauthClient.consoleLinks?.keys).toBe("https://login.tailscale.com/admin/settings/oauth"); + expect(getAppDefinitionForUrl("https://login.tailscale.com/admin/machines")?.slug).toBe("tailscale"); + }); it("offers Anthropic runtime authentication without the unsupported REST tool method", () => { const anthropic = APP_DEFINITIONS.find((app) => app.slug === "anthropic")!; expect(anthropic.methods.map((method) => method.key)).toEqual(["ai-subscription", "ai-api_key"]); @@ -845,7 +875,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(69); + expect(APP_STORE_DEFINITIONS).toHaveLength(70); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 4f0a1e6c894..cb94afcc2f4 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -8,6 +8,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "responses-api", "messages-api", "chat-completions-api", "local", "agentmail", "browser-use-cloud", + "tailscale", "cognee", ...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug), "enterpret", diff --git a/packages/shared/src/app-definitions/tailscale.json b/packages/shared/src/app-definitions/tailscale.json new file mode 100644 index 00000000000..524f13ef041 --- /dev/null +++ b/packages/shared/src/app-definitions/tailscale.json @@ -0,0 +1,98 @@ +{ + "schemaVersion": 1, + "slug": "tailscale", + "name": "Tailscale", + "description": "Reach tailnet machines and private services from agent environments.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/tailscale.svg", + "darkLogoUrl": "/brands/apps/tailscale-dark.svg" + }, + "urlPatterns": [ + "https://login.tailscale.com/*" + ], + "methods": [ + { + "key": "oauth-client", + "transport": "rest_api", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Connect the tailnet whose machines and services agents should reach.", + "defaults": { + "serverUrl": "https://api.tailscale.com/api/v2" + }, + "guidanceMd": "Create an OAuth client in [Tailscale trust credentials](https://login.tailscale.com/admin/settings/oauth) with the `auth_keys` scope (tags `tag:paperclip-agent` and `tag:paperclip-control`) and the `devices:core` scope, then paste its client ID and secret. Paperclip verifies the client server-side by listing devices and minting one short-lived ephemeral test key that it deletes immediately. The client never leaves the server.", + "riskTier": "S3", + "label": "Tailscale OAuth client", + "grantKinds": [ + "organization" + ], + "credentialFields": [ + { + "key": "oauthClientId", + "label": "OAuth client ID", + "type": "password", + "required": true, + "placeholder": "k1234567CNTRL", + "secret": true, + "helperMd": "Copy the client ID shown after you create the OAuth client." + }, + { + "key": "oauthClientSecret", + "label": "OAuth client secret", + "type": "password", + "required": true, + "placeholder": "tskey-client-…", + "secret": true, + "helperMd": "The secret is shown once. It needs the `auth_keys` scope with the tags agents will carry and the `devices:core` scope. Paperclip cannot add scopes or tags to an existing client; create a new client and reconnect when access is insufficient." + } + ], + "keyPlacement": { + "location": "body_json", + "name": "client_secret" + }, + "tenantFields": [ + { + "key": "tailnet", + "label": "Tailnet", + "type": "text", + "required": false, + "defaultValue": "-", + "placeholder": "-", + "helperMd": "Keep `-` for the tailnet that owns the OAuth client, or enter the tailnet name such as `example.com` or `tail1234.ts.net`.", + "validation": { + "pattern": "^(-|[A-Za-z0-9.@_-]+)$", + "maxLength": 253 + } + }, + { + "key": "agentTag", + "label": "Agent tag", + "type": "text", + "required": true, + "advanced": true, + "defaultValue": "tag:paperclip-agent", + "placeholder": "tag:paperclip-agent", + "helperMd": "Tag assigned to machines and sandboxes that join the tailnet for agents. The OAuth client must be allowed to assign it.", + "validation": { + "pattern": "^tag:[A-Za-z0-9_-]+$", + "maxLength": 128 + } + } + ], + "consoleLinks": { + "keys": "https://login.tailscale.com/admin/settings/oauth", + "docs": "https://tailscale.com/kb/1215/oauth-clients" + }, + "warnings": [ + "Agents get network reach through this connection; they do not get tools that change the tailnet in this version." + ] + } + ], + "docsUrl": "https://tailscale.com/kb/1215/oauth-clients" +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a94a156b7fe..8cabbc14b62 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2835,6 +2835,7 @@ export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCan export { isHeartbeatRunVisibleInMine } from "./heartbeat-inbox.js"; export * from "./browser-use.js"; +export * from "./tailscale.js"; export * from "./types/skill-source.js"; export * from "./validators/skill-source.js"; diff --git a/packages/shared/src/tailscale.ts b/packages/shared/src/tailscale.ts new file mode 100644 index 00000000000..ddbb04074b1 --- /dev/null +++ b/packages/shared/src/tailscale.ts @@ -0,0 +1,48 @@ +/** + * Tailscale connector contract shared by the server and the board UI. + * + * The connector is a server-side REST credential: the OAuth client lives in the + * instance vault, the server exchanges it for short-lived API tokens, and no + * agent-facing tool is exposed in this version. See doc/connections/TAILSCALE.md. + */ +export const TAILSCALE_APP_SLUG = "tailscale"; +export const TAILSCALE_METHOD_KEY = "oauth-client"; +export const TAILSCALE_API_URL = "https://api.tailscale.com/api/v2"; +/** `-` asks the API for the tailnet that owns the authenticated OAuth client. */ +export const TAILSCALE_DEFAULT_TAILNET = "-"; +export const TAILSCALE_DEFAULT_AGENT_TAG = "tag:paperclip-agent"; +/** Lifetime of the one-off ephemeral key minted by the health check. */ +export const TAILSCALE_TEST_KEY_EXPIRY_SECONDS = 300; + +/** Scopes that let the client read the tailnet device list. */ +export const TAILSCALE_DEVICE_READ_SCOPES = ["all", "all:read", "devices", "devices:core", "devices:core:read"] as const; +/** Scopes that let the client mint and delete auth keys. */ +export const TAILSCALE_AUTH_KEY_WRITE_SCOPES = ["all", "auth_keys"] as const; + +export const TAILSCALE_HEALTH_CODES = [ + "tailscale_client_invalid", + "tailscale_scope_devices_missing", + "tailscale_scope_auth_keys_missing", + "tailscale_tag_not_owned", + "tailscale_tailnet_not_found", + "tailscale_rate_limited", + "tailscale_unreachable", + "tailscale_test_key_cleanup_failed", + "tailscale_request_failed", +] as const; +export type TailscaleHealthCode = (typeof TAILSCALE_HEALTH_CODES)[number]; + +/** Redacted summary persisted on the connection after a successful check. */ +export interface TailscaleConnectionHealth { + tailnet: string; + /** Scopes granted to the token, as reported by the token endpoint. */ + scopes: string[]; + /** Tags the client was able to assign to a test key. */ + tags: string[]; + deviceCount: number; + checkedAt: string; +} + +export function tailscaleScopeCovers(scopes: readonly string[], accepted: readonly string[]): boolean { + return scopes.some((scope) => accepted.includes(scope)); +} diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 9191eafa9dc..5f8014e6c98 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -922,6 +922,29 @@ const apps = [ }), { docsUrl: "https://docs.browser-use.com/cloud/api-v4-overview" }, ], + ["tailscale", "Tailscale", "Reach tailnet machines and private services from agent environments.", "developer", "tailscale.com", ["https://login.tailscale.com/*"], + method("oauth-client", "rest_api", "api_key", { serverUrl: "https://api.tailscale.com/api/v2" }, "S3", + "Create an OAuth client in [Tailscale trust credentials](https://login.tailscale.com/admin/settings/oauth) with the `auth_keys` scope (tags `tag:paperclip-agent` and `tag:paperclip-control`) and the `devices:core` scope, then paste its client ID and secret. Paperclip verifies the client server-side by listing devices and minting one short-lived ephemeral test key that it deletes immediately. The client never leaves the server.", { + label: "Tailscale OAuth client", + whenToUse: "Connect the tailnet whose machines and services agents should reach.", + grantKinds: ["organization"], + credentialFields: [ + { ...field("oauthClientId", "OAuth client ID", "k1234567CNTRL"), helperMd: "Copy the client ID shown after you create the OAuth client." }, + { ...field("oauthClientSecret", "OAuth client secret", "tskey-client-…"), helperMd: "The secret is shown once. It needs the `auth_keys` scope with the tags agents will carry and the `devices:core` scope. Paperclip cannot add scopes or tags to an existing client; create a new client and reconnect when access is insufficient." }, + ], + // The client is exchanged for a token in the form body of the token + // request; it is never a request header. A non-header placement keeps + // the two fields as vault secret refs only, without header projections. + keyPlacement: { location: "body_json", name: "client_secret" }, + tenantFields: [ + { key: "tailnet", label: "Tailnet", type: "text", required: false, defaultValue: "-", placeholder: "-", helperMd: "Keep `-` for the tailnet that owns the OAuth client, or enter the tailnet name such as `example.com` or `tail1234.ts.net`.", validation: { pattern: "^(-|[A-Za-z0-9.@_-]+)$", maxLength: 253 } }, + { key: "agentTag", label: "Agent tag", type: "text", required: true, advanced: true, defaultValue: "tag:paperclip-agent", placeholder: "tag:paperclip-agent", helperMd: "Tag assigned to machines and sandboxes that join the tailnet for agents. The OAuth client must be allowed to assign it.", validation: { pattern: "^tag:[A-Za-z0-9_-]+$", maxLength: 128 } }, + ], + consoleLinks: { keys: "https://login.tailscale.com/admin/settings/oauth", docs: "https://tailscale.com/kb/1215/oauth-clients" }, + warnings: ["Agents get network reach through this connection; they do not get tools that change the tailnet in this version."], + }), + { docsUrl: "https://tailscale.com/kb/1215/oauth-clients" }, + ], ].map( ([ slug, diff --git a/server/src/__tests__/tailscale-api.test.ts b/server/src/__tests__/tailscale-api.test.ts new file mode 100644 index 00000000000..4e18a94055a --- /dev/null +++ b/server/src/__tests__/tailscale-api.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { TailscaleApiError, isTailscaleConnection, tailscaleApi, tailscaleHealthMessage } from "../services/tailscale-api.js"; + +const CLIENT_ID = "kFIXTURECNTRL"; +const CLIENT_SECRET = "tskey-client-fixture-secret-value"; +const TOKEN = "tskey-api-fixture-access-token"; +const TEST_KEY_SECRET = "tskey-auth-fixture-minted-key"; +const PROVIDER_BODY = "private tailnet detail: fixture.example.ts.net tag:secret-tag"; + +type Behavior = { + tokenStatus?: number; + scope?: string; + devicesStatus?: number; + createStatus?: number; + deleteStatus?: number; + keyTags?: string[]; + keyBody?: unknown; + unreachable?: boolean; +}; + +function fakeTailscale(behavior: Behavior = {}) { + const calls: { method: string; path: string; headers: Headers; body: string | undefined }[] = []; + const request = vi.fn(async (url: string, init: RequestInit) => { + const parsed = new URL(url); + const path = parsed.pathname.replace("/api/v2", "") + parsed.search; + calls.push({ method: init.method ?? "GET", path, headers: new Headers(init.headers), body: typeof init.body === "string" ? init.body : undefined }); + if (behavior.unreachable) throw new Error(`connect ECONNREFUSED ${url} ${PROVIDER_BODY}`); + if (path === "/oauth/token") { + if (behavior.tokenStatus) return Response.json({ message: PROVIDER_BODY }, { status: behavior.tokenStatus }); + return Response.json({ access_token: TOKEN, token_type: "Bearer", expires_in: 3600, scope: behavior.scope ?? "auth_keys devices:core" }); + } + if (new Headers(init.headers).get("Authorization") !== `Bearer ${TOKEN}`) return Response.json({ message: PROVIDER_BODY }, { status: 401 }); + if (path === "/tailnet/-/devices?fields=all" || path === "/tailnet/example.com/devices?fields=all") { + if (behavior.devicesStatus) return Response.json({ message: PROVIDER_BODY }, { status: behavior.devicesStatus }); + return Response.json({ devices: [{ nodeId: "nFIXTURE1", name: "devbox.fixture.example.ts.net", addresses: ["100.64.0.7"], tags: ["tag:dev-box"], os: "linux" }, { nodeId: "nFIXTURE2", name: "laptop.fixture.example.ts.net", addresses: ["100.64.0.8"] }] }); + } + if ((path === "/tailnet/-/keys" || path === "/tailnet/example.com/keys") && init.method === "POST") { + if (behavior.createStatus) return Response.json({ message: PROVIDER_BODY }, { status: behavior.createStatus }); + if (behavior.keyBody !== undefined) return Response.json(behavior.keyBody); + const requested = JSON.parse(String(init.body)) as { capabilities: { devices: { create: { tags: string[] } } } }; + return Response.json({ + id: "kTESTKEY1", + key: TEST_KEY_SECRET, + created: "2026-10-09T10:00:00Z", + expires: "2026-10-09T10:05:00Z", + capabilities: { devices: { create: { reusable: false, ephemeral: true, preauthorized: true, tags: behavior.keyTags ?? requested.capabilities.devices.create.tags } } }, + description: "Paperclip connection check (deleted immediately)", + }); + } + if (path.startsWith("/tailnet/") && path.includes("/keys/") && init.method === "DELETE") { + if (behavior.deleteStatus) return Response.json({ message: PROVIDER_BODY }, { status: behavior.deleteStatus }); + return new Response(null, { status: 200 }); + } + if (path.startsWith("/device/") && init.method === "DELETE") return new Response(null, { status: 200 }); + throw new Error(`Unexpected fixture path ${init.method} ${path}`); + }); + return { request, calls }; +} + +const api = (behavior: Behavior = {}, tailnet?: string) => { + const fake = fakeTailscale(behavior); + return { ...fake, client: tailscaleApi({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, tailnet, request: fake.request }) }; +}; + +async function failure(promise: Promise): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(TailscaleApiError); + return error as TailscaleApiError; + } + throw new Error("expected the Tailscale call to fail"); +} + +describe("Tailscale API boundary", () => { + it("recognizes only the catalog's REST connection", () => { + expect(isTailscaleConnection({ transport: "rest_api", config: { sourceTemplateKey: "tailscale" } })).toBe(true); + expect(isTailscaleConnection({ transport: "mcp_remote", config: { sourceTemplateKey: "tailscale" } })).toBe(false); + expect(isTailscaleConnection({ transport: "rest_api", config: { sourceTemplateKey: "browser-use-cloud" } })).toBe(false); + }); + + it("exchanges the OAuth client once, lists devices, and mints then deletes an ephemeral tagged test key", async () => { + const { client, calls } = api(); + const health = await client.verify({ agentTag: "tag:paperclip-agent" }); + expect(health).toMatchObject({ tailnet: "-", scopes: ["auth_keys", "devices:core"], tags: ["tag:paperclip-agent"], deviceCount: 2 }); + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ + "POST /oauth/token", + "GET /tailnet/-/devices?fields=all", + "POST /tailnet/-/keys", + "DELETE /tailnet/-/keys/kTESTKEY1", + ]); + const token = calls[0]!; + expect(token.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + expect(new URLSearchParams(token.body).get("grant_type")).toBe("client_credentials"); + expect(new URLSearchParams(token.body).get("client_id")).toBe(CLIENT_ID); + expect(new URLSearchParams(token.body).get("client_secret")).toBe(CLIENT_SECRET); + expect(JSON.parse(calls[2]!.body!)).toEqual({ + capabilities: { devices: { create: { reusable: false, ephemeral: true, preauthorized: true, tags: ["tag:paperclip-agent"] } } }, + expirySeconds: 300, + description: "Paperclip connection check (deleted immediately)", + }); + const message = tailscaleHealthMessage(health); + expect(message).toContain("Scopes: auth_keys, devices:core"); + expect(message).toContain("Tags: tag:paperclip-agent"); + for (const text of [JSON.stringify(health), message]) { + expect(text).not.toContain(CLIENT_SECRET); + expect(text).not.toContain(TOKEN); + expect(text).not.toContain(TEST_KEY_SECRET); + } + }); + + it("addresses a named tailnet and reuses the cached token across calls", async () => { + const { client, calls } = api({}, "example.com"); + await client.listDevices(); + await client.deleteDevice("nFIXTURE1"); + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ + "POST /oauth/token", + "GET /tailnet/example.com/devices?fields=all", + "DELETE /device/nFIXTURE1", + ]); + }); + + it.each([400, 401, 403])("maps a rejected client credential exchange (%s) to an actionable code", async (status) => { + const error = await failure(api({ tokenStatus: status }).client.verify()); + expect(error).toMatchObject({ status: 422, code: "tailscale_client_invalid", operation: "token", providerStatus: status }); + expect(error.message).not.toContain(PROVIDER_BODY); + }); + + it("reports a missing devices:core scope from the token scopes before touching the tailnet", async () => { + const { client, calls } = api({ scope: "auth_keys" }); + const error = await failure(client.verify()); + expect(error).toMatchObject({ status: 422, code: "tailscale_scope_devices_missing" }); + expect(calls).toHaveLength(1); + }); + + it("reports a missing auth_keys scope from the token scopes before minting anything", async () => { + const { client, calls } = api({ scope: "devices:core:read" }); + const error = await failure(client.verify()); + expect(error).toMatchObject({ status: 422, code: "tailscale_scope_auth_keys_missing" }); + expect(calls.filter((call) => call.method === "POST" && call.path.endsWith("/keys"))).toHaveLength(0); + }); + + it("falls back to provider responses when the token endpoint omits scopes", async () => { + const devices = await failure(api({ scope: "", devicesStatus: 403 }).client.verify()); + expect(devices).toMatchObject({ code: "tailscale_scope_devices_missing", operation: "list_devices", providerStatus: 403 }); + const tailnet = await failure(api({ scope: "", devicesStatus: 404 }, "example.com").client.verify()); + expect(tailnet).toMatchObject({ code: "tailscale_tailnet_not_found" }); + expect(tailnet.message).toContain("example.com"); + expect(tailnet.message).not.toContain(PROVIDER_BODY); + }); + + it("distinguishes a tag the client cannot assign from a missing auth_keys scope", async () => { + const tag = await failure(api({ createStatus: 403 }).client.verify({ agentTag: "tag:paperclip-agent" })); + expect(tag).toMatchObject({ status: 422, code: "tailscale_tag_not_owned", operation: "create_key" }); + expect(tag.message).toContain("tag:paperclip-agent"); + expect(tag.message).not.toContain(PROVIDER_BODY); + const scope = await failure(api({ scope: "all:read", createStatus: 403 }).client.verify()); + expect(scope).toMatchObject({ code: "tailscale_scope_auth_keys_missing" }); + }); + + it("deletes the test key even when validating it fails afterwards", async () => { + const { client, calls } = api({ keyTags: ["tag:other"] }); + const error = await failure(client.verify({ agentTag: "tag:paperclip-agent" })); + expect(error).toMatchObject({ code: "tailscale_tag_not_owned" }); + expect(calls.map((call) => `${call.method} ${call.path}`)).toContain("DELETE /tailnet/-/keys/kTESTKEY1"); + }); + + it("reports a test key that could not be deleted without hiding the key id", async () => { + const error = await failure(api({ deleteStatus: 500 }).client.verify()); + expect(error).toMatchObject({ status: 502, code: "tailscale_test_key_cleanup_failed", operation: "delete_key", providerStatus: 500 }); + expect(error.message).toContain("5 minutes"); + expect(error.message).not.toContain(TEST_KEY_SECRET); + }); + + it("keeps the first failure when both validation and cleanup fail", async () => { + const error = await failure(api({ keyTags: ["tag:other"], deleteStatus: 500 }).client.verify()); + expect(error).toMatchObject({ code: "tailscale_tag_not_owned" }); + }); + + it("maps rate limits, unreachable hosts, and malformed bodies without echoing provider content", async () => { + const limited = await failure(api({ tokenStatus: 429 }).client.verify()); + expect(limited).toMatchObject({ status: 502, code: "tailscale_rate_limited" }); + const down = await failure(api({ unreachable: true }).client.verify()); + expect(down).toMatchObject({ status: 502, code: "tailscale_unreachable", operation: "token" }); + expect(down.message).not.toContain(PROVIDER_BODY); + const malformed = await failure(api({ keyBody: { nope: true } }).client.verify()); + expect(malformed).toMatchObject({ status: 502, code: "tailscale_request_failed", operation: "create_key" }); + const other = await failure(api({ devicesStatus: 503 }).client.verify()); + expect(other).toMatchObject({ status: 502, code: "tailscale_request_failed", providerStatus: 503 }); + expect(other.message).toBe("Tailscale returned HTTP 503 during list devices."); + }); + + it("never puts the client secret, token, or provider body into an error", async () => { + for (const behavior of [{ tokenStatus: 401 }, { devicesStatus: 403 }, { createStatus: 400 }, { deleteStatus: 403 }, { unreachable: true }] satisfies Behavior[]) { + const error = await failure(api(behavior).client.verify()); + const serialized = `${error.message} ${JSON.stringify(error.details)} ${error.stack ?? ""}`; + expect(serialized).not.toContain(CLIENT_SECRET); + expect(serialized).not.toContain(TOKEN); + expect(serialized).not.toContain(TEST_KEY_SECRET); + expect(serialized).not.toContain(PROVIDER_BODY); + } + }); +}); diff --git a/server/src/__tests__/tailscale-connection.test.ts b/server/src/__tests__/tailscale-connection.test.ts new file mode 100644 index 00000000000..7516034918e --- /dev/null +++ b/server/src/__tests__/tailscale-connection.test.ts @@ -0,0 +1,260 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + companyMemberships, + createDb, + heartbeatRuns, + toolApplications, + toolConnections, + toolAccessAuditEvents, +} from "@paperclipai/db"; +import { toolAccessService } from "../services/tool-access.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const actor = { actorType: "user" as const, actorId: "tailscale-reviewer" }; +const CLIENT_ID = "kFIXTURECNTRL"; +const CLIENT_SECRET = "tskey-client-fixture-secret-value"; +const TOKEN = "tskey-api-fixture-access-token"; +const TEST_KEY_SECRET = "tskey-auth-fixture-minted-key"; +const PROVIDER_BODY = "private tailnet detail: fixture.example.ts.net tag:secret-tag"; +const SECRETS = [CLIENT_SECRET, TOKEN, TEST_KEY_SECRET, PROVIDER_BODY]; + +(support.supported ? describe : describe.skip)("Tailscale connection lifecycle", () => { + let db: ReturnType; + let temp: Awaited>; + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-tailscale-"); + db = createDb(temp.connectionString); + }, 30000); + afterAll(async () => { + await temp?.cleanup(); + }); + + async function fixture() { + const [company] = await db + .insert(companies) + .values({ name: "Tailscale fixture", issuePrefix: `TS${randomUUID().slice(0, 6)}` }) + .returning(); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: actor.actorId, + membershipRole: "admin", + status: "active", + }); + const behavior = { tokenStatus: 0, createStatus: 0, scope: "auth_keys devices:core" }; + const calls: { method: string; path: string; body: string | undefined }[] = []; + const request = vi.fn(async (url: string, init: RequestInit) => { + const parsed = new URL(url); + expect(parsed.origin).toBe("https://api.tailscale.com"); + const path = parsed.pathname.replace("/api/v2", "") + parsed.search; + calls.push({ method: init.method ?? "GET", path, body: typeof init.body === "string" ? init.body : undefined }); + if (path === "/oauth/token") { + const form = new URLSearchParams(String(init.body)); + if (behavior.tokenStatus || form.get("client_id") !== CLIENT_ID || form.get("client_secret") !== CLIENT_SECRET) { + return Response.json({ message: PROVIDER_BODY }, { status: behavior.tokenStatus || 401 }); + } + return Response.json({ access_token: TOKEN, token_type: "Bearer", expires_in: 3600, scope: behavior.scope }); + } + if (new Headers(init.headers).get("Authorization") !== `Bearer ${TOKEN}`) { + return Response.json({ message: PROVIDER_BODY }, { status: 401 }); + } + if (/^\/tailnet\/[^/]+\/devices\?fields=all$/.test(path)) { + return Response.json({ devices: [{ nodeId: "nFIXTURE1", name: "devbox.fixture.example.ts.net", addresses: ["100.64.0.7"] }] }); + } + if (/^\/tailnet\/[^/]+\/keys$/.test(path) && init.method === "POST") { + if (behavior.createStatus) return Response.json({ message: PROVIDER_BODY }, { status: behavior.createStatus }); + const requested = JSON.parse(String(init.body)) as { capabilities: { devices: { create: { tags: string[] } } } }; + return Response.json({ + id: "kTESTKEY1", + key: TEST_KEY_SECRET, + expires: "2026-10-09T10:05:00Z", + capabilities: { devices: { create: { reusable: false, ephemeral: true, preauthorized: true, tags: requested.capabilities.devices.create.tags } } }, + }); + } + if (/^\/tailnet\/[^/]+\/keys\/kTESTKEY1$/.test(path) && init.method === "DELETE") return new Response(null, { status: 200 }); + throw new Error(`Unexpected fixture path ${init.method} ${path}`); + }); + const access = toolAccessService(db, { + remoteHttpRequest: request, + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], + }); + return { company, access, behavior, calls, request }; + } + + function expectRedacted(value: unknown) { + const text = JSON.stringify(value) ?? ""; + for (const secret of SECRETS) expect(text).not.toContain(secret); + } + + it("verifies the OAuth client at setup, stores a redacted summary, and keeps the client in the vault", async () => { + const { company, access, calls } = await fixture(); + const connected = await access.connectGalleryApp( + company.id, + { + galleryKey: "tailscale", + connectionMethodKey: "oauth-client", + credentialValues: { + "credentials.oauthClientId": CLIENT_ID, + "credentials.oauthClientSecret": CLIENT_SECRET, + }, + }, + actor, + ); + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ + "POST /oauth/token", + "GET /tailnet/-/devices?fields=all", + "POST /tailnet/-/keys", + "DELETE /tailnet/-/keys/kTESTKEY1", + ]); + expect(JSON.parse(calls[2]!.body!)).toMatchObject({ + capabilities: { devices: { create: { reusable: false, ephemeral: true, preauthorized: true, tags: ["tag:paperclip-agent"] } } }, + expirySeconds: 300, + }); + expect(connected.catalog).toEqual([]); + expect(connected.connection.healthStatus).toBe("ok"); + expect(connected.connection.healthMessage).toBe( + "Tailscale OAuth client is connected to the client's tailnet. Scopes: auth_keys, devices:core. Tags: tag:paperclip-agent. Devices visible: 1.", + ); + + const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect(row.transport).toBe("rest_api"); + expect(row.config).toMatchObject({ + sourceTemplateKey: "tailscale", + connectionMethodKey: "oauth-client", + methodConfig: { tailnet: "-", agentTag: "tag:paperclip-agent" }, + tailscale: { tailnet: "-", scopes: ["auth_keys", "devices:core"], tags: ["tag:paperclip-agent"], deviceCount: 1 }, + }); + expect(row.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual([ + "credentials.oauthClientId", + "credentials.oauthClientSecret", + ]); + // Neither OAuth field is a request header, so nothing is projected as one. + expect(row.credentialRefs).toEqual([]); + expectRedacted(row.config); + expectRedacted(connected); + const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, row.applicationId)); + expect(application.type).toBe("rest_api"); + + // A later health check repeats the full probe and keeps the summary current. + calls.length = 0; + const checked = await access.checkHealth(connected.connectionId, actor); + expect(checked.connection.healthStatus).toBe("ok"); + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ + "POST /oauth/token", + "GET /tailnet/-/devices?fields=all", + "POST /tailnet/-/keys", + "DELETE /tailnet/-/keys/kTESTKEY1", + ]); + const audits = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connected.connectionId)); + expect(audits.some((entry) => entry.action === "tool_connection.health_check" && entry.outcome === "success")).toBe(true); + expectRedacted(audits); + }); + + it("addresses the configured tailnet and agent tag", async () => { + const { company, access, calls } = await fixture(); + await access.connectGalleryApp( + company.id, + { + galleryKey: "tailscale", + connectionMethodKey: "oauth-client", + configValues: { tailnet: "example.com", agentTag: "tag:paperclip-lab" }, + credentialValues: { + "credentials.oauthClientId": CLIENT_ID, + "credentials.oauthClientSecret": CLIENT_SECRET, + }, + }, + actor, + ); + expect(calls.map((call) => `${call.method} ${call.path}`)).toContain("GET /tailnet/example.com/devices?fields=all"); + expect(JSON.parse(calls[2]!.body!).capabilities.devices.create.tags).toEqual(["tag:paperclip-lab"]); + }); + + it("maps a provider refusal to an actionable 422 without echoing the provider body", async () => { + const { company, access, behavior } = await fixture(); + behavior.createStatus = 403; + await expect( + access.connectGalleryApp( + company.id, + { + galleryKey: "tailscale", + connectionMethodKey: "oauth-client", + credentialValues: { + "credentials.oauthClientId": CLIENT_ID, + "credentials.oauthClientSecret": CLIENT_SECRET, + }, + }, + actor, + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "tailscale_tag_not_owned" }, + message: expect.stringContaining("tag:paperclip-agent"), + }); + + behavior.createStatus = 0; + const connected = await access.connectGalleryApp( + company.id, + { + galleryKey: "tailscale", + connectionMethodKey: "oauth-client", + credentialValues: { + "credentials.oauthClientId": CLIENT_ID, + "credentials.oauthClientSecret": CLIENT_SECRET, + }, + }, + actor, + ); + behavior.tokenStatus = 401; + const failure = await access.checkHealth(connected.connectionId, actor).then( + () => null, + (error: unknown) => error as { status: number; message: string; details: Record }, + ); + expect(failure).toMatchObject({ status: 422, details: { code: "tailscale_client_invalid" } }); + expect(failure!.message).not.toContain(PROVIDER_BODY); + const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect(row.healthStatus).toBe("error"); + expect(row.healthMessage).toContain("rejected the OAuth client"); + expectRedacted(row); + }); + + it("refuses to export the OAuth client to an agent", async () => { + const { company, access } = await fixture(); + const connected = await access.connectGalleryApp( + company.id, + { + galleryKey: "tailscale", + connectionMethodKey: "oauth-client", + credentialValues: { + "credentials.oauthClientId": CLIENT_ID, + "credentials.oauthClientSecret": CLIENT_SECRET, + }, + }, + actor, + ); + const [agent] = await db + .insert(agents) + .values({ companyId: company.id, name: "Tailnet agent", role: "engineer", adapterType: "process", adapterConfig: {} }) + .returning(); + const [run] = await db + .insert(heartbeatRuns) + .values({ companyId: company.id, agentId: agent.id, invocationSource: "on_demand", status: "running" }) + .returning(); + await expect( + access.mintConnectionTokenForAgent({ + connectionId: connected.connectionId, + companyId: company.id, + agentId: agent.id, + runId: run.id, + body: {}, + }), + ).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 5da1230161d..19ae9b4201c 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5214,7 +5214,7 @@ describeEmbeddedPostgres("tool access service", () => { "telem", ]), ); - expect(res.body.apps).toHaveLength(69); + expect(res.body.apps).toHaveLength(70); for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) { expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider"); } diff --git a/server/src/services/tailscale-api.ts b/server/src/services/tailscale-api.ts new file mode 100644 index 00000000000..9adc8ec01f1 --- /dev/null +++ b/server/src/services/tailscale-api.ts @@ -0,0 +1,351 @@ +import { z } from "zod"; +import { + TAILSCALE_API_URL, + TAILSCALE_APP_SLUG, + TAILSCALE_AUTH_KEY_WRITE_SCOPES, + TAILSCALE_DEFAULT_AGENT_TAG, + TAILSCALE_DEFAULT_TAILNET, + TAILSCALE_DEVICE_READ_SCOPES, + TAILSCALE_TEST_KEY_EXPIRY_SECONDS, + tailscaleScopeCovers, + type TailscaleConnectionHealth, + type TailscaleHealthCode, +} from "@paperclipai/shared"; +import { HttpError } from "../errors.js"; +import { guardedRemoteHttpFetch } from "./remote-http-fetch.js"; + +/** + * Tailscale REST boundary. The OAuth client is exchanged for a short-lived + * API token here and only here; neither the client nor the token nor any + * minted auth key reaches an agent runtime, the board UI, or a log line. + * + * Provider error bodies are `{ "message": string }` and can describe tailnet + * names, tags, or key identifiers. They are never read into an error; failures + * carry only the HTTP status, a fixed operation name, and a Paperclip code. + */ +export type TailscaleRequest = (url: string, init: RequestInit) => Promise; +export type TailscaleOperation = "token" | "list_devices" | "create_key" | "delete_key" | "delete_device"; + +export class TailscaleApiError extends HttpError { + constructor( + status: number, + message: string, + readonly code: TailscaleHealthCode, + readonly operation: TailscaleOperation, + readonly providerStatus: number | null = null, + ) { + super(status, message, { code, operation, providerStatus }); + } +} + +export function isTailscaleConnection(connection: { transport: string; config?: Record }) { + return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === TAILSCALE_APP_SLUG; +} + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().optional(), + expires_in: z.number().optional(), + scope: z.string().optional(), +}); +const deviceSchema = z.object({ + id: z.string().optional(), + nodeId: z.string().optional(), + name: z.string().optional(), + hostname: z.string().optional(), + addresses: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + os: z.string().optional(), + authorized: z.boolean().optional(), + connectedToControl: z.boolean().optional(), + lastSeen: z.string().nullable().optional(), + isEphemeral: z.boolean().optional(), + sshEnabled: z.boolean().optional(), +}); +export type TailscaleDevice = z.infer; +const devicesSchema = z.object({ devices: z.array(deviceSchema).default([]) }); +const keySchema = z.object({ + id: z.string().min(1), + key: z.string().optional(), + expires: z.string().optional(), + capabilities: z + .object({ + devices: z + .object({ + create: z + .object({ + reusable: z.boolean().optional(), + ephemeral: z.boolean().optional(), + preauthorized: z.boolean().optional(), + tags: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(), + }) + .optional(), +}); + +export interface TailscaleCreateAuthKeyInput { + tags: string[]; + reusable: boolean; + ephemeral: boolean; + preauthorized: boolean; + expirySeconds: number; + description: string; +} +export interface TailscaleAuthKey { + id: string; + /** The auth key secret. Present only on creation; never persist it in config, logs, or messages. */ + key: string | null; + expires: string | null; + tags: string[]; +} + +export interface TailscaleApiOptions { + clientId: string; + clientSecret: string; + tailnet?: string; + request?: TailscaleRequest; + baseUrl?: string; +} + +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +function failure( + operation: TailscaleOperation, + providerStatus: number, + context: { tailnet: string; agentTag?: string; scopes: string[] }, +): TailscaleApiError { + const rateLimited = () => + new TailscaleApiError(502, "Tailscale is rate limiting this client. Try again in a minute.", "tailscale_rate_limited", operation, providerStatus); + const clientInvalid = () => + new TailscaleApiError(422, "Tailscale rejected the OAuth client ID or secret. Create a new OAuth client in Tailscale and reconnect.", "tailscale_client_invalid", operation, providerStatus); + const tailnetNotFound = () => + new TailscaleApiError(422, `Tailscale could not find tailnet ${context.tailnet} for this OAuth client. Use "-" for the tailnet that owns the client, or enter the tailnet name exactly.`, "tailscale_tailnet_not_found", operation, providerStatus); + if (providerStatus === 429) return rateLimited(); + switch (operation) { + case "token": + if (providerStatus === 400 || providerStatus === 401 || providerStatus === 403) return clientInvalid(); + break; + case "list_devices": + if (providerStatus === 401) return clientInvalid(); + if (providerStatus === 403) + return new TailscaleApiError(422, "The OAuth client cannot list devices. Create a new OAuth client that includes the devices:core scope and reconnect.", "tailscale_scope_devices_missing", operation, providerStatus); + if (providerStatus === 404) return tailnetNotFound(); + break; + case "create_key": + if (providerStatus === 401) return clientInvalid(); + if (providerStatus === 404) return tailnetNotFound(); + if (providerStatus === 400 || providerStatus === 403) { + if (context.scopes.length && !tailscaleScopeCovers(context.scopes, TAILSCALE_AUTH_KEY_WRITE_SCOPES)) + return new TailscaleApiError(422, "The OAuth client cannot create auth keys. Create a new OAuth client that includes the auth_keys scope and reconnect.", "tailscale_scope_auth_keys_missing", operation, providerStatus); + return new TailscaleApiError(422, `The OAuth client cannot assign ${context.agentTag ?? TAILSCALE_DEFAULT_AGENT_TAG}. Add that tag to the client's auth_keys scope in Tailscale (and to tagOwners in the tailnet policy), then reconnect.`, "tailscale_tag_not_owned", operation, providerStatus); + } + break; + case "delete_key": + return new TailscaleApiError(502, `Paperclip minted a test auth key but could not delete it (HTTP ${providerStatus}). The key is ephemeral, single use, and expires in ${Math.round(TAILSCALE_TEST_KEY_EXPIRY_SECONDS / 60)} minutes. Check the OAuth client's auth_keys scope and run the check again.`, "tailscale_test_key_cleanup_failed", operation, providerStatus); + case "delete_device": + if (providerStatus === 401) return clientInvalid(); + if (providerStatus === 403) + return new TailscaleApiError(422, "The OAuth client cannot delete devices. Create a new OAuth client that includes the devices:core scope and reconnect.", "tailscale_scope_devices_missing", operation, providerStatus); + break; + } + return new TailscaleApiError(502, `Tailscale returned HTTP ${providerStatus} during ${operation.replace(/_/g, " ")}.`, "tailscale_request_failed", operation, providerStatus); +} + +function unreachable(operation: TailscaleOperation): TailscaleApiError { + return new TailscaleApiError(502, "Tailscale could not be reached from this Paperclip instance.", "tailscale_unreachable", operation); +} + +function malformed(operation: TailscaleOperation): TailscaleApiError { + return new TailscaleApiError(502, `Tailscale returned an unexpected response during ${operation.replace(/_/g, " ")}.`, "tailscale_request_failed", operation); +} + +async function readJson(response: Response, operation: TailscaleOperation): Promise { + if (response.status === 204 || !response.body) return null; + const reader = response.body.getReader(); + const parts: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const part = await reader.read(); + if (part.done) break; + bytes += part.value.length; + if (bytes > MAX_RESPONSE_BYTES) throw malformed(operation); + parts.push(part.value); + } + } finally { + await reader.cancel().catch(() => {}); + } + const text = Buffer.concat(parts).toString("utf8").trim(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + throw malformed(operation); + } +} + +export function tailscaleApi(options: TailscaleApiOptions) { + const baseUrl = (options.baseUrl ?? TAILSCALE_API_URL).replace(/\/+$/, ""); + const tailnet = (options.tailnet?.trim() || TAILSCALE_DEFAULT_TAILNET).trim(); + const tailnetPath = `/tailnet/${encodeURIComponent(tailnet)}`; + const request: TailscaleRequest = options.request + ?? ((url, init) => guardedRemoteHttpFetch(url, init, { error: (message, code) => new HttpError(502, message, { code }) })); + let cached: { token: string; scopes: string[]; expiresAt: number } | null = null; + + async function send( + operation: TailscaleOperation, + path: string, + init: { method: string; headers: Record; body?: string }, + context: { agentTag?: string; scopes: string[] }, + ): Promise { + let response: Response; + try { + response = await request(`${baseUrl}${path}`, { + method: init.method, + headers: init.headers, + body: init.body, + redirect: "error", + signal: AbortSignal.timeout(20_000), + }); + } catch (error) { + if (error instanceof TailscaleApiError) throw error; + // The caught error may carry a provider URL or header; never forward it. + throw unreachable(operation); + } + if (!response.ok) { + // Error bodies can name tailnets, tags, or keys. Discard them unread. + await response.body?.cancel().catch(() => {}); + throw failure(operation, response.status, { tailnet, agentTag: context.agentTag, scopes: context.scopes }); + } + return readJson(response, operation); + } + + async function token(): Promise<{ accessToken: string; scopes: string[] }> { + if (cached && cached.expiresAt > Date.now()) return { accessToken: cached.token, scopes: cached.scopes }; + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: options.clientId, + client_secret: options.clientSecret, + }).toString(); + const parsed = tokenSchema.safeParse( + await send("token", "/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body, + }, { scopes: [] }), + ); + if (!parsed.success) throw malformed("token"); + const scopes = (parsed.data.scope ?? "").split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean); + const ttlMs = Math.max(30, Math.min(parsed.data.expires_in ?? 3600, 3600) - 60) * 1000; + cached = { token: parsed.data.access_token, scopes, expiresAt: Date.now() + ttlMs }; + return { accessToken: cached.token, scopes }; + } + + async function authorized(operation: TailscaleOperation, path: string, method: string, body?: unknown, agentTag?: string) { + const { accessToken, scopes } = await token(); + return send(operation, path, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, { agentTag, scopes }); + } + + const api = { + tailnet, + token, + async listDevices(): Promise { + const parsed = devicesSchema.safeParse(await authorized("list_devices", `${tailnetPath}/devices?fields=all`, "GET")); + if (!parsed.success) throw malformed("list_devices"); + return parsed.data.devices; + }, + async createAuthKey(input: TailscaleCreateAuthKeyInput): Promise { + const parsed = keySchema.safeParse( + await authorized("create_key", `${tailnetPath}/keys`, "POST", { + capabilities: { + devices: { + create: { + reusable: input.reusable, + ephemeral: input.ephemeral, + preauthorized: input.preauthorized, + tags: input.tags, + }, + }, + }, + expirySeconds: input.expirySeconds, + description: input.description, + }, input.tags[0]), + ); + if (!parsed.success) throw malformed("create_key"); + return { + id: parsed.data.id, + key: parsed.data.key ?? null, + expires: parsed.data.expires ?? null, + tags: parsed.data.capabilities?.devices?.create?.tags ?? [], + }; + }, + async deleteKey(keyId: string): Promise { + await authorized("delete_key", `${tailnetPath}/keys/${encodeURIComponent(keyId)}`, "DELETE"); + }, + async deleteDevice(deviceId: string): Promise { + await authorized("delete_device", `/device/${encodeURIComponent(deviceId)}`, "DELETE"); + }, + /** + * Connection check: token exchange, device list (proves devices:core), then + * mint and immediately delete a one-off ephemeral tagged key (proves + * auth_keys and tag ownership). The test key is deleted even when a later + * validation step fails; a cleanup failure is reported as its own code. + */ + async verify(input: { agentTag?: string } = {}): Promise { + const agentTag = input.agentTag?.trim() || TAILSCALE_DEFAULT_AGENT_TAG; + const { scopes } = await token(); + if (scopes.length && !tailscaleScopeCovers(scopes, TAILSCALE_DEVICE_READ_SCOPES)) + throw failure("list_devices", 403, { tailnet, agentTag, scopes }); + if (scopes.length && !tailscaleScopeCovers(scopes, TAILSCALE_AUTH_KEY_WRITE_SCOPES)) + throw failure("create_key", 403, { tailnet, agentTag, scopes }); + const devices = await api.listDevices(); + const key = await api.createAuthKey({ + tags: [agentTag], + reusable: false, + ephemeral: true, + preauthorized: true, + expirySeconds: TAILSCALE_TEST_KEY_EXPIRY_SECONDS, + description: "Paperclip connection check (deleted immediately)", + }); + let pending: unknown = null; + try { + if (key.tags.length && !key.tags.includes(agentTag)) { + throw new TailscaleApiError(422, `Tailscale issued the test key without ${agentTag}. Add that tag to the OAuth client's auth_keys scope and reconnect.`, "tailscale_tag_not_owned", "create_key"); + } + } catch (error) { + pending = error; + } + try { + await api.deleteKey(key.id); + } catch (error) { + pending ??= error; + } + if (pending) throw pending; + return { + tailnet, + scopes, + tags: [agentTag], + deviceCount: devices.length, + checkedAt: new Date().toISOString(), + }; + }, + }; + return api; +} + +export function tailscaleHealthMessage(health: TailscaleConnectionHealth): string { + const tailnet = health.tailnet === TAILSCALE_DEFAULT_TAILNET ? "the client's tailnet" : `tailnet ${health.tailnet}`; + const scopes = health.scopes.length ? health.scopes.join(", ") : "not reported"; + return `Tailscale OAuth client is connected to ${tailnet}. Scopes: ${scopes}. Tags: ${health.tags.join(", ")}. Devices visible: ${health.deviceCount}.`; +} diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index fb473843888..ce63f15c3d7 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -13,6 +13,7 @@ import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; +import { TAILSCALE_APP_SLUG, TAILSCALE_DEFAULT_AGENT_TAG, TAILSCALE_DEFAULT_TAILNET, type TailscaleConnectionHealth } from "@paperclipai/shared"; import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js"; import { browserUseService } from "./browser-use.js"; import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl } from "./cognee-connection.js"; @@ -238,6 +239,7 @@ import { import { secretService } from "./secrets.js"; import { connectionCredentialConfigPath as credentialRefConfigPath, connectionGrantCredentialRef, connectionSecretsUsedByOtherConsumers, resolveConnectionGrantSecret, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js"; import { agentmailApi } from "./agentmail-api.js"; +import { isTailscaleConnection, tailscaleApi, tailscaleHealthMessage } from "./tailscale-api.js"; import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared"; import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js"; import { createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_TOOLS, RAILWAY_TOOL_PREFIX, railwayRisk, RailwayError } from "./railway.js"; @@ -2893,9 +2895,24 @@ function healthFailureHttpStatus(failure: { if (failure.code === "tool_connection_transport_unsupported") return 422; if (failure.code === "cognee_access_unverified" || failure.code === "memory_api_key_rejected") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; + if (isActionableTailscaleCode(failure.code)) return 422; return 502; } +/** A Tailscale probe result newer than this is reused by catalog discovery right after a health check. */ +const TAILSCALE_PROBE_REUSE_MS = 60_000; + +function isTailscaleHealth(value: unknown): value is TailscaleConnectionHealth { + const record = asRecord(value); + return typeof record.tailnet === "string" && Array.isArray(record.scopes) && Array.isArray(record.tags) + && typeof record.deviceCount === "number" && typeof record.checkedAt === "string"; +} + +/** Tailscale codes the operator fixes in the provider console, not transient provider failures. */ +function isActionableTailscaleCode(code: string): boolean { + return code.startsWith("tailscale_") && !["tailscale_unreachable", "tailscale_rate_limited", "tailscale_request_failed", "tailscale_test_key_cleanup_failed"].includes(code); +} + function unsupportedToolConnectionTransport() { return unprocessable( "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.", @@ -2931,6 +2948,10 @@ function sanitizeHttpFailure(error: unknown): { if (typeof code === "string" && code.startsWith("remote_http_")) { return { status: "error", message: error.message, code }; } + if (typeof code === "string" && code.startsWith("tailscale_")) { + // The message names the scope, tag, or tailnet to fix; never a provider body. + return { status: isActionableTailscaleCode(code) ? "error" : "degraded", message: error.message, code }; + } if (isOAuthEndpointRejection(error)) { return { status: "error", message: error.message, code: String(code) }; } @@ -7454,6 +7475,50 @@ export function toolAccessService( await agentmailApi(key).whoami(); } + /** + * Tailscale keeps its OAuth client in the vault and exchanges it for a + * short-lived token server-side. The check lists devices and mints/deletes a + * one-off ephemeral tagged key; the redacted summary is stored on the + * connection so later features (device picker, sandbox join) can read scopes + * and tags without another provider round trip. + */ + async function validateTailscaleConnection( + connection: typeof toolConnections.$inferSelect, + ): Promise { + const resolve = async (configPath: string) => { + const ref = connection.credentialSecretRefs.find((candidate) => candidate.configPath === configPath); + if (!ref) { + throw unprocessable("Reconnect Tailscale to restore its OAuth client", { code: "missing_secret" }); + } + return secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.versionSelector ?? "latest", { + consumerType: "tool_connection", + consumerId: connection.id, + configPath, + actorType: "system", + actorId: null, + }); + }; + const methodConfig = asRecord(asRecord(connection.config).methodConfig); + const tailnet = typeof methodConfig.tailnet === "string" && methodConfig.tailnet.trim() ? methodConfig.tailnet.trim() : TAILSCALE_DEFAULT_TAILNET; + const agentTag = typeof methodConfig.agentTag === "string" && methodConfig.agentTag.trim() ? methodConfig.agentTag.trim() : TAILSCALE_DEFAULT_AGENT_TAG; + const [clientId, clientSecret] = await Promise.all([ + resolve("credentials.oauthClientId"), + resolve("credentials.oauthClientSecret"), + ]); + const health = await tailscaleApi({ + clientId, + clientSecret, + tailnet, + request: (url, init) => requestRemoteHttpEndpoint(new URL(url), init), + }).verify({ agentTag }); + await db + .update(toolConnections) + .set({ config: { ...connection.config, tailscale: health }, updatedAt: new Date() }) + .where(eq(toolConnections.id, connection.id)); + connection.config = { ...connection.config, tailscale: health }; + return health; + } + function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) { if (isRetiredComposioConnection(connection)) { throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" }); @@ -7476,6 +7541,17 @@ export function toolAccessService( await validateAgentMailConnection(connection); return []; } + if (isTailscaleConnection(connection)) { + // Network reach only: no agent-facing tools in this version. Setup runs + // the health probe and catalog discovery back to back; reuse a summary + // from the last minute so one setup mints one test key, not two. + const recent = asRecord(connection.config).tailscale; + const checkedAt = isTailscaleHealth(recent) ? Date.parse(recent.checkedAt) : Number.NaN; + if (!(Number.isFinite(checkedAt) && Date.now() - checkedAt < TAILSCALE_PROBE_REUSE_MS)) { + await validateTailscaleConnection(connection); + } + return []; + } if (connection.transport === "mcp_remote") return remoteTools(connection, credentialHeaders, actor); if (connection.transport !== "local_stdio") { @@ -7581,6 +7657,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null }; + let tailscaleHealth: TailscaleConnectionHealth | null = null; try { assertSupportedConnection(connection); const config = asRecord(connection.config); @@ -7626,6 +7703,8 @@ export function toolAccessService( await discoverTools(connection, undefined, actor); } else if (isAgentMailConnection(connection)) { await validateAgentMailConnection(connection); + } else if (isTailscaleConnection(connection)) { + tailscaleHealth = await validateTailscaleConnection(connection); } else if (connection.transport === "mcp_remote") { const canProbeWithoutAuthorization = options.allowUnauthenticatedProbe === true && @@ -7660,6 +7739,8 @@ export function toolAccessService( ? "Browser Use API key is connected." : isAgentMailConnection(connection) ? "AgentMail API key is connected." + : tailscaleHealth + ? tailscaleHealthMessage(tailscaleHealth) : connection.transport === "local_stdio" ? "Approved stdio template is ready." : "Remote MCP server responded to tools/list.", @@ -7930,7 +8011,9 @@ export function toolAccessService( healthStatus: "ok", healthMessage: isAgentMailConnection(connection) ? "AgentMail API key is connected." - : "Tool catalog refreshed.", + : isTailscaleConnection(connection) && isTailscaleHealth(asRecord(connection.config).tailscale) + ? tailscaleHealthMessage(asRecord(connection.config).tailscale as TailscaleConnectionHealth) + : "Tool catalog refreshed.", healthCheckedAt: refreshedAt, lastHealthAt: refreshedAt, lastCatalogRefreshAt: refreshedAt, @@ -13102,7 +13185,7 @@ export function toolAccessService( applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, name: applicationName, description: safeApplicationDescription, - type: transport === "rest_api" && galleryEntry?.slug === "browser-use-cloud" ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", + type: transport === "rest_api" && (galleryEntry?.slug === "browser-use-cloud" || galleryEntry?.slug === TAILSCALE_APP_SLUG) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "draft", metadata: galleryEntry ? { @@ -18056,7 +18139,7 @@ export function toolAccessService( companyId, applicationKey: normalizeKey(input.applicationName ?? input.name), name: input.applicationName ?? input.name, - type: isBrowserUseConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", + type: isBrowserUseConnection({ transport, config }) || isTailscaleConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "active", metadata: {}, }) @@ -20103,6 +20186,7 @@ export function toolAccessService( input.companyId, ); if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported."); + if (isTailscaleConnection(connection)) throw forbidden("The Tailscale OAuth client stays on the server and cannot be exported."); if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver"); const application = await getConnectionApplication(connection); const brokerEnabled = connectionTokenBrokerEnabled(connection); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index f3cb45de2a5..ab2f6e08c42 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -574,6 +574,15 @@ "sourceUrl": "https://browser-use.com/logo-primary.svg", "retrievedAt": "2026-09-29" }, + { + "slug": "tailscale", + "provider": "Tailscale", + "catalogVisible": true, + "localAsset": "/brands/apps/tailscale.svg", + "darkAsset": "/brands/apps/tailscale-dark.svg", + "sourceUrl": "https://tailscale.com/favicon.svg", + "retrievedAt": "2026-10-09" + }, { "slug": "chat-completions-api", "provider": "Chat Completions API", diff --git a/ui/public/brands/apps/tailscale-dark.svg b/ui/public/brands/apps/tailscale-dark.svg new file mode 100644 index 00000000000..84e3efafe6e --- /dev/null +++ b/ui/public/brands/apps/tailscale-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ui/public/brands/apps/tailscale.svg b/ui/public/brands/apps/tailscale.svg new file mode 100644 index 00000000000..9e971e01645 --- /dev/null +++ b/ui/public/brands/apps/tailscale.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + +