mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c4ce44e13
commit
47bbe1aba4
18 files changed
+1414
-75
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown>): Promise<TailscaleApiError> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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<string, unknown> },
|
||||
);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<Response>;
|
||||
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<string, unknown> }) {
|
||||
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<typeof deviceSchema>;
|
||||
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<unknown> {
|
||||
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<string, string>; body?: string },
|
||||
context: { agentTag?: string; scopes: string[] },
|
||||
): Promise<unknown> {
|
||||
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<TailscaleDevice[]> {
|
||||
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<TailscaleAuthKey> {
|
||||
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<void> {
|
||||
await authorized("delete_key", `${tailnetPath}/keys/${encodeURIComponent(keyId)}`, "DELETE");
|
||||
},
|
||||
async deleteDevice(deviceId: string): Promise<void> {
|
||||
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<TailscaleConnectionHealth> {
|
||||
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}.`;
|
||||
}
|
||||
@@ -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<TailscaleConnectionHealth> {
|
||||
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<ToolConnectionHealthCheckResult> {
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path fill="#fff" d="M28.8 0C35.5206 0 38.881 0 41.4479 1.30792C43.7058 2.4584 45.5416 4.29417 46.6921 6.55211C48 9.11905 48 12.4794 48 19.2V28.8C48 35.5206 48 38.881 46.6921 41.4479C45.5416 43.7058 43.7058 45.5416 41.4479 46.6921C38.881 48 35.5206 48 28.8 48H19.2C12.4794 48 9.11905 48 6.55211 46.6921C4.29417 45.5416 2.4584 43.7058 1.30792 41.4479C0 38.881 0 35.5206 0 28.8L0 19.2C0 12.4794 0 9.11905 1.30792 6.55211C2.4584 4.29417 4.29417 2.4584 6.55211 1.30792C9.11905 0 12.4794 0 19.2 0L28.8 0Z"/>
|
||||
<g fill="#1f1e1e">
|
||||
<circle cx="15" cy="24" r="3"/>
|
||||
<circle cx="24" cy="24" r="3"/>
|
||||
<circle cx="33" cy="24" r="3"/>
|
||||
<circle cx="24" cy="33" r="3"/>
|
||||
</g>
|
||||
<g fill="#1f1e1e" opacity="0.3">
|
||||
<circle cx="15" cy="15" r="3"/>
|
||||
<circle cx="24" cy="15" r="3"/>
|
||||
<circle cx="33" cy="15" r="3"/>
|
||||
<circle cx="15" cy="33" r="3"/>
|
||||
<circle cx="33" cy="33" r="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 967 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path fill="#1f1e1e" d="M28.8 0C35.5206 0 38.881 0 41.4479 1.30792C43.7058 2.4584 45.5416 4.29417 46.6921 6.55211C48 9.11905 48 12.4794 48 19.2V28.8C48 35.5206 48 38.881 46.6921 41.4479C45.5416 43.7058 43.7058 45.5416 41.4479 46.6921C38.881 48 35.5206 48 28.8 48H19.2C12.4794 48 9.11905 48 6.55211 46.6921C4.29417 45.5416 2.4584 43.7058 1.30792 41.4479C0 38.881 0 35.5206 0 28.8L0 19.2C0 12.4794 0 9.11905 1.30792 6.55211C2.4584 4.29417 4.29417 2.4584 6.55211 1.30792C9.11905 0 12.4794 0 19.2 0L28.8 0Z"/>
|
||||
<g fill="#fff">
|
||||
<circle cx="15" cy="24" r="3"/>
|
||||
<circle cx="24" cy="24" r="3"/>
|
||||
<circle cx="33" cy="24" r="3"/>
|
||||
<circle cx="24" cy="33" r="3"/>
|
||||
</g>
|
||||
<g fill="#fff" opacity="0.4">
|
||||
<circle cx="15" cy="15" r="3"/>
|
||||
<circle cx="24" cy="15" r="3"/>
|
||||
<circle cx="33" cy="15" r="3"/>
|
||||
<circle cx="15" cy="33" r="3"/>
|
||||
<circle cx="33" cy="33" r="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 964 B |
Reference in new issue
Block a user