mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
feat(connections): add Unikraft Cloud connection (#17139)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps connections give agents governed access to external services through vault credentials, grants, action policies, the tool gateway, and audit. > - Unikraft Cloud runs unikernel micro-VMs, services, and volumes across regional metros, and it offers a sandbox plugin for commands and files. > - Unikraft is not in the catalog. It has no hosted MCP server, so the generic "Connect your own MCP server" flow cannot reach it. > - Daytona (#16483) added a native REST pattern for this case: a catalog generated from the provider's published OpenAPI specifications and executed through the gateway. > - This pull request adds Unikraft on that pattern and moves the shared native-provider code into one registry, so the shared connection code does not name each provider. > - The benefit is governed agent access to all published Unikraft actions, with the token kept in the vault and sent only to published Unikraft hosts. ## Linked Issues or Issue Description Refs #16483 **Problem or motivation** Teams that run workloads on Unikraft Cloud want their agents to list instances, read logs, deploy and scale services, and run commands in sandboxes. Unikraft is not in the Apps catalog. Unikraft has no hosted MCP server, so operators cannot connect it through the remote MCP flow. **Proposed solution** Add a native Unikraft connection. Generate its actions from Unikraft's official OpenAPI specifications for the platform API and the sandbox plugin. Pin the specifications to a reviewed commit. The operator connects an API token. The token stays in the vault and goes only to the five published metro hosts. Every action takes an optional metro. Actions without a metro use the connection's default metro, which the operator can change under Advanced. **Alternatives considered** A catalog-only AppDefinition was not possible because no hosted MCP endpoint exists. Wrapping the `unikraft` CLI was rejected because it would expose host filesystem behavior and CLI login state. A second copy of the Daytona bridge was rejected; the shared transport and catalog contract are now one module with a provider registry. **Roadmap alignment** This extends the existing Apps and Connections provider coverage. It uses the existing credential, grant, policy, gateway, and audit model. It does not overlap planned core work. ## What Changed - Added `scripts/generate-unikraft-tools.mjs` and its tests. The generator reads the platform and sandbox plugin specifications at commit `5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b`, resolves shared parameters, records source SHA-256 hashes, and writes `server/src/services/unikraft/operations.generated.json`. - The catalog has 105 actions: 89 platform operations and 16 sandbox plugin operations. The server-sent audit event stream is recorded in the inventory and is not advertised as an action. - Classification: GET operations and sandbox reads and waits are reads. Delete, stop, suspend, detach, unpin, and sandbox command execution, signals, and stdin are destructive. All other operations are writes. - Added `server/src/services/connectors/unikraft/client.ts` and its generated catalog. It validates arguments against the generated schemas, accepts only published metros, rejects path traversal, sends JSON bodies on GET and DELETE as the specification requires, and verifies the token with `GET /v1/users/quotas`. A sandbox wait that times out (documented HTTP 408) returns a still-running result instead of a failure. - Moved the bounded, credential-redacting HTTP sender and the catalog contract helpers from `daytona.ts` to `native-rest-client.ts`. Added `native-provider-catalogs.ts`. `tool-access.ts`, the `tool-access/catalog` and `token-broker` modules, `connectors/tool-policy.ts`, and `tool-gateway.ts` now use this registry instead of Daytona-specific checks. Daytona behavior does not change. - Added the Unikraft definition source, the generated definition, the brand mark and manifest entry, and the permission review (`liveProof: not-run`). - Added `doc/connections/UNIKRAFT.md` with a sequence diagram, endpoints, setup, actions, and limits. Updated the connections README, the permission audit, and both capability maps. - Tests: definition shape, database-backed gateway reads, writes, deletes, and sandbox commands for personal and shared grants, default and per-call metros, rejection of unpublished metros and traversal, Off policies, revocation, stale-contract blocking, credential export denial, schema validation for all actions, pinned-transport tests for GET and DELETE bodies, and a browser setup check. ## Verification - `pnpm exec vitest run packages/shared/src server/src/__tests__/unikraft-connection.test.ts server/src/__tests__/unikraft-http-boundary.test.ts server/src/__tests__/daytona-connection.test.ts server/src/__tests__/daytona-http-boundary.test.ts server/src/__tests__/tool-access-service.test.ts server/src/__tests__/tool-gateway-service.test.ts server/src/__tests__/tool-gateway.test.ts ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/lib/app-brand-assets.test.ts` plus `connector-tool-policy` and `tool-catalog-publication`: all passed after the rebase onto the split tool-access modules. - `node --test scripts/generate-unikraft-tools.test.mjs scripts/generate-daytona-tools.test.mjs scripts/app-brand-validation.test.mjs`: 20 passed. - `node scripts/check-app-brand-assets.mjs`, `node scripts/check-maps.mjs`, and `git diff --check`: passed. - `pnpm --filter @paperclipai/shared typecheck`, `pnpm --filter @paperclipai/server typecheck`, and `pnpm --filter @paperclipai/ui typecheck`: clean. - `npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/unikraft-connector.spec.ts tests/e2e/daytona-connector.spec.ts`: 2 passed. - `node scripts/generate-unikraft-tools.mjs` regenerates the committed catalog with the recorded hashes. - Manual: on a local instance, open Connectors, search for Unikraft, and select **Add key**. Confirm the icon, the password field, the key guidance, and the default metro select under **Change**. - Not verified: a real Unikraft account. Live provider read and write proof is pending. ## Risks - A Unikraft token acts for the whole organization in every metro. Unikraft offers no narrower token permissions. Assign only trusted agents and review action permissions. - Sandbox actions run arbitrary commands inside the selected instance. They are classified as destructive. - A timed-out write can still complete at the provider. The bridge does not retry writes automatically. - New metros fail closed until the specification is regenerated and reviewed. Legacy `*.kraft.cloud` hosts are not reachable. - The Daytona refactor moves code without behavior changes. All Daytona tests pass without edits. - The generated JSON snapshot is large because it holds the provider input contracts. No migration or lockfile change is required. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, 1M context window, used through Claude Code with tool use (shell, file editing, web research, browser tests). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
1 parent
def4726bea
commit
059ae49cab
36 files changed
+13170
-174
No files matched your search
@@ -11,7 +11,8 @@ ledger; the counts above are not restated. Later additions: [Neon](./NEON.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-02), [Superagent](./SUPERAGENT.md)
|
||||
(`mcp-api-key`; 2026-10-06), [Gauge](./GAUGE.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-08), [Ditto](./DITTO.md)
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-10).
|
||||
(`mcp-oauth`, `mcp-api-key`; 2026-10-10), [Unikraft](./UNIKRAFT.md)
|
||||
(`api-token`; 2026-10-15).
|
||||
|
||||
## Shared credential failure
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Long-term memory: [Experimental memory connectors](./MEMORY.md).
|
||||
|
||||
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
|
||||
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Braintrust](./BRAINTRUST.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [Ditto](./DITTO.md),
|
||||
[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
|
||||
[Unikraft](./UNIKRAFT.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
|
||||
[Enterpret](./ENTERPRET.md). Optional credential custody:
|
||||
[Vercel Connect](./VERCEL-CONNECT.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Unikraft connection
|
||||
|
||||
Unikraft Cloud runs unikernel micro-VMs ("instances") across regional metros.
|
||||
Its connection uses the normal Apps connection, selected personal/company
|
||||
credential, agent installation, action policy, gateway, revocation and audit
|
||||
paths. The API token stays in the instance vault. The connector does not borrow
|
||||
`unikraft login` state, `UKC_TOKEN` environment variables, or another
|
||||
connection's credential.
|
||||
|
||||
Sign-in tokens are never reused as resource tokens; id.paperclip.ing never
|
||||
stores resource tokens; no connections hub on the ID service.
|
||||
|
||||
## Vendor
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Product | Unikraft Cloud (formerly KraftCloud) |
|
||||
| API reference | https://unikraft.com/docs/api/platform/v1 |
|
||||
| Specification | https://github.com/unikraft-cloud/openapi (`platform.json`, `plugins/sandbox.json`) |
|
||||
| Metros | https://unikraft.com/docs/platform/metros |
|
||||
| Tokens | https://unikraft.com/docs/sdks/overview#authentication |
|
||||
| Console | https://console.unikraft.com |
|
||||
| Hosted MCP server | None. Unikraft's MCP guides describe hosting your own MCP server on Unikraft. |
|
||||
|
||||
## Transport and auth
|
||||
|
||||
| Axis | Value |
|
||||
| --- | --- |
|
||||
| Transport | `rest_api`, executed by Paperclip's native provider bridge |
|
||||
| Authentication | `api_key`, sent as `Authorization: Bearer <token>` |
|
||||
| OAuth client ownership | Not applicable; Unikraft offers no third-party OAuth |
|
||||
| Credential source | Paperclip vault |
|
||||
| Grant identity | `organization` or `user` |
|
||||
|
||||
Unikraft Cloud has no global platform endpoint. Each metro serves its own API at
|
||||
`https://api.<metro>.unikraft.cloud/v1/...`. The reviewed specification
|
||||
publishes five metros: `dal`, `fra`, `sfo`, `sin` and `was`. Every action accepts
|
||||
an optional `metro` argument limited to that list; actions that omit it use the
|
||||
connection's default metro (Frankfurt unless changed under **Advanced**). The
|
||||
token is never sent to any other host. Legacy `*.kraft.cloud` hosts, which
|
||||
Unikraft documents as nearing end of life, are not reachable through this
|
||||
connection.
|
||||
|
||||
The saved connection URL, `https://controlplane.unikraft.cloud/v1`, identifies
|
||||
the connection only. Actions use the metro hosts above.
|
||||
|
||||
## Connection flow (mandatory)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Operator
|
||||
participant UI as Paperclip UI
|
||||
participant Server as Paperclip server
|
||||
participant Vault as Instance vault
|
||||
participant Metro as Unikraft metro API
|
||||
actor Agent
|
||||
Operator->>UI: Connectors, Unikraft, Add key
|
||||
UI->>Server: POST /api/companies/{companyId}/tools/apps/connect (galleryKey unikraft, token, optional default metro)
|
||||
Server->>Vault: Store the token as a company or user secret
|
||||
Server->>Metro: GET /v1/users/quotas with the stored token
|
||||
Metro-->>Server: status success
|
||||
Server-->>UI: Generated catalog of 105 actions
|
||||
Operator->>UI: Choose agents and action permissions
|
||||
Agent->>Server: Gateway tool call, for example unikraft_get_instances
|
||||
Server->>Server: Check grant, revocation, policy and saved contract
|
||||
Server->>Metro: Request to api.{metro}.unikraft.cloud with the vaulted token
|
||||
Metro-->>Server: JSON result
|
||||
Server-->>Agent: Result with the token redacted
|
||||
```
|
||||
|
||||
Paperclip endpoints used: `GET /api/companies/{companyId}/tools/apps/unikraft/preflight`,
|
||||
`POST /api/companies/{companyId}/tools/apps/connect`,
|
||||
`POST /api/companies/{companyId}/tools/apps/{connectionId}/finish`,
|
||||
`GET /api/tool-connections/{connectionId}/catalog`, and the run-bound tool gateway.
|
||||
There is no OAuth handoff, so `GET /api/tools/oauth/callback` is not used.
|
||||
|
||||
## Administrator setup (mandatory)
|
||||
|
||||
1. Sign in to the [Unikraft Cloud console](https://console.unikraft.com) and
|
||||
create an API token for the organization agents should manage.
|
||||
2. In Paperclip choose **Connectors → Unikraft → Add key**, choose who can use
|
||||
the credential, and paste the token.
|
||||
3. Optional: under **Advanced**, change the default metro.
|
||||
4. On the saved connection's **Permissions** screen, choose agents and set
|
||||
actions to Allowed, Ask first, or Off.
|
||||
|
||||
A token acts for its whole organization in every metro. Unikraft does not offer
|
||||
narrower token permissions, so agent assignment and action permissions enforce
|
||||
the capability boundary. Revoke a token in the Unikraft console, then remove or
|
||||
reconnect the Paperclip connection.
|
||||
|
||||
## Resource filters
|
||||
|
||||
There are no provider-enforced resource filters. `requiredResourceFilters`
|
||||
(`organization`, `metro`, `instance`) is reviewed metadata. The bridge enforces
|
||||
only that requests go to a published metro and that path arguments cannot
|
||||
traverse to another route.
|
||||
|
||||
## Actions
|
||||
|
||||
The catalog contains **105 actions** generated from Unikraft's published
|
||||
specifications at commit `5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b`:
|
||||
89 platform API operations and 16 sandbox plugin operations.
|
||||
|
||||
| Group | Examples | Classification |
|
||||
| --- | --- | --- |
|
||||
| Reads, logs, metrics, history and waits | `unikraft_get_instances`, `unikraft_get_instance_logs_by_uuid`, `unikraft_wait_instances`, `unikraft_sandbox_read_file` | Read |
|
||||
| Creation, updates, start, attach, clone and pinning | `unikraft_create_instance`, `unikraft_update_service_group_by_uuid`, `unikraft_start_instances`, `unikraft_sandbox_write_file` | Write |
|
||||
| Deletion, stop, suspend, detach, unpin, and sandbox command execution, signals and stdin | `unikraft_delete_volume_by_uuid`, `unikraft_stop_instances`, `unikraft_sandbox_run_command` | Destructive |
|
||||
|
||||
Platform actions keep Unikraft's operation names (`unikraft_<operation>`).
|
||||
Sandbox plugin actions are `unikraft_sandbox_<operation>` and take an
|
||||
`instance` (name or UUID); they call
|
||||
`https://api.<metro>.unikraft.cloud/v1/instances/<instance>/plugins/sandbox`.
|
||||
Many Unikraft operations select batch targets with a JSON body, including on
|
||||
GET and DELETE; the bridge sends those bodies as published.
|
||||
`unikraft_sandbox_wait_for_command_with_timeout` returns
|
||||
`{ "status": "running", "timedOut": true }` when Unikraft reports that the
|
||||
command is still running at the timeout (HTTP 408), so an agent can wait again.
|
||||
The bridge lives in `server/src/services/connectors/unikraft/`.
|
||||
|
||||
The audit event stream (`GET /v1/audit`) is server-sent events, not a
|
||||
request/response action. It is recorded in the generated inventory and not
|
||||
advertised as a tool.
|
||||
|
||||
The specification sources and SHA-256 hashes are recorded in
|
||||
`server/src/services/connectors/unikraft/operations.generated.json`. Regenerate with:
|
||||
|
||||
```sh
|
||||
node scripts/generate-unikraft-tools.mjs
|
||||
node --test scripts/generate-unikraft-tools.test.mjs
|
||||
```
|
||||
|
||||
The generator pins a reviewed commit because the specification repository has
|
||||
no tags or releases. Updating it requires explicit regeneration and catalog
|
||||
review: saved connections quarantine new and changed actions until reviewed.
|
||||
|
||||
## Wizard path
|
||||
|
||||
The ordinary key wizard: **Access → Connect**. Setup asks only for the token.
|
||||
The default metro is an optional select under **Advanced**. Setup completes when
|
||||
`GET /v1/users/quotas` on the default metro confirms the token.
|
||||
|
||||
## Governance defaults
|
||||
|
||||
- Risk tier S4: the normal catalog includes deletion and arbitrary command
|
||||
execution inside sandboxes.
|
||||
- Every discovered action is enabled when setup finishes, under the shared
|
||||
connection access rules; operators restrict actions on Permissions.
|
||||
- New and changed actions after a catalog update are quarantined until reviewed.
|
||||
- Agents cannot export the token; generic credential export rejects Unikraft.
|
||||
- Responses and request bodies are bounded at 8 MiB. Each action has one caller
|
||||
deadline, capped at 30 seconds. Mutations are never retried automatically; a
|
||||
timed-out write can still complete at the provider.
|
||||
|
||||
## Manifest
|
||||
|
||||
Defined in `scripts/app-definition-overrides/unikraft.json` and generated into
|
||||
`packages/shared/src/app-definitions/unikraft.json`: slug `unikraft`, category
|
||||
`developer`, one method `api-token` (`rest_api`, `api_key`, `customer`), and a
|
||||
`metro` select tenant field.
|
||||
|
||||
## Validation hook
|
||||
|
||||
Database-backed tests exercise company and personal vault ownership, real
|
||||
run-scoped gateway reads, writes, deletions and sandbox commands, default and
|
||||
per-call metros, rejection of unpublished metros and path traversal, Off
|
||||
policies, revocation, stale-contract blocking, credential export denial, all
|
||||
generated argument schemas, and bounded/sanitized errors. HTTP boundary tests
|
||||
send GET and DELETE JSON bodies through the real pinned transport to a local
|
||||
server. Generator tests cover streaming endpoints, shared parameter references,
|
||||
unsigned integer formats and risk classification.
|
||||
|
||||
These fixtures do not qualify a real Unikraft account. Live provider proof is
|
||||
pending an operator connecting a token and trying an allowed read and write
|
||||
(`liveProof: not-run`).
|
||||
|
||||
## Brand
|
||||
|
||||
`ui/public/brands/apps/unikraft.svg` is Unikraft's gradient mark from the
|
||||
official documentation site, copied from
|
||||
`https://raw.githubusercontent.com/unikraft-cloud/docs/d8acfcefe8186badac84d5308d641a1c1b5b239e/public/unikraft.svg`
|
||||
with editor metadata removed. One asset serves both themes. Unikraft publishes
|
||||
no brand kit.
|
||||
|
||||
Provider research verified 2026-10-15.
|
||||
@@ -2879,6 +2879,24 @@
|
||||
"liveProof": "not-run",
|
||||
"evidenceLimit": "Public provider documentation and OAuth metadata only. Account-bound connect, discovery, read/write execution, run-scoped gateway, refresh/reconnect, removal, and secret handling remain to be qualified for this method.",
|
||||
"keyPermissions": "Create a Braintrust API key in Settings → API keys for the account and organization whose data agents should access. The key must authorize the reads and writes you intend to allow; Paperclip cannot increase or narrow its provider permissions. Evaluations and Topics processing can incur costs. Dataset edits can permanently delete rows; automation activation can enable retention or delivery."
|
||||
},
|
||||
{
|
||||
"app": "unikraft",
|
||||
"method": "api-token",
|
||||
"auth": "api_key",
|
||||
"policy": "provider-key",
|
||||
"requestedScopes": [],
|
||||
"capability": "write",
|
||||
"supportedActions": "All published Unikraft Cloud platform API operations (instances, checkpoints, templates, service groups, autoscale, volumes, images, certificates, and quotas) and sandbox plugin operations (commands and files) in every published metro. The audit event stream is not exposed.",
|
||||
"evidence": [
|
||||
"https://unikraft.com/docs/sdks/overview#authentication",
|
||||
"https://unikraft.com/docs/platform/metros",
|
||||
"https://raw.githubusercontent.com/unikraft-cloud/openapi/5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b/platform.json",
|
||||
"https://raw.githubusercontent.com/unikraft-cloud/openapi/5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b/plugins/sandbox.json"
|
||||
],
|
||||
"reviewedAt": "2026-10-15",
|
||||
"liveProof": "not-run",
|
||||
"keyPermissions": "Create an API token in the Unikraft Cloud console. A token acts for its whole organization in every metro, including running sandbox commands and deleting resources. Unikraft does not offer narrower token permissions, so choose trusted agents and use action permissions to limit what they can do."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -131,6 +131,12 @@ and stale same-name contracts before refresh; new/changed tools stay quarantined
|
||||
exercise this boundary through real run-bound gateway sessions with fixture
|
||||
provider responses. They do not qualify a live Daytona account or agent model.
|
||||
|
||||
Unikraft uses the same native-catalog contract: stale same-name saved entries are
|
||||
rejected before refresh and new or changed actions stay quarantined. Its
|
||||
[database-backed fixtures](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover this through real run-bound gateway sessions with fixture provider
|
||||
responses, not a live Unikraft account.
|
||||
|
||||
## `test-action`
|
||||
|
||||
Capability ID: `app-permissions/test-action`.
|
||||
|
||||
@@ -137,6 +137,13 @@ gateway; they cannot export the key. [Database-backed fixtures](../../server/src
|
||||
cover selected vault ownership and gateway reads/writes with provider responses
|
||||
replaced by fixtures. Real provider authentication remains unqualified.
|
||||
|
||||
Unikraft native setup uses an organization API token in the selected personal or
|
||||
company vault grant. It does not borrow `unikraft login` state or `UKC_TOKEN`.
|
||||
Agents use assigned provider tools through the run-bound gateway, limited to the
|
||||
published metro hosts, and cannot export the token. [Database-backed fixtures](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover vault ownership and gateway reads, writes and sandbox commands with
|
||||
fixture provider responses. Real provider authentication remains unqualified.
|
||||
|
||||
## `handoff`
|
||||
|
||||
Capability ID: `connection-setup/handoff`.
|
||||
|
||||
@@ -85,6 +85,12 @@ remain in place. [Daytona regressions](../../server/src/__tests__/daytona-connec
|
||||
cover shared/personal catalog upgrades, failed refresh, permission preservation,
|
||||
run-bound file transfers, and revocation with fixture provider responses.
|
||||
|
||||
Unikraft actions follow the same review rules: deletion, stop/suspend/detach and
|
||||
sandbox command execution are destructive, and catalog updates quarantine new or
|
||||
changed actions. [Unikraft regressions](../../server/src/__tests__/unikraft-connection.test.ts)
|
||||
cover classification, Off policies, revocation and stale contracts with fixture
|
||||
provider responses.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Connected does not imply an agent has access or an action is allowed.
|
||||
|
||||
@@ -137,6 +137,13 @@ account-bound proof. Live token expiry/refresh, remote OAuth revocation, and
|
||||
native CLI automatic capability delivery remain unqualified; public metadata
|
||||
and fixtures do not establish those results.
|
||||
|
||||
Unikraft follows the same key/access wizard. Setup asks only for the API token;
|
||||
the default metro is an optional select under Advanced, and each action can name
|
||||
another published metro. [Unikraft browser setup](../../tests/e2e/unikraft-connector.spec.ts)
|
||||
checks the catalog, password field, access controls and the advanced metro. It
|
||||
does not submit a token or qualify a real provider account. See
|
||||
[Unikraft](../../doc/connections/UNIKRAFT.md) for token authority and metros.
|
||||
|
||||
### `mcp-link`
|
||||
|
||||
Automated: AppsConnect's generic MCP cases cover probe-driven auth, private or
|
||||
|
||||
@@ -105,6 +105,7 @@ import a103 from "./app-definitions/local.json" with { type: "json" };
|
||||
import a104 from "./app-definitions/daytona.json" with { type: "json" };
|
||||
import a105 from "./app-definitions/monday.json" with { type: "json" };
|
||||
import a106 from "./app-definitions/paperclip-notebook.json" with { type: "json" };
|
||||
import a107 from "./app-definitions/speko.json" with { type: "json" };
|
||||
import a107 from "./app-definitions/unikraft.json" with { type: "json" };
|
||||
import a108 from "./app-definitions/speko.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,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107] 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,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105,a106,a107,a108] as AppDefinition[];
|
||||
@@ -886,7 +886,7 @@ describe("AppDefinition catalog", () => {
|
||||
"ticktick",
|
||||
"xero",
|
||||
]);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(85);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(86);
|
||||
const connectableSlugs = new Set(
|
||||
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
|
||||
);
|
||||
@@ -1285,6 +1285,38 @@ describe("AppDefinition catalog", () => {
|
||||
expect(method.warnings).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
it("connects Unikraft Cloud's published HTTP APIs with an organization API token", () => {
|
||||
const unikraft = APP_DEFINITIONS.find((app) => app.slug === "unikraft")!;
|
||||
expect(unikraft).toMatchObject({
|
||||
name: "Unikraft",
|
||||
categories: ["developer"],
|
||||
urlPatterns: ["https://console.unikraft.com/*", "https://*.unikraft.cloud/*"],
|
||||
docsUrl: "https://unikraft.com/docs/api/platform/v1",
|
||||
branding: { logoUrl: "/brands/apps/unikraft.svg" },
|
||||
});
|
||||
expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "unikraft")).toBe(true);
|
||||
expect(CONNECTABLE_APP_DEFINITIONS.some((app) => app.slug === "unikraft")).toBe(true);
|
||||
expect(unikraft.methods.map((method) => method.key)).toEqual(["api-token"]);
|
||||
const [method] = unikraft.methods;
|
||||
expect(method).toMatchObject({
|
||||
transport: "rest_api",
|
||||
auth: "api_key",
|
||||
ownershipModes: ["customer"],
|
||||
riskTier: "S4",
|
||||
defaults: { serverUrl: "https://controlplane.unikraft.cloud/v1" },
|
||||
keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " },
|
||||
});
|
||||
expect(method!.credentialFields).toEqual([
|
||||
expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }),
|
||||
]);
|
||||
// The key form renders credential helper text, not method warnings.
|
||||
expect(method!.credentialFields?.[0]?.helperMd).toContain("running sandbox commands and deleting resources");
|
||||
// The default metro is optional: setup asks only for the token.
|
||||
expect(method!.tenantFields).toEqual([
|
||||
expect.objectContaining({ key: "metro", type: "select", advanced: true, defaultValue: "fra" }),
|
||||
]);
|
||||
expect(method!.tenantFields?.[0]?.options?.map((option) => option.value)).toEqual(["dal", "fra", "sfo", "sin", "was"]);
|
||||
});
|
||||
it("connects Gauge's hosted server with browser sign-in or an organization API key", () => {
|
||||
const gauge = APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
|
||||
expect(gauge).toMatchObject({
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"agentmail",
|
||||
"browser-use-cloud",
|
||||
"daytona",
|
||||
"unikraft",
|
||||
"cognee",
|
||||
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
|
||||
"monday",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "unikraft",
|
||||
"name": "Unikraft",
|
||||
"description": "Manage Unikraft Cloud instances, services, volumes, images, certificates, autoscaling, and sandbox commands and files.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"urlPatterns": [
|
||||
"https://console.unikraft.com/*",
|
||||
"https://*.unikraft.cloud/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "api-token",
|
||||
"label": "Connect Unikraft",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run sandbox commands in your Unikraft Cloud organization.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://controlplane.unikraft.cloud/v1"
|
||||
},
|
||||
"guidanceMd": "Connect a Unikraft Cloud API token. All published platform API and sandbox plugin actions are available; your action policies control which ones agents can use. Each action can target any Unikraft Cloud metro, and uses the default metro when it does not name one.",
|
||||
"riskTier": "S4",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Unikraft API token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Unikraft API token",
|
||||
"secret": true,
|
||||
"helperMd": "Create an API token in the Unikraft Cloud console. A token acts for its whole organization in every metro, including running sandbox commands and deleting resources. Unikraft does not offer narrower token permissions, so choose trusted agents and use action permissions to limit what they can do."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "metro",
|
||||
"label": "Default metro",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"defaultValue": "fra",
|
||||
"helperMd": "Used to verify the token and for actions that do not name a metro.",
|
||||
"options": [
|
||||
{
|
||||
"value": "dal",
|
||||
"label": "Dallas (dal)"
|
||||
},
|
||||
{
|
||||
"value": "fra",
|
||||
"label": "Frankfurt (fra)"
|
||||
},
|
||||
{
|
||||
"value": "sfo",
|
||||
"label": "San Francisco (sfo)"
|
||||
},
|
||||
{
|
||||
"value": "sin",
|
||||
"label": "Singapore (sin)"
|
||||
},
|
||||
{
|
||||
"value": "was",
|
||||
"label": "Washington, DC (was)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"metro",
|
||||
"instance"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://console.unikraft.com",
|
||||
"docs": "https://unikraft.com/docs/sdks/overview#authentication"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands in sandboxes, read and write sandbox files, start and stop instances, and delete instances, volumes, and other resources. Select trusted agents and set action permissions before use. Your Unikraft API token stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://unikraft.com/docs/api/platform/v1",
|
||||
"branding": {
|
||||
"logoUrl": "/brands/apps/unikraft.svg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "unikraft",
|
||||
"name": "Unikraft",
|
||||
"description": "Manage Unikraft Cloud instances, services, volumes, images, certificates, autoscaling, and sandbox commands and files.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"urlPatterns": [
|
||||
"https://console.unikraft.com/*",
|
||||
"https://*.unikraft.cloud/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "api-token",
|
||||
"label": "Connect Unikraft",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run sandbox commands in your Unikraft Cloud organization.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://controlplane.unikraft.cloud/v1"
|
||||
},
|
||||
"guidanceMd": "Connect a Unikraft Cloud API token. All published platform API and sandbox plugin actions are available; your action policies control which ones agents can use. Each action can target any Unikraft Cloud metro, and uses the default metro when it does not name one.",
|
||||
"riskTier": "S4",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Unikraft API token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Unikraft API token",
|
||||
"secret": true
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "metro",
|
||||
"label": "Default metro",
|
||||
"type": "select",
|
||||
"advanced": true,
|
||||
"defaultValue": "fra",
|
||||
"helperMd": "Used to verify the token and for actions that do not name a metro.",
|
||||
"options": [
|
||||
{ "value": "dal", "label": "Dallas (dal)" },
|
||||
{ "value": "fra", "label": "Frankfurt (fra)" },
|
||||
{ "value": "sfo", "label": "San Francisco (sfo)" },
|
||||
{ "value": "sin", "label": "Singapore (sin)" },
|
||||
{ "value": "was", "label": "Washington, DC (was)" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"metro",
|
||||
"instance"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://console.unikraft.com",
|
||||
"docs": "https://unikraft.com/docs/sdks/overview#authentication"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands in sandboxes, read and write sandbox files, start and stop instances, and delete instances, volumes, and other resources. Select trusted agents and set action permissions before use. Your Unikraft API token stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://unikraft.com/docs/api/platform/v1"
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const snake = value => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a
|
||||
|
||||
/** Retain only reachable input definitions, rather than copying the whole API
|
||||
* schema into every action. Provider response schemas are not tool inputs. */
|
||||
function inputSchema(value, schemas) {
|
||||
export function inputSchema(value, schemas) {
|
||||
const definitions = {};
|
||||
function visit(node) {
|
||||
if (Array.isArray(node)) return node.map(visit);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { inputSchema } from "./generate-daytona-tools.mjs";
|
||||
|
||||
// The official specification repository has no tags or releases. Pin a
|
||||
// reviewed commit so a provider update needs explicit regeneration and review.
|
||||
const commit = "5dd107ea1cd8716b83cb86a83ed2a6ee46f99b5b";
|
||||
const sources = {
|
||||
platform: `https://raw.githubusercontent.com/unikraft-cloud/openapi/${commit}/platform.json`,
|
||||
sandbox: `https://raw.githubusercontent.com/unikraft-cloud/openapi/${commit}/plugins/sandbox.json`,
|
||||
};
|
||||
const verbs = new Set(["get", "post", "put", "patch", "delete"]);
|
||||
const snake = value => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase();
|
||||
// Requests that wait, read, or list without changing provider state.
|
||||
const readOnlyPosts = new Set(["ReadFile", "ReadRawFile", "WaitForCommand", "WaitForCommandWithTimeout"]);
|
||||
// Reversible but disruptive lifecycle changes, and code execution in a sandbox.
|
||||
const destructiveOperations = /^(Stop|Suspend|Detach|Unpin)|^(RunCommand|SignalCommand|WriteCommandStdin)$/;
|
||||
|
||||
function parameterDefinition(parameter, spec) {
|
||||
if (!parameter.$ref) return parameter;
|
||||
const prefix = "#/components/parameters/";
|
||||
if (!parameter.$ref.startsWith(prefix)) throw new Error(`Unsupported parameter reference: ${parameter.$ref}`);
|
||||
const resolved = spec.components?.parameters?.[parameter.$ref.slice(prefix.length)];
|
||||
if (!resolved) throw new Error(`Missing parameter: ${parameter.$ref}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Ajv does not define OpenAPI's unsigned integer formats. Keep their sign
|
||||
* constraint instead of an unknown format that would not validate anything.
|
||||
* Some unsigned fields document -1 as a sentinel (for example, the longest
|
||||
* drain time); keep that value valid. */
|
||||
function unsignedFormats(node) {
|
||||
if (Array.isArray(node)) return node.forEach(unsignedFormats);
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (/^uint(8|16|32|64)$/.test(node.format ?? "")) {
|
||||
delete node.format;
|
||||
node.minimum ??= /(^|\s)-1\b/.test(node.description ?? "") ? -1 : 0;
|
||||
}
|
||||
Object.values(node).forEach(unsignedFormats);
|
||||
}
|
||||
|
||||
function streamingOperation(operation) {
|
||||
return Object.values(operation.responses ?? {}).some(response => Object.hasOwn(response.content ?? {}, "text/event-stream"));
|
||||
}
|
||||
|
||||
export function generateUnikraftCatalog(specs) {
|
||||
const metros = (specs.platform?.servers ?? []).map(server => new URL(server.url).hostname.match(/^api\.([a-z0-9]+)\.unikraft\.cloud$/)?.[1])
|
||||
.filter(Boolean).sort();
|
||||
const tools = [];
|
||||
const streaming = [];
|
||||
for (const [kind, spec] of Object.entries(specs)) {
|
||||
for (const [route, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!verbs.has(method)) continue;
|
||||
if (!operation.operationId) throw new Error(`Missing operationId: ${kind} ${method} ${route}`);
|
||||
const name = kind === "platform" ? `unikraft_${snake(operation.operationId)}` : `unikraft_${kind}_${snake(operation.operationId)}`;
|
||||
// Server-sent event streams hold a connection open; they are not
|
||||
// request/response actions. Keep them in the inventory so an update
|
||||
// cannot silently lose an operation.
|
||||
if (streamingOperation(operation)) {
|
||||
streaming.push({ name, kind, path: route, method: method.toUpperCase(), transport: "server-sent-events" });
|
||||
continue;
|
||||
}
|
||||
const properties = {}, required = [], parameters = [];
|
||||
const groups = { path: { type: "object", properties: {}, required: [], additionalProperties: false },
|
||||
query: { type: "object", properties: {}, required: [], additionalProperties: false },
|
||||
headers: { type: "object", properties: {}, required: [], additionalProperties: false } };
|
||||
for (const reference of [...(item.parameters ?? []), ...(operation.parameters ?? [])]) {
|
||||
const parameter = parameterDefinition(reference, spec);
|
||||
const group = parameter.in === "header" ? "headers" : parameter.in;
|
||||
if (!groups[group]) throw new Error(`Unsupported parameter: ${name} ${parameter.in}`);
|
||||
// Authorization comes from the selected connection, never an argument.
|
||||
if (group === "headers" && /^(authorization|cookie|host)$/i.test(parameter.name)) continue;
|
||||
groups[group].properties[parameter.name] = { ...(parameter.schema ?? {}),
|
||||
...(parameter.description ? { description: parameter.description } : {}) };
|
||||
if (parameter.required) groups[group].required.push(parameter.name);
|
||||
parameters.push({ name: parameter.name, group, style: parameter.style ?? "form", explode: parameter.explode ?? true });
|
||||
}
|
||||
for (const [group, schema] of Object.entries(groups)) {
|
||||
if (!Object.keys(schema.properties).length) continue;
|
||||
properties[group] = schema;
|
||||
if (schema.required.length) required.push(group);
|
||||
}
|
||||
properties.metro = { type: "string", enum: metros, description: "Unikraft Cloud metro. Defaults to the connection's default metro." };
|
||||
if (kind === "sandbox") {
|
||||
properties.instance = { type: "string", minLength: 1, maxLength: 200, description: "Name or UUID of the instance running the sandbox plugin." };
|
||||
required.push("instance");
|
||||
}
|
||||
const content = operation.requestBody?.content ?? {};
|
||||
const bodyContentType = Object.hasOwn(content, "application/json") ? "application/json" : Object.keys(content)[0];
|
||||
if (bodyContentType && bodyContentType !== "application/json") throw new Error(`Unsupported request body: ${name} ${bodyContentType}`);
|
||||
if (bodyContentType) {
|
||||
properties.body = content[bodyContentType].schema ?? {};
|
||||
if (operation.requestBody.required) required.push("body");
|
||||
}
|
||||
const readOnly = method === "get" || readOnlyPosts.has(operation.operationId);
|
||||
const destructive = !readOnly && (method === "delete" || destructiveOperations.test(operation.operationId));
|
||||
const schema = inputSchema({ type: "object", additionalProperties: false, properties, required }, spec.components?.schemas ?? {});
|
||||
unsignedFormats(schema);
|
||||
tools.push({ name, kind, path: route, method: method.toUpperCase(), parameters,
|
||||
...(bodyContentType ? { bodyContentType } : {}),
|
||||
title: operation.summary || operation.operationId,
|
||||
description: operation.description || operation.summary || operation.operationId,
|
||||
inputSchema: schema,
|
||||
annotations: { readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: readOnly, openWorldHint: true } });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metros.length) throw new Error("The platform specification lists no Unikraft Cloud metros");
|
||||
const names = tools.map(tool => tool.name);
|
||||
if (new Set(names).size !== names.length) throw new Error("Duplicate Unikraft action name");
|
||||
return { metros, tools, streaming };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputIndex = process.argv.indexOf("--input-dir");
|
||||
const inputDir = inputIndex < 0 ? undefined : process.argv[inputIndex + 1];
|
||||
const specs = {}, provenance = {};
|
||||
for (const [kind, url] of Object.entries(sources)) {
|
||||
const raw = inputDir ? await readFile(path.join(inputDir, `unikraft-${kind}-openapi.json`))
|
||||
: Buffer.from(await (await fetch(url)).arrayBuffer());
|
||||
specs[kind] = JSON.parse(raw.toString("utf8"));
|
||||
provenance[kind] = { url, sha256: createHash("sha256").update(raw).digest("hex") };
|
||||
}
|
||||
const output = { schemaVersion: 1, sources: provenance, ...generateUnikraftCatalog(specs) };
|
||||
const target = fileURLToPath(new URL("../server/src/services/connectors/unikraft/operations.generated.json", import.meta.url));
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, JSON.stringify(output, null, 2) + "\n");
|
||||
console.log(`Generated ${output.tools.length} Unikraft actions across ${output.metros.length} metros; recorded ${output.streaming.length} streaming endpoints.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateUnikraftCatalog } from "./generate-unikraft-tools.mjs";
|
||||
|
||||
const servers = [{ url: "https://api.fra.unikraft.cloud" }, { url: "https://api.dal.unikraft.cloud" }];
|
||||
|
||||
test("accounts for every HTTP operation and server-sent event stream without dropping methods", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: {
|
||||
"/v1/instances": { get: { operationId: "GetInstances" }, post: { operationId: "CreateInstance" } },
|
||||
"/v1/audit": { get: { operationId: "SubscribeAuditEvents", responses: { default: { content: { "text/event-stream": {} } } } } },
|
||||
} } });
|
||||
assert.deepEqual(result.metros, ["dal", "fra"]);
|
||||
assert.deepEqual(result.tools.map(t => t.name), ["unikraft_get_instances", "unikraft_create_instance"]);
|
||||
assert.deepEqual(result.streaming.map(s => s.name), ["unikraft_subscribe_audit_events"]);
|
||||
assert.deepEqual(result.tools[0].inputSchema.properties.metro.enum, ["dal", "fra"]);
|
||||
});
|
||||
|
||||
test("resolves shared parameters, keeps GET bodies and replaces unsigned formats", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: { "/v1/instances/log": { get: {
|
||||
operationId: "GetInstanceLogs",
|
||||
parameters: [{ $ref: "#/components/parameters/Uuid" }, { name: "limit", in: "query", schema: { type: "integer", format: "uint32" } },
|
||||
{ name: "Authorization", in: "header", schema: { type: "string" } }],
|
||||
requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Target" } } } },
|
||||
} } }, components: {
|
||||
parameters: { Uuid: { name: "uuid", in: "query", schema: { type: "array", items: { type: "string" } } } },
|
||||
schemas: { Target: { type: "object", properties: { offset: { type: "integer", format: "uint64" },
|
||||
drain_timeout_ms: { type: "integer", format: "uint64", description: "No draining if 0. Use -1 for the largest possible value." } } } },
|
||||
} } });
|
||||
const [tool] = result.tools;
|
||||
assert.deepEqual(tool.parameters.map(p => p.name), ["uuid", "limit"]);
|
||||
assert.equal(tool.inputSchema.properties.headers, undefined);
|
||||
assert.deepEqual(tool.inputSchema.properties.query.properties.limit, { type: "integer", minimum: 0 });
|
||||
assert.deepEqual(tool.inputSchema.definitions.Target.properties.offset, { type: "integer", minimum: 0 });
|
||||
// A documented -1 sentinel stays valid.
|
||||
assert.equal(tool.inputSchema.definitions.Target.properties.drain_timeout_ms.minimum, -1);
|
||||
assert.equal(tool.bodyContentType, "application/json");
|
||||
assert.ok(tool.inputSchema.required.includes("body"));
|
||||
assert.equal(tool.annotations.readOnlyHint, true);
|
||||
});
|
||||
|
||||
test("targets a sandbox instance and treats execution, disruption and deletion as destructive", () => {
|
||||
const result = generateUnikraftCatalog({ platform: { servers, paths: {
|
||||
"/v1/instances/stop": { put: { operationId: "StopInstances" } },
|
||||
"/v1/instances/start": { put: { operationId: "StartInstances" } },
|
||||
"/v1/volumes/detach": { put: { operationId: "DetachVolumes" } },
|
||||
"/v1/volumes/{uuid}": { delete: { operationId: "DeleteVolumeByUUID" } },
|
||||
} }, sandbox: { paths: {
|
||||
"/commands": { post: { operationId: "RunCommand" } },
|
||||
"/fs/read": { post: { operationId: "ReadFile" } },
|
||||
"/fs/write": { post: { operationId: "WriteFile" } },
|
||||
} } });
|
||||
const risk = Object.fromEntries(result.tools.map(t => [t.name,
|
||||
t.annotations.destructiveHint ? "destructive" : t.annotations.readOnlyHint ? "read" : "write"]));
|
||||
assert.deepEqual(risk, {
|
||||
unikraft_stop_instances: "destructive", unikraft_start_instances: "write", unikraft_detach_volumes: "destructive",
|
||||
unikraft_delete_volume_by_uuid: "destructive", unikraft_sandbox_run_command: "destructive",
|
||||
unikraft_sandbox_read_file: "read", unikraft_sandbox_write_file: "write",
|
||||
});
|
||||
const sandbox = result.tools.find(t => t.name === "unikraft_sandbox_run_command");
|
||||
assert.ok(sandbox.inputSchema.required.includes("instance"));
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("reviewed connector risk dispatch", () => {
|
||||
});
|
||||
|
||||
describe("reviewed connector catalog policies", () => {
|
||||
it.each(["railway", "enterpret", "daytona", "braintrust"])("declares the initial drift policy for %s", source => {
|
||||
it.each(["railway", "enterpret", "daytona", "unikraft", "braintrust"])("declares the initial drift policy for %s", source => {
|
||||
expect(connectorToolPolicy(source).quarantineNewEntries).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb,
|
||||
ensurePostgresDatabase, applyPendingMigrations, heartbeatRuns, issues, toolCatalogEntries, toolConnections } from "@paperclipai/db";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { createToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { UNIKRAFT_TOOLS, executeUnikraftTool, unikraftClient } from "../services/connectors/unikraft/client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const actor = { actorType: "user" as const, actorId: "unikraft-reviewer" };
|
||||
const credential = "unikraft-fixture-token";
|
||||
(support.supported ? describe : describe.skip)("Unikraft governed provider actions", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
beforeAll(async () => {
|
||||
if (external) {
|
||||
const url = new URL(external); url.pathname = "/unikraft_" + randomUUID().replaceAll("-", "");
|
||||
await ensurePostgresDatabase(external, url.pathname.slice(1));
|
||||
await applyPendingMigrations(url.toString()); db = createDb(url.toString());
|
||||
} else { temp = await startEmbeddedPostgresTestDatabase("unikraft-connector-"); db = createDb(temp.connectionString); }
|
||||
}, 30000);
|
||||
afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); });
|
||||
|
||||
async function fixture(options: { personal?: boolean; metro?: string } = {}) {
|
||||
const [company] = await db.insert(companies).values({ name: "Unikraft fixture", issuePrefix: `U${randomUUID().slice(0, 6)}` }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" });
|
||||
const instanceId = randomUUID();
|
||||
const request = vi.fn(async (url: string, init: RequestInit) => {
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${credential}`);
|
||||
expect(["error", "manual"]).toContain(init.redirect);
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.hostname).toMatch(/^api\.(dal|fra|sfo|sin|was)\.unikraft\.cloud$/);
|
||||
const metro = parsed.hostname.split(".")[1];
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||||
if (parsed.pathname === "/v1/users/quotas") return Response.json({ status: "success", data: { quotas: [{ uuid: randomUUID() }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === "/v1/instances" && init.method === "GET") {
|
||||
return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, name: "web", metro, selector: body ?? null }] }, op_time_us: 1 });
|
||||
}
|
||||
if (parsed.pathname === "/v1/instances" && init.method === "POST") return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, ...body }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === `/v1/instances/${instanceId}` && init.method === "DELETE") return Response.json({ status: "success", data: { instances: [{ uuid: instanceId, state: "deleted" }] }, op_time_us: 1 });
|
||||
if (parsed.pathname === "/v1/instances/web/plugins/sandbox/commands" && init.method === "POST") {
|
||||
return Response.json({ uuid: randomUUID(), cmd: body.cmd, stdout: `ran with ${credential}` });
|
||||
}
|
||||
throw new Error(`Unexpected provider request ${init.method} ${url}`);
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "unikraft", methodKey: "api-token",
|
||||
...(options.personal ? { grantKind: "user" as const } : {}), ...(options.metro ? { configValues: { metro: options.metro } } : {}),
|
||||
credentialValues: { "credentials.authorization": credential } }, actor);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Operator", role: "engineer", adapterType: "process" }).returning();
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map(c => c.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] } }, actor);
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Provider work", assigneeAgentId: agent.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id,
|
||||
scopeKind: "issue", invocationSource: "on_demand", status: "running", responsibleUserId: actor.actorId, contextSnapshot: { issueId: issue.id } }).returning();
|
||||
const gateway = createToolGatewayService(db, { remoteHttpRequest: request });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id, issueId: issue.id });
|
||||
const tools = await gateway.listToolsForSession(session.token);
|
||||
const call = (name: string, parameters: unknown) => {
|
||||
const tool = tools.find(t => t.upstreamToolName === name);
|
||||
expect(tool, name).toBeTruthy();
|
||||
return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters, idempotencyKey: randomUUID() });
|
||||
};
|
||||
return { company, service, connected, agent, request, call, gateway, session, instanceId };
|
||||
}
|
||||
|
||||
it.each([false, true])("uses the selected vault identity for real gateway reads, writes and sandbox commands (personal=%s)", async personal => {
|
||||
const f = await fixture({ personal });
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connected.connectionId));
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
|
||||
expect(grant.kind).toBe(personal ? "user" : "organization");
|
||||
expect(secret.scope).toBe(personal ? "user" : "company");
|
||||
expect(secret.ownerUserId).toBe(personal ? actor.actorId : null);
|
||||
expect(f.connected.catalog).toHaveLength(UNIKRAFT_TOOLS.length);
|
||||
expect(f.connected.catalog.find(c => c.toolName === "unikraft_get_instances")).toMatchObject({ isReadOnly: true });
|
||||
expect(f.connected.catalog.find(c => c.toolName === "unikraft_create_instance")).toMatchObject({ isReadOnly: false, isDestructive: false });
|
||||
for (const name of ["unikraft_delete_instance_by_uuid", "unikraft_stop_instances", "unikraft_sandbox_run_command"]) {
|
||||
expect(f.connected.catalog.find(c => c.toolName === name), name).toMatchObject({ isDestructive: true, isReadOnly: false });
|
||||
}
|
||||
expect(JSON.stringify(await f.service.getConnection(f.connected.connectionId))).not.toContain(credential);
|
||||
// The token is verified against the default metro.
|
||||
expect(f.request.mock.calls.some(([url]) => url === "https://api.fra.unikraft.cloud/v1/users/quotas")).toBe(true);
|
||||
// Batch selectors travel as a JSON body even on GET.
|
||||
const listed = JSON.stringify(await f.call("unikraft_get_instances", { body: [{ name: "web" }] }));
|
||||
expect(listed).toContain(f.instanceId); expect(listed).toContain('"metro":"fra"'); expect(listed).toContain('"name":"web"');
|
||||
expect(JSON.stringify(await f.call("unikraft_get_instances", { metro: "sin" }))).toContain('"metro":"sin"');
|
||||
expect(JSON.stringify(await f.call("unikraft_create_instance", { body: { image: "nginx:latest" } }))).toContain("nginx:latest");
|
||||
expect(JSON.stringify(await f.call("unikraft_delete_instance_by_uuid", { path: { uuid: f.instanceId } }))).toContain("deleted");
|
||||
const ran = JSON.stringify(await f.call("unikraft_sandbox_run_command", { instance: "web", body: { cmd: "echo done" } }));
|
||||
expect(ran).toContain("echo done"); expect(ran).not.toContain(credential);
|
||||
}, 30000);
|
||||
|
||||
it("uses the connection's default metro for verification and actions that do not name one", async () => {
|
||||
const f = await fixture({ metro: "was" });
|
||||
expect(f.request.mock.calls.some(([url]) => url === "https://api.was.unikraft.cloud/v1/users/quotas")).toBe(true);
|
||||
expect(JSON.stringify(await f.call("unikraft_get_instances", {}))).toContain('"metro":"was"');
|
||||
});
|
||||
|
||||
it("rejects unpublished metros, unknown arguments, path traversal and foreign bindings before forwarding the token", async () => {
|
||||
const f = await fixture(); f.request.mockClear();
|
||||
await expect(f.call("unikraft_get_instances", { metro: "attacker.example" })).rejects.toThrow();
|
||||
await expect(f.call("unikraft_get_instances", { command: "printenv" })).rejects.toThrow();
|
||||
await expect(f.call("unikraft_sandbox_run_command", { instance: "../../users/quotas", body: { cmd: "true" } })).rejects.toThrow("Invalid Unikraft path");
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await expect(executeUnikraftTool({ binding: { companyId: randomUUID(), agentId: f.agent.id },
|
||||
connection: { companyId: f.company.id, config: {} }, headers: { Authorization: `Bearer ${credential}` }, request: f.request,
|
||||
toolName: "unikraft_get_instances", parameters: {} })).rejects.toThrow("Connection not found");
|
||||
await expect(executeUnikraftTool({ binding: { companyId: f.company.id, agentId: f.agent.id },
|
||||
connection: { companyId: f.company.id, config: { methodConfig: { metro: "evil" } } }, headers: { Authorization: `Bearer ${credential}` },
|
||||
request: f.request, toolName: "unikraft_get_instances", parameters: {} })).rejects.toThrow("Choose a Unikraft Cloud metro");
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks revocation and Off policies for a previously created gateway session", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
enabledCatalogEntryIds: f.connected.catalog.filter(c => c.toolName !== "unikraft_sandbox_run_command").map(c => c.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
||||
}, actor);
|
||||
f.request.mockClear();
|
||||
await expect(f.call("unikraft_sandbox_run_command", { instance: "web", body: { cmd: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
await db.update(toolConnections).set({ enabled: false }).where(and(eq(toolConnections.id, f.connected.connectionId), eq(toolConnections.companyId, f.company.id)));
|
||||
await expect(f.call("unikraft_get_instances", {})).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a stale same-name contract and quarantines it on refresh", async () => {
|
||||
const f = await fixture();
|
||||
const changed = f.connected.catalog.find(entry => entry.toolName === "unikraft_sandbox_run_command")!;
|
||||
await db.update(toolCatalogEntries).set({ versionHash: "earlier-provider-contract", schemaHash: "earlier-schema", riskLevel: "read" })
|
||||
.where(eq(toolCatalogEntries.id, changed.id));
|
||||
f.request.mockClear();
|
||||
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName === changed.toolName)).toBe(false);
|
||||
await expect(f.call(changed.toolName, { instance: "web", body: { cmd: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
const refreshed = await f.service.refreshCatalog(f.connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(refreshed.catalog.find(entry => entry.toolName === changed.toolName)?.status).toBe("quarantined");
|
||||
});
|
||||
|
||||
it("does not export the token to agents", async () => {
|
||||
const f = await fixture();
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agent.id));
|
||||
await expect(f.service.mintConnectionTokenForAgent({ connectionId: f.connected.connectionId, companyId: f.company.id,
|
||||
agentId: f.agent.id, runId: run.id, body: {} as never })).rejects.toThrow("Unikraft credentials stay in the governed tool gateway");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unikraft provider client", () => {
|
||||
it("validates every published action schema before provider dispatch", async () => {
|
||||
const request = vi.fn(async () => { throw new Error("Unexpected provider request"); });
|
||||
expect(UNIKRAFT_TOOLS).toHaveLength(105);
|
||||
for (const tool of UNIKRAFT_TOOLS) {
|
||||
await expect(executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" },
|
||||
connection: { companyId: "company", config: {} }, headers: { Authorization: `Bearer ${credential}` },
|
||||
request, toolName: tool.name, parameters: null }), tool.name).rejects.toThrow("Invalid Unikraft action arguments");
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("sanitizes provider denials and rejects an unconfirmed token", async () => {
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => new Response(credential, { status: 401 })).probe())
|
||||
.rejects.toThrow("Unikraft denied access");
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => Response.json({ status: "error" })).probe())
|
||||
.rejects.toThrow("did not confirm");
|
||||
});
|
||||
it("accepts the documented -1 drain timeout for the longest drain", async () => {
|
||||
const request = vi.fn(async (_url: string, init: RequestInit) => Response.json({ status: "success", echo: JSON.parse(init.body as string) }));
|
||||
for (const [toolName, parameters] of [
|
||||
["unikraft_stop_instances", { body: [{ name: "web", drain_timeout_ms: -1 }] }],
|
||||
["unikraft_suspend_instance_by_uuid", { path: { uuid: randomUUID() }, body: { drain_timeout_ms: -1 } }],
|
||||
] as const) {
|
||||
await executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName, parameters });
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
await expect(executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName: "unikraft_stop_instances",
|
||||
parameters: { body: [{ name: "web", drain_timeout_ms: -2 }] } })).rejects.toThrow("Invalid Unikraft action arguments");
|
||||
});
|
||||
it("reports a timed-out sandbox wait as a still-running command, not a provider failure", async () => {
|
||||
const commandId = randomUUID();
|
||||
const request = vi.fn(async () => new Response(null, { status: 408 }));
|
||||
const call = (toolName: string) => executeUnikraftTool({ binding: { companyId: "company", agentId: "agent" },
|
||||
connection: { companyId: "company", config: {} }, headers: { Authorization: `Bearer ${credential}` }, request, toolName,
|
||||
parameters: { instance: "web", path: { uuid: commandId }, ...(toolName.endsWith("with_timeout") ? { body: { timeout_s: 0.01 } } : {}) } });
|
||||
await expect(call("unikraft_sandbox_wait_for_command_with_timeout")).resolves.toMatchObject({ status: "running", timedOut: true });
|
||||
expect(request.mock.calls[0]![0]).toBe(`https://api.fra.unikraft.cloud/v1/instances/web/plugins/sandbox/commands/${commandId}/wait_timeout`);
|
||||
// Other operations keep treating 408 as a failure.
|
||||
await expect(call("unikraft_sandbox_get_command_by_uuid")).rejects.toThrow("Unikraft could not complete this operation");
|
||||
});
|
||||
it("bounds response bytes", async () => {
|
||||
await expect(unikraftClient({ Authorization: `Bearer ${credential}` }, async () => new Response("x".repeat(8 * 1024 * 1024 + 1))).probe())
|
||||
.rejects.toThrow("exceeded the limit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { connect, type AddressInfo, type Socket } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { executeUnikraftTool } from "../services/connectors/unikraft/client.js";
|
||||
import { guardedRemoteHttpFetch } from "../services/remote-http-fetch.js";
|
||||
|
||||
const credential = "unikraft-http-fixture-token";
|
||||
const servers: Server[] = [], sockets: Socket[] = [];
|
||||
afterEach(async () => {
|
||||
sockets.splice(0).forEach(socket => socket.destroy());
|
||||
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
async function provider() {
|
||||
const received: { method: string; url: string; host: string; contentType?: string; body: string }[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", chunk => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({ method: req.method!, url: req.url!, host: req.headers.host!, contentType: req.headers["content-type"],
|
||||
body: Buffer.concat(chunks).toString("utf8") });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "success", data: { instances: [] }, op_time_us: 1 }));
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.on("connection", socket => sockets.push(socket));
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
// Preserve the real pinned node:http path, including its body serializer.
|
||||
// Only the network/TLS leg is a controlled local stand-in.
|
||||
const request = (url: string, init: RequestInit) => {
|
||||
const endpoint = new URL(url);
|
||||
endpoint.protocol = "http:"; endpoint.port = String(port);
|
||||
return guardedRemoteHttpFetch(endpoint, init, {
|
||||
allowPrivateNetwork: false, lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||||
socketFactory: target => {
|
||||
const socket = connect({ host: "127.0.0.1", port });
|
||||
sockets.push(socket);
|
||||
Object.defineProperty(socket, "remoteAddress", { get: () => target.address, configurable: true });
|
||||
return socket;
|
||||
},
|
||||
responseTimeoutMs: 5000, error: (message, code) => Object.assign(new Error(message), { code }),
|
||||
});
|
||||
};
|
||||
const call = (toolName: string, parameters: unknown) => executeUnikraftTool({
|
||||
binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName, parameters,
|
||||
signal: AbortSignal.timeout(3000), deadlineAt: Date.now() + 3000,
|
||||
});
|
||||
return { call, received };
|
||||
}
|
||||
|
||||
describe("Unikraft production HTTP boundary", () => {
|
||||
it.each([
|
||||
["unikraft_get_instance_logs", "GET", { metro: "dal", query: { name: ["web"] }, body: [{ name: "web" }] }],
|
||||
["unikraft_delete_instances", "DELETE", { body: [{ name: "web" }] }],
|
||||
] as const)("sends %s JSON selectors as a %s request body through the pinned transport", async (toolName, method, parameters) => {
|
||||
const p = await provider();
|
||||
await p.call(toolName, parameters);
|
||||
expect(p.received).toHaveLength(1);
|
||||
const [received] = p.received;
|
||||
expect(received.method).toBe(method);
|
||||
expect(received.host).toMatch(new RegExp(`^api\\.${parameters.metro ?? "fra"}\\.unikraft\\.cloud:`));
|
||||
expect(received.contentType).toBe("application/json");
|
||||
expect(JSON.parse(received.body)).toEqual([{ name: "web" }]);
|
||||
if (method === "GET") expect(received.url).toBe("/v1/instances/log?name=web");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { daytonaRisk, isDaytonaConnection } from "../daytona.js";
|
||||
import { NATIVE_PROVIDER_CATALOGS } from "../native-provider-catalogs.js";
|
||||
import { classifyRailwayToolRisk, isRailwayEndpoint } from "../railway.js";
|
||||
import { braintrustRisk } from "./braintrust/risk.js";
|
||||
import { enterpretRisk } from "./enterpret/risk.js";
|
||||
@@ -34,12 +34,13 @@ const reviewedPolicies: ReviewedToolPolicy[] = [
|
||||
// review and drift handling, without enabling the native API bridge.
|
||||
matchesEndpoint: connection => isRailwayEndpoint(connection.config.url),
|
||||
},
|
||||
{
|
||||
sourceTemplateKey: "daytona",
|
||||
// Native REST providers (Daytona, Unikraft) classify from their generated contract.
|
||||
...NATIVE_PROVIDER_CATALOGS.map((provider): ReviewedToolPolicy => ({
|
||||
sourceTemplateKey: provider.slug,
|
||||
quarantineNewEntries: true,
|
||||
classifyRisk: tool => daytonaRisk(tool.name),
|
||||
matchesConnection: isDaytonaConnection,
|
||||
},
|
||||
classifyRisk: tool => provider.risk(tool.name),
|
||||
matchesConnection: provider.isConnection,
|
||||
})),
|
||||
{ sourceTemplateKey: "enterpret", quarantineNewEntries: true, classifyRisk: enterpretRisk },
|
||||
{ sourceTemplateKey: "braintrust", quarantineNewEntries: true, classifyRisk: braintrustRisk },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NATIVE_REST_MAX_BYTES, nativeCatalog, nativeRestFailure as failure, nativeRestPathValue, nativeRestRecord as record,
|
||||
nativeRestSender, type NativeCatalogEntry, type NativeRestRequest } from "../../native-rest-client.js";
|
||||
import catalog from "./operations.generated.json" with { type: "json" };
|
||||
|
||||
export const UNIKRAFT_METHOD = "api-token";
|
||||
// Unikraft Cloud has no global platform endpoint; each metro serves its own API.
|
||||
// The global control-plane URL identifies the connection; actions use the
|
||||
// reviewed metro hosts below.
|
||||
export const UNIKRAFT_CONNECTION_URL = "https://controlplane.unikraft.cloud/v1";
|
||||
export const UNIKRAFT_METROS: readonly string[] = catalog.metros;
|
||||
export const UNIKRAFT_DEFAULT_METRO = "fra";
|
||||
type Operation = typeof catalog.tools[number];
|
||||
const contract = nativeCatalog(catalog.tools);
|
||||
|
||||
export const UNIKRAFT_TOOLS = contract.tools;
|
||||
export const isUnikraftToolName = contract.isToolName;
|
||||
export const unikraftRisk = contract.risk;
|
||||
export const isCurrentUnikraftCatalogEntry = (entry: NativeCatalogEntry) => contract.isCurrentEntry(entry);
|
||||
|
||||
export function isUnikraftConnection(connection: { transport: string; config?: Record<string, unknown> }) {
|
||||
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "unikraft"
|
||||
&& connection.config.connectionMethodKey === UNIKRAFT_METHOD && connection.config.url === UNIKRAFT_CONNECTION_URL;
|
||||
}
|
||||
|
||||
/** Only metros published in the reviewed specification receive the token. */
|
||||
export function unikraftApiUrl(metro: unknown) {
|
||||
if (typeof metro !== "string" || !UNIKRAFT_METROS.includes(metro)) {
|
||||
throw failure(422, "unikraft_metro_invalid", `Choose a Unikraft Cloud metro: ${UNIKRAFT_METROS.join(", ")}.`);
|
||||
}
|
||||
return `https://api.${metro}.unikraft.cloud`;
|
||||
}
|
||||
|
||||
function pathValue(value: unknown) {
|
||||
return nativeRestPathValue(value, () => failure(422, "unikraft_arguments_invalid", "Invalid Unikraft path argument."));
|
||||
}
|
||||
|
||||
/** A provider-focused REST bridge. It never reads host files, calls host shell
|
||||
* commands, borrows CLI login state, or looks up Paperclip tasks. */
|
||||
export function unikraftClient(headers: Record<string, string>, request: NativeRestRequest, defaultMetro: unknown = UNIKRAFT_DEFAULT_METRO,
|
||||
signal: AbortSignal = AbortSignal.timeout(30_000), deadlineAt: number = Date.now() + 30_000) {
|
||||
const authorization = headers.Authorization;
|
||||
if (!authorization || !/^Bearer [^\r\n]+$/.test(authorization)) {
|
||||
throw failure(422, "unikraft_credential_missing", "Reconnect Unikraft to restore its API token.");
|
||||
}
|
||||
const { send } = nativeRestSender({ provider: "Unikraft", code: "unikraft", authorization, request, signal, deadlineAt,
|
||||
deniedMessage: "Unikraft denied access. Check that this token belongs to the intended organization and has not been revoked." });
|
||||
|
||||
async function probe() {
|
||||
const value = record(await send(`${unikraftApiUrl(defaultMetro)}/v1/users/quotas`, { method: "GET" }));
|
||||
if (value.status !== "success") throw failure(502, "unikraft_invalid_response", "Unikraft did not confirm this API token.");
|
||||
return { metro: defaultMetro as string };
|
||||
}
|
||||
|
||||
async function execute(operation: Operation, parameters: Record<string, unknown>) {
|
||||
let base = unikraftApiUrl(parameters.metro ?? defaultMetro);
|
||||
if (operation.kind === "sandbox") base += `/v1/instances/${pathValue(parameters.instance)}/plugins/sandbox`;
|
||||
const route = operation.path.replace(/\{([^}]+)\}/g, (_match, key: string) => pathValue(record(parameters.path)[key]));
|
||||
const url = new URL(base + route);
|
||||
const projectedHeaders: Record<string, string> = {};
|
||||
for (const parameter of operation.parameters) {
|
||||
if (parameter.group === "path") continue;
|
||||
const value = record(parameters[parameter.group])[parameter.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (parameter.group === "headers") {
|
||||
const text = String(value);
|
||||
if (/[\r\n]/.test(text)) throw failure(422, "unikraft_arguments_invalid", "Invalid Unikraft header argument.");
|
||||
projectedHeaders[parameter.name] = text;
|
||||
} else if (Array.isArray(value)) {
|
||||
if (parameter.explode) value.forEach(item => url.searchParams.append(parameter.name, String(item)));
|
||||
else url.searchParams.append(parameter.name, value.join(","));
|
||||
} else url.searchParams.append(parameter.name, String(value));
|
||||
}
|
||||
let body: string | undefined;
|
||||
// Unikraft selects batch targets with JSON bodies, including on GET and
|
||||
// DELETE. The pinned transport sends them as ordinary request bodies.
|
||||
if (parameters.body !== undefined) {
|
||||
body = JSON.stringify(parameters.body);
|
||||
if (Buffer.byteLength(body) > NATIVE_REST_MAX_BYTES) throw failure(422, "unikraft_input_limit", "The request body exceeds the limit.");
|
||||
projectedHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
// The sandbox plugin documents 408 as "the command is still running when
|
||||
// the wait timed out", an ordinary outcome rather than a failure.
|
||||
const expected = operation.kind === "sandbox" && operation.path === "/commands/{uuid}/wait_timeout"
|
||||
? { 408: { status: "running", timedOut: true, message: "The command is still running. Wait again or check its logs." } } : {};
|
||||
// No automatic retry. A transport failure cannot prove a write did not run.
|
||||
return send(url.toString(), { method: operation.method, headers: projectedHeaders, body }, {}, expected);
|
||||
}
|
||||
return { probe, execute };
|
||||
}
|
||||
|
||||
type Binding = { companyId: string; agentId: string | null };
|
||||
type Connection = { companyId: string; config: Record<string, unknown> };
|
||||
export async function executeUnikraftTool(input: {
|
||||
binding: Binding; connection: Connection; headers: Record<string, string>;
|
||||
request: NativeRestRequest; toolName: string; parameters: unknown;
|
||||
signal?: AbortSignal;
|
||||
deadlineAt?: number;
|
||||
}) {
|
||||
if (!input.binding.agentId || input.connection.companyId !== input.binding.companyId) {
|
||||
throw failure(404, "unikraft_connection_not_found", "Connection not found.");
|
||||
}
|
||||
const operation = contract.operation(input.toolName);
|
||||
if (!operation) throw failure(404, "unikraft_tool_not_found", "Unikraft action not found.");
|
||||
if (!contract.validate(operation.name, input.parameters)) throw failure(422, "unikraft_arguments_invalid", "Invalid Unikraft action arguments.");
|
||||
const config = record(input.connection.config.methodConfig);
|
||||
return unikraftClient(input.headers, input.request, config.metro ?? UNIKRAFT_DEFAULT_METRO, input.signal, input.deadlineAt)
|
||||
.execute(operation, record(input.parameters));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+16
-120
@@ -1,62 +1,27 @@
|
||||
import Ajv, { type ValidateFunction } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import { z } from "zod";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import { descriptorHash, stableHash } from "./tool-catalog-hash.js";
|
||||
import { NATIVE_REST_MAX_BYTES as MAX_BYTES, nativeCatalog, nativeRestFailure as failure, nativeRestPathValue, nativeRestRecord as record,
|
||||
nativeRestSender, type NativeCatalogEntry, type NativeRestRequest } from "./native-rest-client.js";
|
||||
import catalog from "./daytona/operations.generated.json" with { type: "json" };
|
||||
|
||||
export const DAYTONA_API_URL = "https://app.daytona.io/api";
|
||||
// Preserve the saved method identity when upgrading the earlier small catalog.
|
||||
export const DAYTONA_METHOD = "workspace-diagnostics";
|
||||
const MAX_BYTES = 8 * 1024 * 1024;
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const failure = (status: number, code: string, message: string) => new HttpError(status, message, { code });
|
||||
export type DaytonaRequest = (url: string, init: RequestInit) => Promise<Response>;
|
||||
export type DaytonaRequest = NativeRestRequest;
|
||||
type Operation = typeof catalog.tools[number];
|
||||
const operations = new Map(catalog.tools.map(operation => [operation.name, operation]));
|
||||
const validators = new Map<string, ValidateFunction>();
|
||||
// Match the interop used by the existing plugin configuration validator.
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ strict: false, allErrors: true, validateFormats: true });
|
||||
((addFormats as any).default ?? addFormats)(ajv);
|
||||
const contract = nativeCatalog(catalog.tools);
|
||||
|
||||
export const DAYTONA_TOOLS = catalog.tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
||||
name, title, description, inputSchema: inputSchema as Record<string, unknown>, annotations,
|
||||
}));
|
||||
|
||||
export function isDaytonaToolName(name: string): boolean {
|
||||
return operations.has(name);
|
||||
}
|
||||
|
||||
const currentContracts = new Map(DAYTONA_TOOLS.map(tool => [tool.name, {
|
||||
versionHash: descriptorHash(tool, daytonaRisk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: daytonaRisk(tool.name),
|
||||
}]));
|
||||
|
||||
export function isCurrentDaytonaCatalogEntry(entry: { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string }): boolean {
|
||||
const current = currentContracts.get(entry.toolName);
|
||||
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
|
||||
}
|
||||
export const DAYTONA_TOOLS = contract.tools;
|
||||
export const isDaytonaToolName = contract.isToolName;
|
||||
export const daytonaRisk = contract.risk;
|
||||
export const isCurrentDaytonaCatalogEntry = (entry: NativeCatalogEntry) => contract.isCurrentEntry(entry);
|
||||
|
||||
export function isDaytonaConnection(connection: { transport: string; config?: Record<string, unknown> }) {
|
||||
return connection.transport === "rest_api" && connection.config?.sourceTemplateKey === "daytona"
|
||||
&& connection.config.connectionMethodKey === DAYTONA_METHOD && connection.config.url === DAYTONA_API_URL;
|
||||
}
|
||||
|
||||
export function daytonaRisk(name: string): "read" | "write" | "destructive" {
|
||||
const operation = operations.get(name);
|
||||
if (!operation || operation.annotations.destructiveHint) return "destructive";
|
||||
return operation.annotations.readOnlyHint ? "read" : "write";
|
||||
}
|
||||
|
||||
function pathValue(value: unknown) {
|
||||
const text = String(value);
|
||||
if (!text || text.length > 8192 || /[\u0000-\u001f\\]/.test(text)
|
||||
|| text.split("/").some(segment => segment === "." || segment === "..")) {
|
||||
throw failure(422, "daytona_arguments_invalid", "Invalid Daytona path argument.");
|
||||
}
|
||||
return encodeURIComponent(text);
|
||||
return nativeRestPathValue(value, () => failure(422, "daytona_arguments_invalid", "Invalid Daytona path argument."));
|
||||
}
|
||||
|
||||
function decodeFile(value: unknown) {
|
||||
@@ -77,78 +42,11 @@ export function daytonaClient(headers: Record<string, string>, request: DaytonaR
|
||||
if (!authorization || !/^Bearer [^\r\n]+$/.test(authorization)) {
|
||||
throw failure(422, "daytona_credential_missing", "Reconnect Daytona to restore its API key.");
|
||||
}
|
||||
const secret = authorization.slice(7);
|
||||
const expired = () => failure(504, "daytona_deadline_exceeded", "The Daytona action deadline expired. An operation already sent may still be running; inspect it before retrying.");
|
||||
// Bound the caller even if a transport or body reader is slow to acknowledge
|
||||
// cancellation. A late prerequisite result never advances to the next call.
|
||||
async function withinDeadline<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
let abort!: () => void;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(expired());
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally { signal.removeEventListener("abort", abort); }
|
||||
}
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactSensitiveText(value.split(secret).join("[REDACTED]"));
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
async function send(url: string, init: RequestInit, organizationId?: string): Promise<unknown> {
|
||||
const projected = new Headers(init.headers);
|
||||
projected.set("Authorization", authorization!);
|
||||
projected.set("X-Daytona-Source", "paperclip-connector");
|
||||
if (organizationId) projected.set("X-Daytona-Organization-ID", organizationId);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await withinDeadline(() => request(url, { ...init, redirect: "error", headers: Object.fromEntries(projected), signal }));
|
||||
} catch {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
throw failure(502, "daytona_outcome_unknown", init.method === "GET"
|
||||
? "Daytona could not complete the request."
|
||||
: "Daytona did not return a result. The operation may have started; inspect its state before retrying.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
const status = [400, 401, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
|
||||
throw failure(status, "daytona_request_failed", status === 401 || status === 403
|
||||
? "Daytona denied access. Check this key's organization and permissions. Some account operations require provider user authentication."
|
||||
: status === 404 ? "Daytona could not find this resource." : "Daytona could not complete this operation.");
|
||||
}
|
||||
if (response.status === 204 || !response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await withinDeadline(() => reader.read());
|
||||
if (part.done) break;
|
||||
length += part.value.byteLength;
|
||||
if (length > MAX_BYTES) throw failure(502, "daytona_output_limit", "Daytona's response exceeded the limit. Request a smaller page or file range.");
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw failure(502, "daytona_outcome_unknown", "Daytona's response was interrupted. Inspect the operation before retrying.");
|
||||
} finally { void reader.cancel().catch(() => {}); }
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!bytes.length) return null;
|
||||
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
if (contentType.includes("json")) {
|
||||
try { return sanitize(JSON.parse(bytes.toString("utf8"))); }
|
||||
catch { throw failure(502, "daytona_invalid_response", "Daytona returned an invalid response."); }
|
||||
}
|
||||
if (contentType.startsWith("text/")) return sanitize(bytes.toString("utf8"));
|
||||
if (bytes.includes(Buffer.from(secret))) throw failure(403, "daytona_credential_export_denied", "The response contains connection credential material.");
|
||||
return { contentType, contentBase64: bytes.toString("base64"), byteLength: bytes.length };
|
||||
}
|
||||
const { send: sendRequest, withinDeadline } = nativeRestSender({ provider: "Daytona", code: "daytona", authorization, request, signal, deadlineAt,
|
||||
deniedMessage: "Daytona denied access. Check this key's organization and permissions. Some account operations require provider user authentication.",
|
||||
fixedHeaders: { "X-Daytona-Source": "paperclip-connector" } });
|
||||
const send = (url: string, init: RequestInit, organizationId?: string) =>
|
||||
sendRequest(url, init, organizationId ? { "X-Daytona-Organization-ID": organizationId } : {});
|
||||
|
||||
async function identity() {
|
||||
const value = record(await send(`${DAYTONA_API_URL}/api-keys/current`, { method: "GET" }));
|
||||
@@ -248,11 +146,9 @@ export async function executeDaytonaTool(input: {
|
||||
if (!input.binding.agentId || input.connection.companyId !== input.binding.companyId) {
|
||||
throw failure(404, "daytona_connection_not_found", "Connection not found.");
|
||||
}
|
||||
const operation = operations.get(input.toolName);
|
||||
const operation = contract.operation(input.toolName);
|
||||
if (!operation) throw failure(404, "daytona_tool_not_found", "Daytona action not found.");
|
||||
let validate = validators.get(operation.name);
|
||||
if (!validate) { validate = ajv.compile(operation.inputSchema); validators.set(operation.name, validate!); }
|
||||
if (!validate!(input.parameters)) throw failure(422, "daytona_arguments_invalid", "Invalid Daytona action arguments.");
|
||||
if (!contract.validate(operation.name, input.parameters)) throw failure(422, "daytona_arguments_invalid", "Invalid Daytona action arguments.");
|
||||
const config = record(input.connection.config.methodConfig);
|
||||
return daytonaClient(input.headers, input.request, config.organizationId, input.signal, input.deadlineAt).execute(operation, record(input.parameters));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, executeDaytonaTool, isCurrentDaytonaCatalogEntry, isDaytonaConnection, isDaytonaToolName } from "./daytona.js";
|
||||
import type { NativeCatalogEntry, NativeRestRequest } from "./native-rest-client.js";
|
||||
import { UNIKRAFT_DEFAULT_METRO, UNIKRAFT_TOOLS, executeUnikraftTool, isCurrentUnikraftCatalogEntry, isUnikraftConnection,
|
||||
isUnikraftToolName, unikraftClient, unikraftRisk } from "./connectors/unikraft/client.js";
|
||||
|
||||
type ConnectionShape = { transport: string; config?: Record<string, unknown> };
|
||||
const methodConfig = (connection: ConnectionShape) => {
|
||||
const value = connection.config?.methodConfig;
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
};
|
||||
|
||||
/** A provider whose action catalog is generated from its published HTTP API
|
||||
* and executed by a Paperclip bridge rather than a remote MCP server. Shared
|
||||
* connection code consumes this contract instead of naming providers. */
|
||||
export type NativeProviderCatalog = {
|
||||
slug: string;
|
||||
name: string;
|
||||
tools: typeof DAYTONA_TOOLS;
|
||||
isConnection(connection: ConnectionShape): boolean;
|
||||
isToolName(name: string): boolean;
|
||||
risk(name: string): "read" | "write" | "destructive";
|
||||
isCurrentEntry(entry: NativeCatalogEntry): boolean;
|
||||
probe(connection: ConnectionShape, headers: Record<string, string>, request: NativeRestRequest): Promise<unknown>;
|
||||
execute(input: Parameters<typeof executeDaytonaTool>[0]): Promise<unknown>;
|
||||
};
|
||||
|
||||
export const NATIVE_PROVIDER_CATALOGS: readonly NativeProviderCatalog[] = [
|
||||
{
|
||||
slug: "daytona", name: "Daytona", tools: DAYTONA_TOOLS,
|
||||
isConnection: isDaytonaConnection, isToolName: isDaytonaToolName, risk: daytonaRisk, isCurrentEntry: isCurrentDaytonaCatalogEntry,
|
||||
probe: (connection, headers, request) => daytonaClient(headers, request, methodConfig(connection).organizationId).probe(),
|
||||
execute: executeDaytonaTool,
|
||||
},
|
||||
{
|
||||
slug: "unikraft", name: "Unikraft", tools: UNIKRAFT_TOOLS,
|
||||
isConnection: isUnikraftConnection, isToolName: isUnikraftToolName, risk: unikraftRisk, isCurrentEntry: isCurrentUnikraftCatalogEntry,
|
||||
probe: (connection, headers, request) => unikraftClient(headers, request, methodConfig(connection).metro ?? UNIKRAFT_DEFAULT_METRO).probe(),
|
||||
execute: executeUnikraftTool,
|
||||
},
|
||||
];
|
||||
|
||||
export const NATIVE_PROVIDER_SLUGS: readonly string[] = NATIVE_PROVIDER_CATALOGS.map(provider => provider.slug);
|
||||
|
||||
export function nativeProviderCatalogFor(connection: ConnectionShape): NativeProviderCatalog | null {
|
||||
return NATIVE_PROVIDER_CATALOGS.find(provider => provider.isConnection(connection)) ?? null;
|
||||
}
|
||||
|
||||
export function nativeProviderCatalogForSlug(slug: unknown): NativeProviderCatalog | null {
|
||||
return NATIVE_PROVIDER_CATALOGS.find(provider => provider.slug === slug) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Ajv, { type ValidateFunction } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import { descriptorHash, stableHash } from "./tool-catalog-hash.js";
|
||||
|
||||
/** Shared transport for native provider catalogs (Daytona, Unikraft). Each
|
||||
* provider owns its endpoints and target checks; this owns the bounded,
|
||||
* credential-redacting request/response handling they must all apply. */
|
||||
export const NATIVE_REST_MAX_BYTES = 8 * 1024 * 1024;
|
||||
export type NativeRestRequest = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export const nativeRestFailure = (status: number, code: string, message: string) => new HttpError(status, message, { code });
|
||||
|
||||
export function nativeRestRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function nativeRestPathValue(value: unknown, invalid: () => Error) {
|
||||
const text = String(value);
|
||||
if (!text || text.length > 8192 || /[\u0000-\u001f\\]/.test(text)
|
||||
|| text.split("/").some(segment => segment === "." || segment === "..")) {
|
||||
throw invalid();
|
||||
}
|
||||
return encodeURIComponent(text);
|
||||
}
|
||||
|
||||
export function nativeRestSender(options: {
|
||||
/** Provider display name used in caller-facing messages. */
|
||||
provider: string;
|
||||
/** Error code prefix, such as `daytona`. */
|
||||
code: string;
|
||||
authorization: string;
|
||||
request: NativeRestRequest;
|
||||
signal: AbortSignal;
|
||||
deadlineAt: number;
|
||||
deniedMessage: string;
|
||||
/** Provider-owned headers set after caller headers so they cannot be replaced. */
|
||||
fixedHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const { provider, code, authorization, request, signal, deadlineAt } = options;
|
||||
const secret = authorization.replace(/^Bearer /, "");
|
||||
const expired = () => nativeRestFailure(504, `${code}_deadline_exceeded`, `The ${provider} action deadline expired. An operation already sent may still be running; inspect it before retrying.`);
|
||||
// Bound the caller even if a transport or body reader is slow to acknowledge
|
||||
// cancellation. A late prerequisite result never advances to the next call.
|
||||
async function withinDeadline<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
let abort!: () => void;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => { if (signal.aborted || Date.now() >= deadlineAt) throw expired(); return work(); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(expired());
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally { signal.removeEventListener("abort", abort); }
|
||||
}
|
||||
function sanitize(value: unknown): unknown {
|
||||
if (typeof value === "string") return redactSensitiveText(value.split(secret).join("[REDACTED]"));
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `expectedStatuses` maps a provider-documented non-2xx outcome (for
|
||||
* example, a wait that timed out) to the result returned in its place. */
|
||||
async function send(url: string, init: RequestInit, extraHeaders: Record<string, string> = {},
|
||||
expectedStatuses: Record<number, unknown> = {}): Promise<unknown> {
|
||||
const projected = new Headers(init.headers);
|
||||
projected.set("Authorization", authorization);
|
||||
for (const [name, value] of Object.entries({ ...options.fixedHeaders, ...extraHeaders })) projected.set(name, value);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await withinDeadline(() => request(url, { ...init, redirect: "error", headers: Object.fromEntries(projected), signal }));
|
||||
} catch {
|
||||
if (signal.aborted || Date.now() >= deadlineAt) throw expired();
|
||||
throw nativeRestFailure(502, `${code}_outcome_unknown`, init.method === "GET"
|
||||
? `${provider} could not complete the request.`
|
||||
: `${provider} did not return a result. The operation may have started; inspect its state before retrying.`);
|
||||
}
|
||||
if (Object.hasOwn(expectedStatuses, response.status)) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
return expectedStatuses[response.status];
|
||||
}
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
const status = [400, 401, 403, 404, 409, 422, 429].includes(response.status) ? response.status : 502;
|
||||
throw nativeRestFailure(status, `${code}_request_failed`, status === 401 || status === 403
|
||||
? options.deniedMessage
|
||||
: status === 404 ? `${provider} could not find this resource.` : `${provider} could not complete this operation.`);
|
||||
}
|
||||
if (response.status === 204 || !response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const part = await withinDeadline(() => reader.read());
|
||||
if (part.done) break;
|
||||
length += part.value.byteLength;
|
||||
if (length > NATIVE_REST_MAX_BYTES) throw nativeRestFailure(502, `${code}_output_limit`, `${provider}'s response exceeded the limit. Request a smaller page or file range.`);
|
||||
chunks.push(part.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw nativeRestFailure(502, `${code}_outcome_unknown`, `${provider}'s response was interrupted. Inspect the operation before retrying.`);
|
||||
} finally { void reader.cancel().catch(() => {}); }
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!bytes.length) return null;
|
||||
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
if (contentType.includes("json")) {
|
||||
try { return sanitize(JSON.parse(bytes.toString("utf8"))); }
|
||||
catch { throw nativeRestFailure(502, `${code}_invalid_response`, `${provider} returned an invalid response.`); }
|
||||
}
|
||||
if (contentType.startsWith("text/")) return sanitize(bytes.toString("utf8"));
|
||||
if (bytes.includes(Buffer.from(secret))) throw nativeRestFailure(403, `${code}_credential_export_denied`, "The response contains connection credential material.");
|
||||
return { contentType, contentBase64: bytes.toString("base64"), byteLength: bytes.length };
|
||||
}
|
||||
|
||||
return { send, withinDeadline };
|
||||
}
|
||||
|
||||
type NativeOperation = {
|
||||
name: string; title: string; description: string; inputSchema: unknown;
|
||||
annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
||||
};
|
||||
export type NativeCatalogEntry = { toolName: string; versionHash: string; schemaHash: string | null; riskLevel: string };
|
||||
|
||||
// Match the interop used by the existing plugin configuration validator.
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ strict: false, allErrors: true, validateFormats: true });
|
||||
((addFormats as any).default ?? addFormats)(ajv);
|
||||
|
||||
/** The deployed contract of a generated provider catalog: agent-visible tools,
|
||||
* reviewed risk, saved-entry staleness and argument validation. */
|
||||
export function nativeCatalog<T extends NativeOperation>(operationsList: T[]) {
|
||||
const operations = new Map(operationsList.map(operation => [operation.name, operation]));
|
||||
const validators = new Map<string, ValidateFunction>();
|
||||
const risk = (name: string): "read" | "write" | "destructive" => {
|
||||
const operation = operations.get(name);
|
||||
if (!operation || operation.annotations.destructiveHint) return "destructive";
|
||||
return operation.annotations.readOnlyHint ? "read" : "write";
|
||||
};
|
||||
const tools = operationsList.map(({ name, title, description, inputSchema, annotations }) => ({
|
||||
name, title, description, inputSchema: inputSchema as Record<string, unknown>, annotations,
|
||||
}));
|
||||
const contracts = new Map(tools.map(tool => [tool.name, {
|
||||
versionHash: descriptorHash(tool, risk(tool.name)), schemaHash: stableHash(tool.inputSchema), riskLevel: risk(tool.name),
|
||||
}]));
|
||||
return {
|
||||
tools,
|
||||
operation: (name: string) => operations.get(name),
|
||||
isToolName: (name: string) => operations.has(name),
|
||||
risk,
|
||||
isCurrentEntry(entry: NativeCatalogEntry): boolean {
|
||||
const current = contracts.get(entry.toolName);
|
||||
return Boolean(current && current.versionHash === entry.versionHash && current.schemaHash === entry.schemaHash && current.riskLevel === entry.riskLevel);
|
||||
},
|
||||
validate(name: string, parameters: unknown): boolean {
|
||||
const operation = operations.get(name);
|
||||
if (!operation) return false;
|
||||
let validate = validators.get(name);
|
||||
if (!validate) { validate = ajv.compile(operation.inputSchema as object) as ValidateFunction; validators.set(name, validate); }
|
||||
return validate(parameters) as boolean;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-acc
|
||||
|
||||
import { stableHash } from "./tool-catalog-hash.js";
|
||||
|
||||
import { isDaytonaConnection } from "./daytona.js";
|
||||
import { NATIVE_PROVIDER_SLUGS, nativeProviderCatalogFor } from "./native-provider-catalogs.js";
|
||||
import { connectorToolPolicy } from "./connectors/tool-policy.js";
|
||||
|
||||
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
||||
@@ -2412,7 +2412,7 @@ export function toolAccessService(
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name: applicationName,
|
||||
description: safeApplicationDescription,
|
||||
type: transport === "rest_api" && ["browser-use-cloud", "daytona"].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: transport === "rest_api" && ["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry
|
||||
? {
|
||||
@@ -4488,7 +4488,7 @@ export function toolAccessService(
|
||||
companyId,
|
||||
applicationKey: normalizeKey(input.applicationName ?? input.name),
|
||||
name: input.applicationName ?? input.name,
|
||||
type: isBrowserUseConnection({ transport, config }) || isDaytonaConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "active",
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { googleWorkspaceToolTarget } from "@paperclipai/shared";
|
||||
import { BROWSER_USE_TOOLS } from "@paperclipai/shared";
|
||||
import { isMemoryConnectorId } from "@paperclipai/shared";
|
||||
@@ -291,5 +291,5 @@ export function isGoogleWorkspaceToolAllowed(
|
||||
export function shouldQuarantineNewEntries(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): boolean {
|
||||
return isDaytonaConnection(connection) || asRecord(connection.config).quarantineNewEntries === true;
|
||||
return Boolean(nativeProviderCatalogFor(connection)) || asRecord(connection.config).quarantineNewEntries === true;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { ActorInfo } from "../types.js";
|
||||
import { createOAuthDiscovery } from "../oauth/discovery.js";
|
||||
import { oauthConfig } from "../oauth/metadata.js";
|
||||
import { projectedConnectionHeaders } from "../connection-methods.js";
|
||||
import { DAYTONA_TOOLS, daytonaClient, isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, type GoogleWorkspaceService } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "../../connection-permission-errors.js";
|
||||
import { mcpDiscoveryHttpFailure, withMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
@@ -426,10 +426,11 @@ export function createToolCatalogDiscovery(db: Db, { now, audit, secrets, creden
|
||||
assertSupportedConnection(connection);
|
||||
await assertConnectorConnectionSetupEnabled(connection);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
||||
if (isDaytonaConnection(connection)) {
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) {
|
||||
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
|
||||
await daytonaClient(headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init), asRecord(connection.config.methodConfig).organizationId).probe();
|
||||
return DAYTONA_TOOLS;
|
||||
await nativeProvider.probe(connection, headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init));
|
||||
return nativeProvider.tools;
|
||||
}
|
||||
if (isBrowserUseConnection(connection)) {
|
||||
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connectionSetupUrl, connectionReconnectUrl } from "../identifiers.js";
|
||||
import { createGitHubGrantContinuity } from "../github-grant-continuity.js";
|
||||
import { createToolRuntimeSlots } from "../runtime/slots.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
import { isBrowserUseConnection } from "../../browser-use-client.js";
|
||||
import { and, asc, eq, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
@@ -121,7 +121,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
});
|
||||
for (const grant of grantsToCheck)
|
||||
await refreshManagedGitHubGrantAccess(connection, grant, actor);
|
||||
} else if (isBrowserUseConnection(connection) || isDaytonaConnection(connection)) {
|
||||
} else if (isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) {
|
||||
await discoverTools(connection, undefined, actor);
|
||||
} else if (isAgentMailConnection(connection)) {
|
||||
await validateAgentMailConnection(connection);
|
||||
@@ -157,8 +157,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
: config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isDaytonaConnection(connection)
|
||||
? "Daytona API access is connected."
|
||||
: nativeProviderCatalogFor(connection)
|
||||
? `${nativeProviderCatalogFor(connection)!.name} API access is connected.`
|
||||
: isBrowserUseConnection(connection)
|
||||
? "Browser Use API key is connected."
|
||||
: isAgentMailConnection(connection)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createToolRuntimeSlots } from "../runtime/slots.js";
|
||||
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
|
||||
import { connectionMethodForConnection } from "../connection-methods.js";
|
||||
import { stableHash, descriptorHash } from "../../tool-catalog-hash.js";
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
|
||||
import { retainMcpConnectionFailure } from "../../mcp-connection-failure.js";
|
||||
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
||||
@@ -115,9 +115,9 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
const existingByName = new Map(
|
||||
existingRows.map((entry) => [entry.toolName, entry]),
|
||||
);
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
const nativeProvider = Boolean(nativeProviderCatalogFor(connection));
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
|| nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
|| nativeProvider || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
||||
// The catalog is shared, but discovery uses only the caller's grant. A
|
||||
// narrower personal consent must not remove actions another active grant
|
||||
@@ -135,13 +135,13 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
}
|
||||
// Preserve only consent-filtered actions, not failed-service catalogs or
|
||||
// tools a successful, sufficiently authorized discovery actually removed.
|
||||
const removedStatus = nativeDaytona ? "removed" : "disabled";
|
||||
const removedStatus = nativeProvider ? "removed" : "disabled";
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus
|
||||
&& !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes))
|
||||
&& !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes)
|
||||
&& !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id);
|
||||
if (removedIds.length) await publish(tx => tx.update(toolCatalogEntries)
|
||||
.set({ status: removedStatus, quarantineReason: nativeDaytona ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.set({ status: removedStatus, quarantineReason: nativeProvider ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds))));
|
||||
}
|
||||
// Retired native actions are absent from discovery, but old catalog rows
|
||||
@@ -200,7 +200,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
|
||||
quarantineOnRefresh &&
|
||||
(!existing || changed) &&
|
||||
existing?.status !== "disabled" &&
|
||||
(!safeDefault || riskLevel !== "read" || nativeDaytona);
|
||||
(!safeDefault || riskLevel !== "read" || nativeProvider);
|
||||
const providerPermanentlyBlocked = Boolean(
|
||||
googleProfile &&
|
||||
!isGoogleWorkspaceToolAllowed(googleProfile, descriptor),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { singleFlight } from "../single-flight.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { descriptorHash } from "../../tool-catalog-hash.js";
|
||||
import { DAYTONA_TOOLS, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { toolCatalogEntries, toolConnections } from "@paperclipai/db";
|
||||
@@ -27,18 +27,18 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
.where(eq(toolCatalogEntries.connectionId, connection.id))
|
||||
.orderBy(desc(toolCatalogEntries.updatedAt));
|
||||
// Native provider catalogs change with the deployed code. Upgrade saved
|
||||
// Daytona connections too; their first small catalog must not survive
|
||||
// merely because REST discovery has no remote MCP cache timer.
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
// connections too; an earlier catalog must not survive merely because
|
||||
// REST discovery has no remote MCP cache timer.
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
const rowsByName = new Map(rows.map((row) => [row.toolName, row]));
|
||||
const daytonaCatalogChanged = nativeDaytona && connection.status === "active" && (
|
||||
rows.some((row) => row.status !== "removed" && !isDaytonaToolName(row.toolName))
|
||||
|| DAYTONA_TOOLS.some((tool) => {
|
||||
const nativeCatalogChanged = Boolean(nativeProvider) && connection.status === "active" && (
|
||||
rows.some((row) => row.status !== "removed" && !nativeProvider!.isToolName(row.toolName))
|
||||
|| nativeProvider!.tools.some((tool) => {
|
||||
const existing = rowsByName.get(tool.name);
|
||||
return !existing || existing.versionHash !== descriptorHash(tool, daytonaRisk(tool.name));
|
||||
return !existing || existing.versionHash !== descriptorHash(tool, nativeProvider!.risk(tool.name));
|
||||
})
|
||||
);
|
||||
const cacheExpired = daytonaCatalogChanged || (
|
||||
const cacheExpired = nativeCatalogChanged || (
|
||||
connection.transport === "mcp_remote" &&
|
||||
connection.status !== "archived" &&
|
||||
(rows.length === 0 ||
|
||||
@@ -48,7 +48,7 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
if (cacheExpired) {
|
||||
try {
|
||||
await singleFlight(catalogRefreshFlights, connection.id, () =>
|
||||
refreshCatalog(connection.id, nativeDaytona && actor ? actor : {
|
||||
refreshCatalog(connection.id, nativeProvider && actor ? actor : {
|
||||
actorType: "system",
|
||||
actorId: "tool_catalog_cache",
|
||||
}),
|
||||
@@ -68,7 +68,7 @@ export function createToolCatalogReader(db: Db, options: { catalogCacheTtlMs?: n
|
||||
// Even failed discovery must not keep retired native tools visible.
|
||||
// Their database rows remain available to historical invocation records.
|
||||
return rows
|
||||
.filter((row) => !nativeDaytona || (row.status !== "removed" && isDaytonaToolName(row.toolName)))
|
||||
.filter((row) => !nativeProvider || (row.status !== "removed" && nativeProvider.isToolName(row.toolName)))
|
||||
.map((row) => toCatalogEntryForConnection(row, connection));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDaytonaConnection } from "../../daytona.js";
|
||||
import { nativeProviderCatalogFor } from "../../native-provider-catalogs.js";
|
||||
import { isBrowserUseConnection } from "../../browser-use-client.js";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -58,7 +58,8 @@ export function createConnectionTokenBroker(db: Db, { now, secrets, policySvc, a
|
||||
input.connectionId,
|
||||
input.companyId,
|
||||
);
|
||||
if (isDaytonaConnection(connection)) throw forbidden("Daytona credentials stay in the governed tool gateway and cannot be exported.");
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) throw forbidden(`${nativeProvider.name} credentials stay in the governed tool gateway and cannot be exported.`);
|
||||
if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported.");
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
|
||||
const application = await getConnectionApplication(connection);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BudgetServiceHooks } from "./budgets.js";
|
||||
import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared";
|
||||
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { executeDaytonaTool, isDaytonaConnection, isCurrentDaytonaCatalogEntry } from "./daytona.js";
|
||||
import { nativeProviderCatalogFor } from "./native-provider-catalogs.js";
|
||||
import { composeConnectionInstructions } from "./connection-instructions.js";
|
||||
import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
@@ -1445,9 +1445,9 @@ export function createToolGatewayService(
|
||||
({ catalogEntry, connection, application }) =>
|
||||
(!featureSettings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(application.applicationKey), featureSettings)) &&
|
||||
!isRetiredComposioConnection(connection) &&
|
||||
!(isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(catalogEntry)) &&
|
||||
!(nativeProviderCatalogFor(connection) && !nativeProviderCatalogFor(connection)!.isCurrentEntry(catalogEntry)) &&
|
||||
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
|
||||
(((isBrowserUseConnection(connection) || isDaytonaConnection(connection)) && application.type === "rest_api") ||
|
||||
(((isBrowserUseConnection(connection) || nativeProviderCatalogFor(connection)) && application.type === "rest_api") ||
|
||||
(connection.transport === "mcp_remote" &&
|
||||
application.type === "mcp_http") ||
|
||||
(connection.transport === "local_stdio" &&
|
||||
@@ -1473,7 +1473,7 @@ export function createToolGatewayService(
|
||||
({ catalogEntry, connection, application }, index) => {
|
||||
if (
|
||||
connection.transport !== "mcp_remote" &&
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection)
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection)
|
||||
) {
|
||||
throw new Error(
|
||||
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
|
||||
@@ -4903,7 +4903,7 @@ export function createToolGatewayService(
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection))) {
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !nativeProviderCatalogFor(connection))) {
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
`Tool "${tool.name}" not found`,
|
||||
@@ -4926,8 +4926,9 @@ export function createToolGatewayService(
|
||||
if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) {
|
||||
throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked");
|
||||
}
|
||||
if (isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(entry)) {
|
||||
throw new ToolGatewayHttpError(409, "Daytona's action catalog changed. Review the updated actions in Permissions.", "daytona_catalog_changed");
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider && !nativeProvider.isCurrentEntry(entry)) {
|
||||
throw new ToolGatewayHttpError(409, `${nativeProvider.name}'s action catalog changed. Review the updated actions in Permissions.`, `${nativeProvider.slug}_catalog_changed`);
|
||||
}
|
||||
return { entry, connection };
|
||||
}
|
||||
@@ -6069,11 +6070,12 @@ export function createToolGatewayService(
|
||||
ms = railwayCommandBudgetMs(parameters);
|
||||
}
|
||||
const grant = await resolveConnectionGrant(session, connection);
|
||||
if (isDaytonaConnection(connection)) {
|
||||
const nativeProvider = nativeProviderCatalogFor(connection);
|
||||
if (nativeProvider) {
|
||||
const budgetMs = Math.min(ms, 30_000);
|
||||
const deadlineAt = Date.now() + budgetMs;
|
||||
const signal = AbortSignal.timeout(budgetMs);
|
||||
const data = await executeDaytonaTool({ binding: session, connection,
|
||||
const data = await nativeProvider.execute({ binding: session, connection,
|
||||
headers: await resolveCredentialHeaders(session, connection, grant),
|
||||
request: (url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
|
||||
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Unikraft uses the ordinary access and credential wizard", async ({ page, request }) => {
|
||||
const response = await request.post("/api/companies", { data: { name: `Unikraft setup ${Date.now()}` } });
|
||||
expect(response.ok()).toBe(true);
|
||||
const company = await response.json();
|
||||
await page.goto(`/${company.issuePrefix}/apps`);
|
||||
await expect(page.getByRole("heading", { name: "Connectors", exact: true })).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: "Search connectors" }).fill("Unikraft");
|
||||
const card = page.getByRole("list", { name: "Connector list" }).getByRole("listitem").filter({ hasText: "Unikraft" });
|
||||
await expect(card).toHaveCount(1);
|
||||
// App marks are decorative (empty alt), so they have no accessible img role.
|
||||
await expect(card.locator('img[src="/brands/apps/unikraft.svg"]')).toBeVisible();
|
||||
await card.getByRole("button", { name: "Add key Unikraft", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connect Unikraft", exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Unikraft key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Unikraft key", { exact: true })).toHaveAttribute("type", "password");
|
||||
await expect(page.getByText("Default metro", { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText("Connects for everyone in your organization, available to all agents.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||||
await expect(page.getByText("Which humans can use this credential?", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Default metro", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("select").filter({ has: page.locator('option[value="fra"]') })).toHaveValue("fra");
|
||||
// No token is submitted and no real provider connection is created. Gateway
|
||||
// lifecycle and provider reads are exercised in the database-backed suite.
|
||||
});
|
||||
@@ -702,6 +702,12 @@
|
||||
"catalogVisible": true,
|
||||
"localAsset": "/brands/apps/paperclip-notebook.svg",
|
||||
"darkAsset": "/brands/apps/paperclip-notebook-dark.svg"
|
||||
},
|
||||
{
|
||||
"slug": "unikraft",
|
||||
"provider": "Unikraft",
|
||||
"catalogVisible": true,
|
||||
"localAsset": "/brands/apps/unikraft.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 50 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M49.466,39.225l-13.671,0c-0.193,0 -0.338,0.097 -0.435,0.29c-0.579,1.111 -1.787,1.884 -3.139,1.884l-14.396,-0c-1.98,-0 -3.575,-1.594 -3.575,-3.575l0,-19.95c0,-1.981 -1.594,-3.575 -3.574,-3.575l-7.101,-0c-1.981,-0 -3.575,1.594 -3.575,3.575l-0,19.95c-0,9.855 7.971,17.825 17.825,17.825l14.396,0c9.178,0 16.714,-6.956 17.728,-15.844c0,-0.29 -0.193,-0.58 -0.483,-0.58Z" style="fill:url(#_Radial1);fill-rule:nonzero;"></path><path d="M48.79,2.125c0.435,0 0.628,-0.483 0.386,-0.772c-0.676,-0.822 -1.642,-1.353 -2.802,-1.353l-7.149,-0c-1.111,-0 -2.125,0.531 -2.802,1.353c-0.241,0.338 0,0.772 0.387,0.772l11.98,0Zm0.145,33.574l-12.27,-0c-0.58,-0 -1.015,0.434 -1.015,1.014l0,0.097c0,0.579 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.435 1.014,-1.014l0,-0.097c0,-0.58 -0.435,-1.014 -1.014,-1.014Zm-0.145,-24.975c0.435,0 0.628,0.483 0.386,0.773c-0.676,0.821 -1.642,1.353 -2.802,1.353l-11.641,-0c-0.29,-0 -0.484,-0.194 -0.484,-0.483c0,-0.145 0,-0.29 0.194,-0.339c0.338,-0.289 0.628,-0.628 0.821,-1.062c0.096,-0.145 0.241,-0.29 0.435,-0.29l13.091,-0l-0,0.048Zm-11.835,4.396c-0.338,0.29 -0.628,0.628 -0.822,1.063c-0.096,0.145 -0.241,0.29 -0.434,0.29l-13.14,-0c-0.435,-0 -0.628,-0.483 -0.386,-0.773c0.676,-0.822 1.642,-1.353 2.802,-1.353l11.642,0c0.289,0 0.483,0.193 0.483,0.483c-0,0.097 -0,0.242 -0.145,0.338l-0,-0.048Zm0.193,11.497c-0,0.29 -0.193,0.483 -0.483,0.483l-11.642,0c-1.111,0 -2.126,-0.531 -2.802,-1.352c-0.242,-0.339 -0,-0.773 0.387,-0.773l13.139,-0c0.193,-0 0.338,0.096 0.435,0.289c0.193,0.387 0.483,0.725 0.821,1.063c0.097,0 0.145,0.193 0.145,0.338l-0,-0.048Zm11.642,4.058l-13.14,-0c-0.193,-0 -0.338,-0.097 -0.434,-0.29c-0.194,-0.386 -0.483,-0.725 -0.822,-1.063c-0.096,-0.096 -0.193,-0.241 -0.193,-0.338c0,-0.29 0.193,-0.483 0.483,-0.483l11.642,-0c1.111,-0 2.126,0.531 2.802,1.353c0.29,0.338 -0,0.821 -0.338,0.821Zm0.145,1.449l-12.27,-0c-0.58,-0 -1.015,0.435 -1.015,1.014l0,0.097c0,0.58 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.434 1.014,-1.014l0,-0.097c0,-0.579 -0.435,-1.014 -1.014,-1.014Zm-14.251,-10.724l-12.27,-0c-0.579,-0 -1.014,0.435 -1.014,1.014l-0,0.097c-0,0.58 0.435,1.014 1.014,1.014l12.27,0c0.58,0 1.015,-0.434 1.015,-1.014l-0,-0.097c-0,-0.579 -0.483,-1.014 -1.015,-1.014Zm0,-3.575l-12.27,0c-0.579,0 -1.014,0.435 -1.014,1.015l-0,0.096c-0,0.58 0.435,1.015 1.014,1.015l12.27,-0c0.58,-0 1.015,-0.435 1.015,-1.015l-0,-0.096c-0,-0.532 -0.483,-1.015 -1.015,-1.015Zm14.251,-10.676l-12.27,0c-0.58,0 -1.015,0.435 -1.015,1.015l0,0.096c0,0.58 0.435,1.015 1.015,1.015l12.27,-0c0.579,-0 1.014,-0.435 1.014,-1.015l0,-0.096c0,-0.58 -0.435,-1.015 -1.014,-1.015Zm-0,-3.574l-12.27,-0c-0.58,-0 -1.015,0.434 -1.015,1.014l0,0.097c0,0.579 0.435,1.014 1.015,1.014l12.27,0c0.579,0 1.014,-0.435 1.014,-1.014l0,-0.097c0,-0.58 -0.435,-1.014 -1.014,-1.014Z" style="fill:url(#_Linear2);fill-rule:nonzero;"></path><defs><radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.0718,20.5709,-35.9993,28.1257,14.8241,23.4977)"><stop offset="0" style="stop-color:#0059e9;stop-opacity:1"></stop><stop offset="1" style="stop-color:#00a2f6;stop-opacity:1"></stop></radialGradient><linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-0.676277,-37.9208,37.9208,-0.676277,36.1334,38.0657)"><stop offset="0" style="stop-color:#0094f1;stop-opacity:1"></stop><stop offset="0.2" style="stop-color:#0094f1;stop-opacity:1"></stop><stop offset="0.5" style="stop-color:#0094ff;stop-opacity:1"></stop><stop offset="0.7" style="stop-color:#00d9fd;stop-opacity:1"></stop><stop offset="1" style="stop-color:#00e8ce;stop-opacity:1"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
Reference in new issue
Block a user