mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
feat(connections): add generic Daytona API and Toolbox access (#16483)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps connections give agents controlled access to external services. > - Daytona provides sandbox, file, command, Git, and account APIs. > - Operators need to assign this access without exposing a server credential. > - A small task-specific tool list cannot serve general Daytona work. > - This pull request adds the published HTTP actions through the existing connection gateway. > - Operators keep control of identity, agent access, and each action. ## Linked Issues or Issue Description **Problem or motivation** Paperclip has a Daytona sandbox provider for its own execution infrastructure. It lacks a normal Apps connection for agents to manage an operator's Daytona account. Reusing the server's provider credential would bypass normal connection ownership and action permissions. **Proposed solution** Add a Daytona connection with personal or shared vault credentials. Generate its action catalog from the official platform and Toolbox API specifications. Use the existing run-bound gateway, grants, action policies, approvals, revocation, and audit paths. Sandbox IDs come from Daytona; tools need no Paperclip task or lease ID. **Alternatives considered** A four-action diagnostic subset cannot support general provider work. Launching the provider CLI would expose host filesystem behavior. Native OAuth is not included because a supported third-party registration path was not verified. The native method uses an organization API key. Existing Composio setup is a separate possible OAuth route and is not qualified by this change. **Roadmap alignment** This extends the existing Apps and Connections provider coverage in `ROADMAP.md`. It uses that system's credential and permission model. GitHub searches found no existing Daytona Apps connector PR or issue. ## What Changed - Add the Daytona catalog entry, provider source override, official theme logos, permission review, and setup documentation. - Generate 274 HTTP actions: 169 platform and 105 Toolbox operations. Record two WebSocket upgrades separately; do not advertise them as HTTP tools. Record source URLs and hashes. - Add provider-focused execution for sandboxes, snapshots, volumes, files, Git, commands, computer use, and account resources. Provider authority still limits which endpoints accept an organization key. - Validate action arguments. Resolve Toolbox targets through the fixed platform API. Check sandbox identity and organization before using an approved Daytona proxy. Keep normal guarded network requests and vault resolution. - Transfer file bytes without reading or writing host files. Share one caller deadline across prerequisite reads, dispatch, and response reading. Bound requests and responses. Do not automatically retry writes with unknown outcomes. Redact the selected credential from results and errors. - Classify credential access, access changes, interactive input, command execution, file permissions, and deletion (including TTL and auto-delete) as destructive actions. Keep new and changed actions under catalog review. - Upgrade saved catalogs and retire the earlier diagnostic names. Hide retired actions from the UI and agent discovery even when provider refresh fails. Preserve existing action choices and historical records. Reject stale same-name contracts during agent discovery and dispatch before a board refresh. - Add database-backed gateway and catalog-upgrade regressions, schema generator tests, a browser setup check, and both capability map updates. ## Verification - Passed on the review-fix head: 57 focused Vitest cases (18 Daytona database/client cases, five HTTP boundary cases, and 34 shared catalog cases), four generator tests, `pnpm -r typecheck`, and `pnpm build`. The five HTTP cases use a real local server through the pinned production transport. The normal browser setup check and 17 generator/artwork cases passed on the initial PR head; those UI and artwork paths are unchanged. - Passed on this branch: brand asset validation, token gates, map structure checks, and `git diff --check`. All latest-head GitHub checks pass on `ea979ccb3a64dc9508807823540f935c4316ea35`. Apex is 5/5, with all five findings addressed and zero unresolved threads. The branch has no merge conflicts. - The initial catalog-generation command could not find the external research corpus. The supported `--definitions-only` mode regenerated the definitions successfully. Unrelated generated drift was excluded. - An earlier full local Vitest attempt on the preview branch failed when bundled PostgreSQL could not start on macOS. After replay onto current master, all three unchanged event-sequencing cases pass with bundled PostgreSQL; the earlier startup cause remains uncertain. - The first full-suite command on this branch used my external test database override. Chat fixtures require a current migrated schema and failed because `agents.lifecycle_state` was absent in that disposable database. A focused comparison reproduces failure with the override and passes with normal setup. That attempt was stopped, and the full suite restarted without the override. Focused Daytona cases use their own migrated PostgreSQL 16 databases. - The clean local full-suite attempt recorded embedded PostgreSQL startup failures in two unchanged native-runtime cases and two failures in unchanged Git fixtures. It was stopped after these failures were isolated; no full local suite pass is claimed. The 15 matching native-runtime cases pass separately. The Git cases fail with the local `init.defaultBranch=main` setting and pass with `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=init.defaultBranch GIT_CONFIG_VALUE_0=master` limited to the test command. No global Git setting was changed. Full Linux CI is green. - Fixtures use real database persistence and run-bound gateway sessions with provider responses replaced by fixtures. They cover personal/shared vault ownership, reads and writes, file bytes, Off policies, revocation, organization mismatch, untrusted proxy rejection, output bounds, saved catalog upgrades, failed refresh, and permission preservation. - The first Apex review found five valid issues: multipart transport compatibility, destructive classification, caller deadlines, audit filters, and stale saved contracts. All five were fixed with regressions before the second review. A typecheck then found the nullable saved schema hash; admission now accepts that stored type and rejects null as stale. - The browser check covers setup controls without submitting a provider key. No real Daytona account, provider write, native OAuth flow, or agent model run is qualified. Live provider read/write proof remains pending a test account. ## Risks - Daytona keys have broad Toolbox authority, including keys with no selected resource scopes. Only assign this connection to trusted agents and review its action permissions. - Remote writes or commands can continue after a timeout. Calls do not automatically retry an uncertain outcome. - The bridge targets Daytona Cloud. Proxy hosts are restricted; an unrecognized future regional host fails closed. - Files and responses have an 8 MiB bound. Each action has one caller deadline, capped at 30 seconds across all provider requests and response reading. Interactive WebSocket sessions are not exposed as HTTP actions. - The generated JSON snapshot exceeds 1,000 lines because it holds the provider input contracts. Generator logic and execution code remain in separate small modules. Updates require explicit regeneration and catalog review. - No database migration or lockfile change is required. ## Model Used OpenAI Codex, GPT-6. The runtime identifies the model family but does not expose the exact backend model ID or context window. Used reasoning, repository inspection, code editing, shell tools, database verification, and 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
1 parent
61831ba7b8
commit
0b62c736a0
25 files changed
+17140
-55
No files matched your search
@@ -0,0 +1,146 @@
|
||||
# Daytona connection
|
||||
|
||||
Daytona uses the normal Apps connection, selected personal/company credential,
|
||||
agent installation, action policy, gateway, revocation and audit paths. The API
|
||||
key stays in the instance vault. The connector does not borrow sandbox-provider
|
||||
environment credentials 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.
|
||||
|
||||
## Connect and test
|
||||
|
||||
Choose **Connectors → Daytona → Add key**, select access, and enter a Daytona
|
||||
organization API key. Give that key the read, write, and delete permissions for
|
||||
the resources you intend to manage. An optional organization ID under Advanced
|
||||
pins the credential to its organization. Setup verifies `GET /api-keys/current`.
|
||||
Use the saved connection's ordinary Permissions screen to choose agents and set
|
||||
actions to Allowed, Ask first, or Off.
|
||||
|
||||
This is a provider-focused connector. Sandbox IDs and names come from Daytona;
|
||||
no Paperclip task, run, or environment lease is required. Granting access to this
|
||||
connection grants access to the provider resources its credential can reach.
|
||||
Some user/account and administrator operations require extra provider authority
|
||||
or user authentication and will reject an API key. Paperclip does not fall back
|
||||
to another identity when Daytona denies access.
|
||||
|
||||
## Complete published HTTP action catalog
|
||||
|
||||
The catalog contains **274 actions** from Daytona's published specifications:
|
||||
169 platform API operations and 105 Toolbox operations. It includes sandbox
|
||||
creation, start/stop/recovery/deletion, snapshots, volumes, files and transfers,
|
||||
Git, commands and sessions, computer use, organization/account administration,
|
||||
and provider telemetry queries. These provider telemetry tools do not emit
|
||||
Paperclip first-party telemetry.
|
||||
|
||||
Actions have provider names such as `daytona_api_list_sandboxes`,
|
||||
`daytona_api_create_sandbox`, `daytona_toolbox_execute_command`, and
|
||||
`daytona_toolbox_download_file`. The generated schemas retain the provider's
|
||||
path/query/body arguments and required fields. Toolbox actions also take a
|
||||
`sandboxId`. Returned provider objects are not reduced to Paperclip task data.
|
||||
The selected connection credential is redacted from results and errors.
|
||||
|
||||
| Action group | Permission classification |
|
||||
| --- | --- |
|
||||
| Ordinary reads, file downloads, and read-only lookups | Read |
|
||||
| Resource creation, file/Git changes, lifecycle and configuration changes | Write |
|
||||
| Deletion (including TTL/auto-delete), command/code/interactive input, reset/restore, secret/credential access, signed access links and file/account access changes | Destructive |
|
||||
|
||||
The source inventory also records **two WebSocket upgrade endpoints**:
|
||||
interactive interpreter execution and PTY connection. These are streaming
|
||||
protocol connections, not ordinary HTTP/MCP request-response tools. They are
|
||||
not advertised as nonfunctional actions. The command/code and session tools
|
||||
provide the corresponding request-response execution paths.
|
||||
|
||||
The API and Toolbox specification sources and SHA-256 hashes are recorded in
|
||||
`server/src/services/daytona/operations.generated.json`. Regenerate with:
|
||||
|
||||
```sh
|
||||
node scripts/generate-daytona-tools.mjs
|
||||
node --test scripts/generate-daytona-tools.test.mjs
|
||||
```
|
||||
|
||||
The large generated JSON file is one provider contract snapshot. Its generator
|
||||
and invocation implementation stay separate from it; no runtime network discovery
|
||||
or unbounded schema copying occurs on the server's startup path.
|
||||
|
||||
## Credential and transport boundary
|
||||
|
||||
The platform API endpoint is fixed to `https://app.daytona.io/api`. Toolbox
|
||||
calls resolve the sandbox through that API, verify its exact ID/name and
|
||||
organization, and accept only reviewed Daytona cloud proxy hostnames and the
|
||||
`/toolbox` path. Every request still uses the normal DNS-pinned egress guard.
|
||||
Redirects are rejected. Action arguments cannot replace Authorization or the
|
||||
organization header. Generic credential export rejects Daytona.
|
||||
|
||||
File uploads take base64 bytes; downloads return bytes with content type and
|
||||
length. The connector never reads a local host file path, writes host files,
|
||||
launches the Daytona CLI, or executes commands on the Paperclip server. Provider
|
||||
commands execute inside the selected Daytona sandbox. Reads and transfers have
|
||||
an 8 MiB bound. An action shares one deadline across identity, target lookup,
|
||||
dispatch, and response reading: the caller's budget, capped at 30 seconds.
|
||||
Expiry blocks later dispatches and cancels active requests. Mutations are never
|
||||
retried automatically. A timed-out command or write can still be running at the
|
||||
provider; inspect it before retrying.
|
||||
|
||||
Daytona does not provide a diagnostic-only runtime key: every valid organization
|
||||
key can access running sandboxes through the Toolbox, even with no selected
|
||||
resource scopes. Treat this as broad provider access. Agent assignment and action
|
||||
permissions enforce the capability boundary; a read grant is not permission to
|
||||
run a command or delete a sandbox.
|
||||
|
||||
## OAuth research
|
||||
|
||||
The native connection currently uses an API key. Daytona's CLI supports browser
|
||||
sign-in, but its published configuration uses Daytona's own first-party AuthKit
|
||||
clients. The public issuer metadata does not advertise client registration, and
|
||||
a supported third-party OAuth app registration path was not verified. The
|
||||
connector does not borrow those client IDs, copy CLI sign-in tokens, or invent a
|
||||
native OAuth consent flow.
|
||||
|
||||
Composio advertises managed OAuth for its Daytona toolkit. That is a separate
|
||||
existing provider route: connect Composio through its ordinary connection flow,
|
||||
then configure its Daytona app. Its auth configuration and account entitlements
|
||||
must be verified through that provider's live setup. This native connector does
|
||||
not require Composio or forward its credential to Composio. A marketing listing
|
||||
is not proof that this instance completed Daytona OAuth.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
Database-backed tests exercise company and personal vault ownership, real
|
||||
run-scoped gateway reads and writes, file bytes, action denial, revocation,
|
||||
foreign-account rejection, untrusted proxy rejection, all generated argument
|
||||
schemas, and bounded/sanitized errors. Generator tests account for HTTP operations
|
||||
and protocol upgrades and preserve recursive input definitions.
|
||||
|
||||
These fixtures do not qualify a real Daytona account. Live provider proof is
|
||||
pending an operator connecting a key and trying an allowed read and write. No
|
||||
platform key or live provider credential is installed by this change. Existing
|
||||
saved connections must review the expanded catalog through normal Permissions;
|
||||
new actions do not inherit approval from the previous four diagnostic actions.
|
||||
Opening a saved Daytona connection upgrades an outdated catalog. The earlier
|
||||
Paperclip-specific actions are retired and hidden from both Permissions and
|
||||
agent discovery, even if a provider outage prevents refresh. Historical rows
|
||||
remain for audit, and newly discovered actions require review.
|
||||
Agent discovery and dispatch also check same-name saved contracts against the
|
||||
deployed schema and risk classification. An outdated action cannot execute
|
||||
while it waits for a human to open Permissions and review the change.
|
||||
|
||||
The HTTP boundary tests use a real local server and the pinned transport to
|
||||
check multipart uploads, audit filter encoding, response cancellation, and
|
||||
unrelated request responsiveness. The provider network/TLS leg is simulated;
|
||||
these checks do not replace live account proof.
|
||||
|
||||
Provider research verified 2026-10-15:
|
||||
|
||||
- https://www.daytona.io/docs/en/api-keys/
|
||||
- https://www.daytona.io/docs/en/tools/api/
|
||||
- https://www.daytona.io/docs/openapi.json
|
||||
- https://www.daytona.io/docs/toolbox-openapi.json
|
||||
- https://github.com/daytona/clients/tree/main/cli/mcp
|
||||
- https://composio.dev/toolkits/daytona
|
||||
- https://www.daytona.io/brand
|
||||
|
||||
Official square logo assets are copied unchanged from
|
||||
`https://framerusercontent.com/images/k2z1jCEWqymEd7reVoRseEVMFHU.svg` and
|
||||
`https://framerusercontent.com/images/JjrFl7J2k84ANwYg3pD9Hwilc.svg`.
|
||||
@@ -2668,6 +2668,24 @@
|
||||
"reviewedAt": "2026-10-09",
|
||||
"liveProof": "Paperclip Apps qualification passed: connected the public-mcp method from the natural Connectors catalog; initialization/discovery showed exactly 4 Read actions and 0 Write actions. Restricted agent access to Just agents I pick with 0 selected; the public method requires no identity. A bounded hf_fs search of hf://docs for MCP with limit 3 succeeded (3 public doc entries; UI preview reported one successful result row) in 0.7s. After reload, the page still showed Connected, 4 actions, Read 4, Write 0, and no agents can use this connection; the same query succeeded again in 0.8s. The Test dialog reported the direct call ran as Connector Tester; no heartbeat/agent execution or other provider tool call occurred.",
|
||||
"evidenceLimit": "Qualification used only public documentation and did not use a Hugging Face account, OAuth, API key, private repo, write, inference, Space execution, or job. Anonymous endpoint was directly probed separately and returned 4 read tools, matching Paperclip discovery. The setup flow says no identity required; a one-member human audience was saved before reload, but human identity controls were not shown after reload. A root browser inspection confirmed four read actions and zero agents selected; a privacy-safe permissions screenshot was saved. Rate limits and later provider catalog changes remain outside this proof."
|
||||
},
|
||||
{
|
||||
"app": "daytona",
|
||||
"method": "workspace-diagnostics",
|
||||
"auth": "api_key",
|
||||
"policy": "provider-key",
|
||||
"requestedScopes": [],
|
||||
"capability": "write",
|
||||
"supportedActions": "All published Daytona HTTP API and Toolbox operations: account and organization resources, sandbox lifecycle, files, Git, commands, sessions, snapshots, volumes, computer use, and telemetry reads. Provider permissions still apply.",
|
||||
"evidence": [
|
||||
"https://www.daytona.io/docs/en/api-keys/",
|
||||
"https://www.daytona.io/docs/openapi.json",
|
||||
"https://www.daytona.io/docs/toolbox-openapi.json",
|
||||
"https://github.com/daytona/clients/tree/main/cli/mcp"
|
||||
],
|
||||
"reviewedAt": "2026-10-15",
|
||||
"liveProof": "not-run",
|
||||
"keyPermissions": "Create an organization key with the read, write, and delete permissions for the resources you intend to manage. Daytona toolbox access is available to every valid organization key, even without selected scopes. Some user or administration endpoints require provider user authentication and will reject an API key."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,6 +82,15 @@ own policy and access review.
|
||||
Gaps: Catalog refresh/review and quarantined-action enablement are management
|
||||
operations.
|
||||
|
||||
Daytona also checks its deployed native contract on the board's
|
||||
`GET /api/tool-connections/{connectionId}/catalog`. A changed saved catalog
|
||||
refreshes under the viewing human's credential context. Retired tools are
|
||||
hidden even if refresh fails. Agent discovery and dispatch reject retired names
|
||||
and stale same-name contracts before refresh; new/changed tools stay quarantined until reviewed. The
|
||||
[database-backed Daytona fixtures](../../server/src/__tests__/daytona-connection.test.ts)
|
||||
exercise this boundary through real run-bound gateway sessions with fixture
|
||||
provider responses. They do not qualify a live Daytona account or agent model.
|
||||
|
||||
## `test-action`
|
||||
|
||||
Review: `baseline`.
|
||||
|
||||
@@ -83,6 +83,13 @@ remaining operations before qualifying the whole capability.
|
||||
Gaps: An initiation endpoint exists; it is not authorization to complete the person's
|
||||
credential flow.
|
||||
|
||||
Daytona native setup uses an organization API key in the selected personal or
|
||||
company vault grant. It does not borrow sandbox-provider credentials or CLI
|
||||
login tokens. Agents use assigned provider tools through the normal run-bound
|
||||
gateway; they cannot export the key. [Database-backed fixtures](../../server/src/__tests__/daytona-connection.test.ts)
|
||||
cover selected vault ownership and gateway reads/writes with provider responses
|
||||
replaced by fixtures. Real provider authentication remains unqualified.
|
||||
|
||||
## `handoff`
|
||||
|
||||
Review: `baseline`.
|
||||
|
||||
@@ -44,6 +44,13 @@ Automated: [app detail tests](../../ui/src/pages/apps/AppDetail.test.tsx) cover
|
||||
|
||||
Manual: Run a read-only action with explicit inputs and inspect returned data and activity. Refresh discovery and review new actions. Revoke or disconnect only the disposable credential, then verify subsequent use fails with an actionable reconnect state.
|
||||
|
||||
For Daytona, opening an older saved catalog discovers the current native
|
||||
contract. Removed diagnostic actions disappear from Permissions and agent
|
||||
discovery; new or changed actions require review. Existing Off/Allowed choices
|
||||
remain in place. [Daytona regressions](../../server/src/__tests__/daytona-connection.test.ts)
|
||||
cover shared/personal catalog upgrades, failed refresh, permission preservation,
|
||||
run-bound file transfers, and revocation with fixture provider responses.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Connected does not imply an agent has access or an action is allowed.
|
||||
|
||||
@@ -92,6 +92,13 @@ saved connection. Run a permitted read-only action with an eligible agent and
|
||||
verify its useful result. Try an ineligible agent and confirm denial. Repeat
|
||||
with a user who cannot install company-wide; the UI must not imply broad access.
|
||||
|
||||
Daytona follows this same key/access wizard. Its native method exposes the
|
||||
published platform and Toolbox HTTP actions using provider sandbox IDs, without
|
||||
a Paperclip task or lease requirement. [Daytona browser setup](../../tests/e2e/daytona-connector.spec.ts)
|
||||
checks the catalog, password field, access controls, and optional organization
|
||||
pin. It does not submit a key or qualify a real provider account. See
|
||||
[Daytona](../../doc/connections/DAYTONA.md) for API-key authority and OAuth limits.
|
||||
|
||||
### `mcp-link`
|
||||
|
||||
Automated: AppsConnect's generic MCP cases cover probe-driven auth, private or
|
||||
|
||||
@@ -99,7 +99,8 @@ import a97 from "./app-definitions/responses-api.json" with { type: "json" };
|
||||
import a98 from "./app-definitions/messages-api.json" with { type: "json" };
|
||||
import a99 from "./app-definitions/chat-completions-api.json" with { type: "json" };
|
||||
import a100 from "./app-definitions/local.json" with { type: "json" };
|
||||
import a101 from "./app-definitions/monday.json" with { type: "json" };
|
||||
import a102 from "./app-definitions/speko.json" with { type: "json" };
|
||||
import a101 from "./app-definitions/daytona.json" with { type: "json" };
|
||||
import a102 from "./app-definitions/monday.json" with { type: "json" };
|
||||
import a103 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] 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] as AppDefinition[];
|
||||
@@ -884,7 +884,7 @@ describe("AppDefinition catalog", () => {
|
||||
"ticktick",
|
||||
"xero",
|
||||
]);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(80);
|
||||
expect(APP_STORE_DEFINITIONS).toHaveLength(81);
|
||||
const connectableSlugs = new Set(
|
||||
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"responses-api", "messages-api", "chat-completions-api", "local",
|
||||
"agentmail",
|
||||
"browser-use-cloud",
|
||||
"daytona",
|
||||
"cognee",
|
||||
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
|
||||
"monday",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "daytona",
|
||||
"name": "Daytona",
|
||||
"description": "Manage Daytona sandboxes, files, commands, Git, snapshots, volumes, and account resources.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"branding": {
|
||||
"logoUrl": "/brands/apps/daytona.svg",
|
||||
"darkLogoUrl": "/brands/apps/daytona-dark.svg"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://app.daytona.io/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "workspace-diagnostics",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run tools in your Daytona account.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://app.daytona.io/api"
|
||||
},
|
||||
"guidanceMd": "Connect a Daytona organization API key. All published HTTP API and Toolbox actions are available; the key's provider permissions and your action policies control access. Sandbox IDs come directly from Daytona.",
|
||||
"riskTier": "S4",
|
||||
"label": "Connect Daytona",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Daytona API key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Daytona API key",
|
||||
"secret": true,
|
||||
"helperMd": "Create an organization key with the read, write, and delete permissions for the resources you intend to manage. Daytona toolbox access is available to every valid organization key, even without selected scopes. Some user or administration endpoints require provider user authentication and will reject an API key."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "organizationId",
|
||||
"label": "Pin to organization ID",
|
||||
"type": "text",
|
||||
"advanced": true,
|
||||
"helperMd": "Optional. Reject a key or sandbox from a different Daytona organization.",
|
||||
"validation": {
|
||||
"pattern": "^[a-fA-F0-9-]{36}$",
|
||||
"maxLength": 36
|
||||
}
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"sandbox"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://app.daytona.io/dashboard/keys",
|
||||
"docs": "https://www.daytona.io/docs/en/api-keys/"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands, access sandbox files and secrets, create resources, and delete data. Select trusted agents and set action permissions before use. Your Daytona API key stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://www.daytona.io/docs/en/sandboxes/"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "daytona",
|
||||
"name": "Daytona",
|
||||
"description": "Manage Daytona sandboxes, files, commands, Git, snapshots, volumes, and account resources.",
|
||||
"categories": [
|
||||
"developer"
|
||||
],
|
||||
"featured": false,
|
||||
"branding": {
|
||||
"logoUrl": "/brands/apps/daytona.svg",
|
||||
"darkLogoUrl": "/brands/apps/daytona-dark.svg"
|
||||
},
|
||||
"urlPatterns": [
|
||||
"https://app.daytona.io/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "workspace-diagnostics",
|
||||
"transport": "rest_api",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Manage resources and run tools in your Daytona account.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://app.daytona.io/api"
|
||||
},
|
||||
"guidanceMd": "Connect a Daytona organization API key. All published HTTP API and Toolbox actions are available; the key's provider permissions and your action policies control access. Sandbox IDs come directly from Daytona.",
|
||||
"riskTier": "S4",
|
||||
"label": "Connect Daytona",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Daytona API key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Enter Daytona API key",
|
||||
"secret": true,
|
||||
"helperMd": "Create an organization key with the read, write, and delete permissions for the resources you intend to manage. Daytona toolbox access is available to every valid organization key, even without selected scopes. Some user or administration endpoints require provider user authentication and will reject an API key."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"tenantFields": [
|
||||
{
|
||||
"key": "organizationId",
|
||||
"label": "Pin to organization ID",
|
||||
"type": "text",
|
||||
"advanced": true,
|
||||
"helperMd": "Optional. Reject a key or sandbox from a different Daytona organization.",
|
||||
"validation": {
|
||||
"pattern": "^[a-fA-F0-9-]{36}$",
|
||||
"maxLength": 36
|
||||
}
|
||||
}
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"sandbox"
|
||||
],
|
||||
"consoleLinks": {
|
||||
"keys": "https://app.daytona.io/dashboard/keys",
|
||||
"docs": "https://www.daytona.io/docs/en/api-keys/"
|
||||
},
|
||||
"warnings": [
|
||||
"This connection can run commands, access sandbox files and secrets, create resources, and delete data. Select trusted agents and set action permissions before use. Your Daytona API key stays in the vault."
|
||||
]
|
||||
}
|
||||
],
|
||||
"docsUrl": "https://www.daytona.io/docs/en/sandboxes/"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const sources = {
|
||||
api: "https://www.daytona.io/docs/openapi.json",
|
||||
toolbox: "https://www.daytona.io/docs/toolbox-openapi.json",
|
||||
};
|
||||
const verbs = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
||||
const snake = value => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase();
|
||||
|
||||
/** 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) {
|
||||
const definitions = {};
|
||||
function visit(node) {
|
||||
if (Array.isArray(node)) return node.map(visit);
|
||||
if (!node || typeof node !== "object") return node;
|
||||
if (node.$ref) {
|
||||
const prefix = "#/components/schemas/";
|
||||
if (!node.$ref.startsWith(prefix)) throw new Error(`Unsupported schema reference: ${node.$ref}`);
|
||||
const key = node.$ref.slice(prefix.length);
|
||||
if (!Object.hasOwn(schemas, key)) throw new Error(`Missing schema: ${key}`);
|
||||
if (!Object.hasOwn(definitions, key)) {
|
||||
definitions[key] = {};
|
||||
definitions[key] = visit(schemas[key]);
|
||||
}
|
||||
return { $ref: `#/definitions/${key}`, ...Object.fromEntries(Object.entries(node).filter(([k]) => k !== "$ref").map(([k,v]) => [k,visit(v)])) };
|
||||
}
|
||||
const result = Object.fromEntries(Object.entries(node).filter(([key]) => !["nullable", "example", "xml", "discriminator"].includes(key)).map(([key,child]) => [key,visit(child)]));
|
||||
if (result.format === "binary") {
|
||||
delete result.format;
|
||||
result.contentEncoding = "base64";
|
||||
result.description = `${result.description ?? "File bytes"} (base64; never a host file path)`;
|
||||
}
|
||||
// OpenAPI formats that Ajv does not define do not constrain JSON values.
|
||||
if (["int32", "int64", "float", "double", "byte"].includes(result.format)) delete result.format;
|
||||
return node.nullable ? { anyOf: [result, { type: "null" }] } : result;
|
||||
}
|
||||
const root = visit(value);
|
||||
if (Object.keys(definitions).length) root.definitions = definitions;
|
||||
return root;
|
||||
}
|
||||
|
||||
export function generateDaytonaCatalog(specs) {
|
||||
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 = `daytona_${kind}_${snake(operation.operationId)}`;
|
||||
// These are protocol upgrades, not request/response MCP actions. Keep
|
||||
// them in the inventory so an update cannot silently lose an operation.
|
||||
if (operation.responses?.["101"]) {
|
||||
streaming.push({ name, kind, path: route, method: method.toUpperCase(), transport: "websocket" });
|
||||
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 parameter of [...(item.parameters ?? []), ...(operation.parameters ?? [])]) {
|
||||
const group = parameter.in === "header" ? "headers" : parameter.in;
|
||||
if (!groups[group]) throw new Error(`Unsupported parameter: ${name} ${parameter.in}`);
|
||||
// Organization and invocation authorization come from the selected
|
||||
// connection, never an action argument.
|
||||
if (group === "headers" && /^(authorization|cookie|host|x-daytona-organization-id)$/i.test(parameter.name)) continue;
|
||||
groups[group].properties[parameter.name] = { ...(parameter.schema ?? parameter.content?.["application/json"]?.schema ?? {}),
|
||||
...(parameter.description ? { description: parameter.description } : {}) };
|
||||
if (parameter.required) groups[group].required.push(parameter.name);
|
||||
// The audit API documents bracket-key filters despite declaring form
|
||||
// style for its referenced filter objects in the OpenAPI document.
|
||||
const auditFilter = kind === "api" && operation.operationId === "getOrganizationAuditLogs"
|
||||
&& group === "query" && /Filter$/.test(parameter.schema?.$ref ?? "");
|
||||
parameters.push({ name: parameter.name, group, style: auditFilter ? "deepObject" : parameter.style ?? "form", explode: parameter.explode ?? true,
|
||||
json: Boolean(parameter.content?.["application/json"]) });
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (kind === "toolbox") { properties.sandboxId = { type: "string", minLength: 1, maxLength: 200 }; required.push("sandboxId"); }
|
||||
const content = operation.requestBody?.content ?? {};
|
||||
let bodyContentType = Object.hasOwn(content, "application/json") ? "application/json" : Object.keys(content)[0];
|
||||
if (bodyContentType) {
|
||||
properties.body = content[bodyContentType].schema ?? {};
|
||||
if (operation.requestBody.required) required.push("body");
|
||||
}
|
||||
// Daytona's bulk upload endpoint omits its multipart body from the
|
||||
// OpenAPI input. Its published SDK supplies repeated file parts whose
|
||||
// filename is the destination path. No host filesystem is exposed.
|
||||
if (kind === "toolbox" && operation.operationId === "UploadFiles") {
|
||||
bodyContentType = "multipart/form-data";
|
||||
properties.files = { type: "array", minItems: 1, maxItems: 100, items: { type: "object", additionalProperties: false,
|
||||
properties: { path: { type: "string", minLength: 1 }, contentBase64: { type: "string", contentEncoding: "base64" } },
|
||||
required: ["path", "contentBase64"] } };
|
||||
required.push("files");
|
||||
}
|
||||
const sensitive = /signing.?key|secrets?|push.?access|api.?key|credentials|ssh.?access|signed.?port.?preview|admin.?portal|app.?portal/i.test(operation.operationId)
|
||||
|| /\/(secret|docker-registry)(\/|$)/.test(route);
|
||||
const readOnly = (method === "get" || method === "head" || ["DownloadFiles", "FindAccessibilityNodes", "Completions"].includes(operation.operationId)) && !sensitive;
|
||||
const accessChange = !readOnly && /\/(roles|invitations|identity-providers|linked-accounts|pending-sso-links)(\/|$)|\/users\/[^/]+\/access$/.test(route);
|
||||
const interactiveInput = kind === "toolbox" && !readOnly
|
||||
&& (/^\/computeruse\//.test(route) || route === "/process/pty" || /\/command\/[^/]+\/input$/.test(route));
|
||||
const destructive = method === "delete" || sensitive || accessChange || interactiveInput
|
||||
|| /execute|code.?run|push.?changes|reset.?changes|restore.?files|initialize|update.?env|public.?status|network.?settings|authenticate|set.?ttl|auto.?delete|file.?permissions/i.test(operation.operationId);
|
||||
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: inputSchema({ type: "object", additionalProperties: false, properties, required }, spec.components?.schemas ?? {}),
|
||||
annotations: { readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: readOnly, openWorldHint: true } });
|
||||
}
|
||||
}
|
||||
}
|
||||
const names = tools.map(tool => tool.name);
|
||||
if (new Set(names).size !== names.length) throw new Error("Duplicate Daytona action name");
|
||||
return { 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, `daytona-${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, ...generateDaytonaCatalog(specs) };
|
||||
const target = fileURLToPath(new URL("../server/src/services/daytona/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} Daytona actions; recorded ${output.streaming.length} protocol upgrade endpoints.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateDaytonaCatalog } from "./generate-daytona-tools.mjs";
|
||||
|
||||
test("accounts for every HTTP operation and protocol upgrade without dropping methods", () => {
|
||||
const result = generateDaytonaCatalog({ api: { paths: {
|
||||
"/sandbox": { get: { operationId: "listSandboxes" }, post: { operationId: "createSandbox" } },
|
||||
"/stream": { get: { operationId: "stream", responses: { 101: {} } } },
|
||||
} } });
|
||||
assert.deepEqual(result.tools.map(t => t.name), ["daytona_api_list_sandboxes", "daytona_api_create_sandbox"]);
|
||||
assert.equal(result.tools[0].annotations.readOnlyHint, true);
|
||||
assert.equal(result.tools[1].annotations.readOnlyHint, false);
|
||||
assert.equal(result.streaming.length, 1);
|
||||
});
|
||||
|
||||
test("retains reachable recursive input definitions and replaces file paths with bytes", () => {
|
||||
const result = generateDaytonaCatalog({ toolbox: { paths: { "/files": { post: { operationId: "UploadFile",
|
||||
requestBody: { required: true, content: { "multipart/form-data": { schema: { $ref: "#/components/schemas/File" } } } } } } },
|
||||
components: { schemas: { File: { type: "object", properties: { file: { type: "string", format: "binary" },
|
||||
next: { $ref: "#/components/schemas/File" } } }, Unused: { type: "string" } } } } });
|
||||
const schema = result.tools[0].inputSchema;
|
||||
assert.equal(schema.definitions.File.properties.file.contentEncoding, "base64");
|
||||
assert.equal(schema.definitions.File.properties.next.$ref, "#/definitions/File");
|
||||
assert.equal(schema.definitions.Unused, undefined);
|
||||
assert.ok(schema.required.includes("sandboxId"));
|
||||
});
|
||||
|
||||
test("never lets action arguments override selected connection authorization", () => {
|
||||
const result = generateDaytonaCatalog({ api: { paths: { "/sandbox": { get: { operationId: "listSandboxes",
|
||||
parameters: [{ name: "Authorization", in: "header", schema: { type: "string" } },
|
||||
{ name: "X-Daytona-Organization-ID", in: "header", schema: { type: "string" } }] } } } } });
|
||||
assert.equal(result.tools[0].parameters.length, 0);
|
||||
assert.equal(result.tools[0].inputSchema.properties.headers, undefined);
|
||||
});
|
||||
|
||||
test("requires destructive permission for credentials, access changes, and interactive execution", () => {
|
||||
const result = generateDaytonaCatalog({ api: { paths: {
|
||||
"/secret/{id}": { get: { operationId: "getSecret" } },
|
||||
"/organizations/{id}/roles": { post: { operationId: "createRole" } },
|
||||
"/sandbox/{id}/ports/{port}/signed-preview-url": { get: { operationId: "getSignedPortPreviewUrl" } },
|
||||
"/sandbox/{id}/ttl/{minutes}": { post: { operationId: "setTtl" } },
|
||||
"/sandbox/{id}/autodelete/{interval}": { post: { operationId: "setAutoDeleteInterval" } },
|
||||
} }, toolbox: { paths: {
|
||||
"/computeruse/keyboard/type": { post: { operationId: "TypeText" } },
|
||||
"/process/session/{sessionId}/command/{commandId}/input": { post: { operationId: "SendInput" } },
|
||||
"/process/pty": { post: { operationId: "CreatePtySession" } },
|
||||
"/files/permissions": { post: { operationId: "SetFilePermissions" } },
|
||||
} } });
|
||||
for (const tool of result.tools) {
|
||||
assert.equal(tool.annotations.readOnlyHint, false, tool.name);
|
||||
assert.equal(tool.annotations.destructiveHint, true, tool.name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
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 { DAYTONA_API_URL, DAYTONA_TOOLS, daytonaClient, executeDaytonaTool } from "../services/daytona.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: "daytona-reviewer" };
|
||||
const credential = "daytona-fixture-secret";
|
||||
(support.supported ? describe : describe.skip)("Daytona 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 = "/daytona_" + randomUUID().replaceAll("-", "");
|
||||
await ensurePostgresDatabase(external, url.pathname.slice(1));
|
||||
await applyPendingMigrations(url.toString()); db = createDb(url.toString());
|
||||
} else { temp = await startEmbeddedPostgresTestDatabase("daytona-connector-"); db = createDb(temp.connectionString); }
|
||||
}, 30000);
|
||||
afterAll(async () => { await db?.$client.end(); await temp?.cleanup(); });
|
||||
|
||||
async function fixture(personal = false) {
|
||||
const [company] = await db.insert(companies).values({ name: "Daytona fixture", issuePrefix: `D${randomUUID().slice(0, 6)}` }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, membershipRole: "admin", status: "active" });
|
||||
const organizationId = randomUUID(), sandboxId = randomUUID();
|
||||
let sandboxOrganization = organizationId, proxy = "https://proxy.app.daytona.io/toolbox";
|
||||
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);
|
||||
if (url === `${DAYTONA_API_URL}/api-keys/current`) return Response.json({ organizationId, key: credential });
|
||||
if (parsed.pathname === "/api/sandbox" && init.method === "GET") return Response.json([{ id: sandboxId, name: "External sandbox", organizationId }]);
|
||||
if (parsed.pathname === "/api/sandbox" && init.method === "POST") return Response.json({ id: sandboxId, organizationId, ...JSON.parse(init.body as string) });
|
||||
if (url === `${DAYTONA_API_URL}/sandbox/${sandboxId}`) return init.method === "DELETE" ? new Response(null, { status: 204 })
|
||||
: Response.json({ id: sandboxId, name: "External sandbox", organizationId: sandboxOrganization, state: "started", toolboxProxyUrl: proxy });
|
||||
if (parsed.pathname === `/toolbox/${sandboxId}/process/execute`) return Response.json({ exitCode: 0, result: `done ${credential}` });
|
||||
if (parsed.pathname === `/toolbox/${sandboxId}/files/download`) return new Response("file bytes", { headers: { "Content-Type": "application/octet-stream" } });
|
||||
if (parsed.pathname === `/toolbox/${sandboxId}/files/upload-v2`) {
|
||||
expect(Buffer.isBuffer(init.body)).toBe(true);
|
||||
const form = await new Response(init.body, { headers: init.headers }).formData();
|
||||
expect(await (form.get("file") as Blob).text()).toBe("uploaded bytes");
|
||||
return Response.json({ path: parsed.searchParams.get("path") });
|
||||
}
|
||||
throw new Error("Unexpected provider request");
|
||||
});
|
||||
const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] });
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "daytona", methodKey: "workspace-diagnostics",
|
||||
...(personal ? { grantKind: "user" as const } : {}), credentialValues: { "credentials.authorization": credential } }, actor);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Maintainer", 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, timeoutMs?: number) => {
|
||||
const tool = tools.find(t => t.upstreamToolName === name);
|
||||
expect(tool, name).toBeTruthy();
|
||||
return gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters, timeoutMs, idempotencyKey: randomUUID() });
|
||||
};
|
||||
return { company, service, connected, agent, request, call, gateway, session, organizationId, sandboxId,
|
||||
foreignOrganization: () => { sandboxOrganization = randomUUID(); }, foreignProxy: () => { proxy = "https://attacker.example/toolbox"; } };
|
||||
}
|
||||
|
||||
it.each([false, true])("uses the selected vault identity for real gateway reads and writes (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(DAYTONA_TOOLS.length);
|
||||
expect(f.connected.catalog.find(c => c.toolName === "daytona_api_list_sandboxes")).toMatchObject({ isReadOnly: true });
|
||||
expect(f.connected.catalog.find(c => c.toolName === "daytona_toolbox_execute_command")).toMatchObject({ isDestructive: true, isReadOnly: false });
|
||||
for (const name of ["daytona_api_set_ttl", "daytona_api_set_auto_delete_interval", "daytona_toolbox_set_file_permissions"]) {
|
||||
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 sandbox has no Paperclip environment lease or task ownership record.
|
||||
expect(JSON.stringify(await f.call("daytona_api_list_sandboxes", {}))).toContain(f.sandboxId);
|
||||
expect(JSON.stringify(await f.call("daytona_api_create_sandbox", { body: { name: "Created through gateway" } }))).toContain("Created through gateway");
|
||||
const result = JSON.stringify(await f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "echo done" } }));
|
||||
expect(result).toContain("done"); expect(result).not.toContain(credential);
|
||||
expect(JSON.stringify(await f.call("daytona_api_get_current_api_key", {}))).not.toContain(credential);
|
||||
}, 30000);
|
||||
|
||||
it("transfers file bytes without reading or writing the server filesystem", async () => {
|
||||
const f = await fixture();
|
||||
const download = JSON.stringify(await f.call("daytona_toolbox_download_file", { sandboxId: f.sandboxId, query: { path: "/workspace/file.txt" } }));
|
||||
expect(download).toContain(Buffer.from("file bytes").toString("base64"));
|
||||
const upload = await f.call("daytona_toolbox_upload_file", { sandboxId: f.sandboxId, query: { path: "/workspace/file.txt" },
|
||||
body: { file: Buffer.from("uploaded bytes").toString("base64") } });
|
||||
expect(JSON.stringify(upload)).toContain("/workspace/file.txt");
|
||||
});
|
||||
|
||||
it("rejects a mismatched organization or untrusted toolbox URL before forwarding the key", async () => {
|
||||
const f = await fixture();
|
||||
f.foreignOrganization(); f.request.mockClear();
|
||||
await expect(f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "true" } })).rejects.toThrow("outside the selected account");
|
||||
expect(f.request).toHaveBeenCalledTimes(2);
|
||||
const g = await fixture(); g.foreignProxy(); g.request.mockClear();
|
||||
await expect(g.call("daytona_toolbox_execute_command", { sandboxId: g.sandboxId, body: { command: "true" } })).rejects.toThrow("unrecognized toolbox");
|
||||
expect(g.request.mock.calls.every(([url]) => url.startsWith(DAYTONA_API_URL))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects foreign connection bindings, unknown arguments and path traversal", async () => {
|
||||
const f = await fixture(); f.request.mockClear();
|
||||
await expect(executeDaytonaTool({ binding: { companyId: randomUUID(), agentId: f.agent.id },
|
||||
connection: { companyId: f.company.id, config: {} }, headers: { Authorization: `Bearer ${credential}` }, request: f.request,
|
||||
toolName: "daytona_api_list_sandboxes", parameters: {} })).rejects.toThrow("Connection not found");
|
||||
await expect(f.call("daytona_api_list_sandboxes", { command: "printenv" })).rejects.toThrow();
|
||||
await expect(f.call("daytona_api_get_sandbox", { path: { sandboxIdOrName: "../api-keys" } })).rejects.toThrow("Invalid Daytona path");
|
||||
});
|
||||
|
||||
it("rechecks revocation for a previously created gateway session", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolConnections).set({ enabled: false }).where(and(eq(toolConnections.id, f.connected.connectionId), eq(toolConnections.companyId, f.company.id)));
|
||||
f.request.mockClear();
|
||||
await expect(f.call("daytona_api_list_sandboxes", {})).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("applies an Off action policy to writes through an existing session", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
enabledCatalogEntryIds: f.connected.catalog.filter(c => c.toolName !== "daytona_toolbox_execute_command").map(c => c.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
||||
}, actor);
|
||||
f.request.mockClear();
|
||||
await expect(f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("quarantines added and changed actions even when a refresh requests all tools", async () => {
|
||||
const f = await fixture();
|
||||
const changed = f.connected.catalog.find(c => c.toolName === "daytona_toolbox_execute_command")!;
|
||||
const removed = f.connected.catalog.find(c => c.toolName === "daytona_api_create_sandbox")!;
|
||||
await db.update(toolCatalogEntries).set({ schemaHash: "earlier-schema", versionHash: "earlier-version" }).where(eq(toolCatalogEntries.id, changed.id));
|
||||
await db.delete(toolCatalogEntries).where(eq(toolCatalogEntries.id, removed.id));
|
||||
const refresh = await f.service.refreshCatalog(f.connected.connectionId, actor, { enableAllByDefault: true });
|
||||
expect(refresh.catalog.find(c => c.toolName === changed.toolName)?.status).toBe("quarantined");
|
||||
expect(refresh.catalog.find(c => c.toolName === removed.toolName)?.status).toBe("quarantined");
|
||||
f.request.mockClear();
|
||||
await expect(f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function installLegacyCatalog(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
await db.delete(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, f.connected.connectionId));
|
||||
const legacyNames = ["daytona_get_account", "daytona_list_workspaces", "daytona_get_workspace", "daytona_get_workspace_recovery"];
|
||||
const legacy = await db.insert(toolCatalogEntries).values(legacyNames.map(toolName => ({
|
||||
companyId: f.company.id, applicationId: f.connected.connection.applicationId, connectionId: f.connected.connectionId,
|
||||
name: toolName, toolName, title: toolName === "daytona_get_workspace_recovery" ? "Get workspace recovery" : toolName,
|
||||
entryKind: "tool", status: "active", riskLevel: "read", isReadOnly: true,
|
||||
inputSchema: { type: "object" }, versionHash: "legacy", schemaHash: "legacy",
|
||||
}))).returning();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
enabledCatalogEntryIds: legacy.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
||||
}, actor);
|
||||
// Earlier read-only defaults must not authorize the expanded native catalog.
|
||||
const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connected.connectionId));
|
||||
await db.update(toolConnections).set({ config: { ...row.config, safeDefault: true } }).where(eq(toolConnections.id, row.id));
|
||||
return legacyNames;
|
||||
}
|
||||
|
||||
it.each([false, true])("upgrades a saved four-action catalog without exposing or authorizing retired tools (personal=%s)", async personal => {
|
||||
const f = await fixture(personal);
|
||||
const legacyNames = await installLegacyCatalog(f);
|
||||
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => legacyNames.includes(tool.upstreamToolName ?? ""))).toBe(false);
|
||||
f.request.mockClear();
|
||||
const catalog = await f.service.listCatalog(f.connected.connectionId, f.company.id, actor);
|
||||
expect(catalog).toHaveLength(274);
|
||||
expect(catalog.every(entry => entry.status === "quarantined")).toBe(true);
|
||||
expect(catalog.some(entry => legacyNames.includes(entry.toolName))).toBe(false);
|
||||
const stored = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, f.connected.connectionId));
|
||||
expect(stored.filter(entry => legacyNames.includes(entry.toolName))).toHaveLength(4);
|
||||
expect(stored.filter(entry => legacyNames.includes(entry.toolName)).every(entry => entry.status === "removed")).toBe(true);
|
||||
expect(f.request).toHaveBeenCalledTimes(1);
|
||||
f.request.mockClear();
|
||||
expect(await f.service.listCatalog(f.connected.connectionId, f.company.id, actor)).toHaveLength(274);
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName?.startsWith("daytona_"))).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("hides retired native actions even when the saved key cannot refresh its catalog", async () => {
|
||||
const f = await fixture();
|
||||
await installLegacyCatalog(f);
|
||||
f.request.mockRejectedValue(new Error("Provider unavailable"));
|
||||
expect(await f.service.listCatalog(f.connected.connectionId, f.company.id, actor)).toEqual([]);
|
||||
expect((await f.gateway.listToolsForSession(f.session.token)).some(tool => tool.upstreamToolName?.startsWith("daytona_get_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves existing Off and Allowed choices while retiring leftover actions", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.finishGalleryAppConnection(f.company.id, f.connected.connectionId, {
|
||||
enabledCatalogEntryIds: f.connected.catalog.filter(entry => entry.toolName !== "daytona_toolbox_execute_command").map(entry => entry.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [f.agent.id] },
|
||||
}, actor);
|
||||
await db.insert(toolCatalogEntries).values({
|
||||
companyId: f.company.id, applicationId: f.connected.connection.applicationId, connectionId: f.connected.connectionId,
|
||||
name: "daytona_get_workspace_recovery", toolName: "daytona_get_workspace_recovery", title: "Get workspace recovery",
|
||||
entryKind: "tool", status: "active", riskLevel: "read", isReadOnly: true,
|
||||
inputSchema: { type: "object" }, versionHash: "legacy", schemaHash: "legacy",
|
||||
});
|
||||
expect(await f.service.listCatalog(f.connected.connectionId, f.company.id, actor)).toHaveLength(274);
|
||||
f.request.mockClear();
|
||||
await expect(f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(await f.call("daytona_api_list_sandboxes", {}))).toContain(f.sandboxId);
|
||||
});
|
||||
|
||||
it("blocks a stale same-name contract before board refresh, including an existing session", async () => {
|
||||
const f = await fixture();
|
||||
const changed = f.connected.catalog.find(entry => entry.toolName === "daytona_toolbox_execute_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, { sandboxId: f.sandboxId, body: { command: "true" } })).rejects.toThrow();
|
||||
expect(f.request).not.toHaveBeenCalled();
|
||||
const refreshed = await f.service.refreshCatalog(f.connected.connectionId, actor);
|
||||
expect(refreshed.catalog.find(entry => entry.toolName === changed.toolName)?.status).toBe("quarantined");
|
||||
});
|
||||
|
||||
it("carries a caller deadline across prerequisite reads and never starts a late write", async () => {
|
||||
const f = await fixture();
|
||||
const original = f.request.getMockImplementation()!;
|
||||
f.request.mockImplementation(async (url, init) => {
|
||||
if (url.endsWith("/api-keys/current")) await new Promise(resolve => setTimeout(resolve, 80));
|
||||
return original(url, init);
|
||||
});
|
||||
f.request.mockClear();
|
||||
await expect(f.call("daytona_toolbox_execute_command", { sandboxId: f.sandboxId, body: { command: "true" } }, 20)).rejects.toThrow("deadline expired");
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(f.request.mock.calls.every(([url]) => url.endsWith("/api-keys/current"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Daytona provider client", () => {
|
||||
it("validates every published action schema before provider dispatch", async () => {
|
||||
const request = vi.fn(async () => { throw new Error("Unexpected provider request"); });
|
||||
expect(DAYTONA_TOOLS).toHaveLength(274);
|
||||
for (const tool of DAYTONA_TOOLS) {
|
||||
await expect(executeDaytonaTool({ binding: { companyId: "company", agentId: "agent" },
|
||||
connection: { companyId: "company", config: {} }, headers: { Authorization: `Bearer ${credential}` },
|
||||
request, toolName: tool.name, parameters: null }), tool.name).rejects.toThrow("Invalid Daytona action arguments");
|
||||
}
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed on organization mismatch and sanitizes provider errors", async () => {
|
||||
await expect(daytonaClient({ Authorization: `Bearer ${credential}` }, async () => Response.json({ organizationId: randomUUID() }), randomUUID()).probe())
|
||||
.rejects.toThrow("configured organization");
|
||||
await expect(daytonaClient({ Authorization: `Bearer ${credential}` }, async () => new Response(credential, { status: 403 })).probe())
|
||||
.rejects.toThrow("Daytona denied access");
|
||||
});
|
||||
it("bounds response bytes", async () => {
|
||||
await expect(daytonaClient({ Authorization: `Bearer ${credential}` }, async () => new Response("x".repeat(8 * 1024 * 1024 + 1))).probe())
|
||||
.rejects.toThrow("exceeded the limit");
|
||||
});
|
||||
it("does not expose transport stream errors", async () => {
|
||||
const request = async () => new Response(new ReadableStream({ start(controller) { controller.error(new Error(credential)); } }));
|
||||
await expect(daytonaClient({ Authorization: `Bearer ${credential}` }, request).probe()).rejects.toThrow("response was interrupted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { connect, type AddressInfo, type Socket } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { executeDaytonaTool } from "../services/daytona.js";
|
||||
import { guardedRemoteHttpFetch } from "../services/remote-http-fetch.js";
|
||||
|
||||
const organizationId = randomUUID(), sandboxId = randomUUID();
|
||||
const credential = "daytona-http-fixture-key";
|
||||
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(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> | void,
|
||||
stallIdentity = false) {
|
||||
const calls: string[] = [], errors: unknown[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
calls.push(req.url!);
|
||||
if (req.url === "/health") { res.end("ok"); return; }
|
||||
const json = (value: unknown) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(value)); };
|
||||
if (req.url === "/api/api-keys/current" && !stallIdentity) { json({ organizationId }); return; }
|
||||
if (req.url === `/api/sandbox/${sandboxId}`) {
|
||||
json({ id: sandboxId, organizationId, toolboxProxyUrl: "https://proxy.app.daytona.io/toolbox" }); return;
|
||||
}
|
||||
Promise.resolve(handler(req, res)).catch(error => { errors.push(error); res.statusCode = 500; res.end(); });
|
||||
});
|
||||
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 and
|
||||
// abort handling. 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.hostname = "daytona-boundary.test"; 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, budgetMs = 3000) => executeDaytonaTool({
|
||||
binding: { companyId: "company", agentId: "agent" }, connection: { companyId: "company", config: {} },
|
||||
headers: { Authorization: `Bearer ${credential}` }, request, toolName, parameters,
|
||||
signal: AbortSignal.timeout(budgetMs), deadlineAt: Date.now() + budgetMs,
|
||||
});
|
||||
return { call, calls, errors, health: () => fetch(`http://127.0.0.1:${port}/health`) };
|
||||
}
|
||||
|
||||
describe("Daytona production HTTP boundary", () => {
|
||||
it.each([false, true])("sends valid multipart bytes through the pinned transport (bulk=%s)", async bulk => {
|
||||
const bytes = Buffer.from([0, 255, 1, 13, 10]);
|
||||
const p = await provider(async (req, res) => {
|
||||
expect(req.headers.authorization).toBe(`Bearer ${credential}`);
|
||||
expect(req.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = Buffer.concat(chunks);
|
||||
const form = await new Response(body, { headers: { "Content-Type": req.headers["content-type"]! } }).formData();
|
||||
expect(Buffer.from(await (form.get(bulk ? "files" : "file") as Blob).arrayBuffer())).toEqual(bytes);
|
||||
if (bulk) expect(body.toString("latin1")).toContain('filename="/workspace/binary.bin"');
|
||||
res.setHeader("Content-Type", "application/json"); res.end('{"uploaded":true}');
|
||||
});
|
||||
await expect(p.call(bulk ? "daytona_toolbox_upload_files" : "daytona_toolbox_upload_file", {
|
||||
sandboxId,
|
||||
...(bulk ? { files: [{ path: "/workspace/binary.bin", contentBase64: bytes.toString("base64") }] }
|
||||
: { query: { path: "/workspace/binary.bin" }, body: { file: bytes.toString("base64") } }),
|
||||
})).resolves.toEqual({ uploaded: true });
|
||||
expect(p.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("encodes audit filter objects using the documented bracket keys", async () => {
|
||||
const p = await provider((req, res) => {
|
||||
const url = new URL(req.url!, "http://provider.test");
|
||||
expect(url.searchParams.get("createdAt[gte]")).toBe("2026-10-01T00:00:00Z");
|
||||
expect(url.searchParams.get("actorId[eq]")).toBe("actor");
|
||||
expect(url.searchParams.get("statusCode[gte]")).toBe("400");
|
||||
expect(url.searchParams.has("createdAt")).toBe(false);
|
||||
res.setHeader("Content-Type", "application/json"); res.end("[]");
|
||||
});
|
||||
await expect(p.call("daytona_api_get_organization_audit_logs", { path: { organizationId },
|
||||
query: { createdAt: { gte: "2026-10-01T00:00:00Z" }, actorId: { eq: "actor" }, statusCode: { gte: 400 } },
|
||||
})).resolves.toEqual([]);
|
||||
expect(p.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["identity", "body"])("honors a total deadline during %s without blocking unrelated requests", async phase => {
|
||||
const p = await provider((_req, res) => {
|
||||
if (phase === "body") {
|
||||
res.setHeader("Content-Type", "application/json"); res.write('{"pending":');
|
||||
// A dribbling body defeats idle timeouts but must not renew the action budget.
|
||||
const timer = setInterval(() => res.write(" "), 10);
|
||||
res.on("close", () => clearInterval(timer));
|
||||
}
|
||||
}, phase === "identity");
|
||||
const pending = expect(p.call("daytona_api_create_sandbox", { body: { name: "deadline" } }, 300))
|
||||
.rejects.toMatchObject({ status: 504, details: { code: "daytona_deadline_exceeded" } });
|
||||
expect(await (await p.health()).text()).toBe("ok");
|
||||
await pending;
|
||||
expect(p.calls.filter(url => url === "/api/sandbox")).toHaveLength(phase === "identity" ? 0 : 1);
|
||||
expect(p.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2281,7 +2281,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
assertBoard(req);
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId) });
|
||||
res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId, getActorInfo(req)) });
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/activity", async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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 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>;
|
||||
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);
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
function decodeFile(value: unknown) {
|
||||
if (typeof value !== "string" || value.length > Math.ceil(MAX_BYTES / 3) * 4
|
||||
|| value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) {
|
||||
throw failure(422, "daytona_file_invalid", "File contents must be bounded base64 bytes.");
|
||||
}
|
||||
const bytes = Buffer.from(value, "base64");
|
||||
if (bytes.toString("base64") !== value) throw failure(422, "daytona_file_invalid", "File contents must be canonical base64 bytes.");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** A provider-focused REST bridge. It never reads host files, calls host shell
|
||||
* commands, borrows provider-plugin credentials, or looks up Paperclip tasks. */
|
||||
export function daytonaClient(headers: Record<string, string>, request: DaytonaRequest, pinnedOrganizationId?: unknown,
|
||||
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, "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 };
|
||||
}
|
||||
|
||||
async function identity() {
|
||||
const value = record(await send(`${DAYTONA_API_URL}/api-keys/current`, { method: "GET" }));
|
||||
const organizationId = value.organizationId;
|
||||
if (!z.string().uuid().safeParse(organizationId).success || (pinnedOrganizationId && organizationId !== pinnedOrganizationId)) {
|
||||
throw failure(403, "daytona_organization_mismatch", "The Daytona key does not identify the configured organization.");
|
||||
}
|
||||
return { organizationId: organizationId as string };
|
||||
}
|
||||
|
||||
async function execute(operation: Operation, parameters: Record<string, unknown>) {
|
||||
const account = await identity();
|
||||
let base = DAYTONA_API_URL;
|
||||
if (operation.kind === "toolbox") {
|
||||
const sandbox = record(await send(`${DAYTONA_API_URL}/sandbox/${pathValue(parameters.sandboxId)}`, { method: "GET" }, account.organizationId));
|
||||
if (!z.string().uuid().safeParse(sandbox.id).success || sandbox.organizationId !== account.organizationId
|
||||
|| (sandbox.id !== parameters.sandboxId && sandbox.name !== parameters.sandboxId)) {
|
||||
throw failure(403, "daytona_workspace_mismatch", "Daytona returned a sandbox outside the selected account or target.");
|
||||
}
|
||||
let proxyValue = sandbox.toolboxProxyUrl;
|
||||
if (!proxyValue) proxyValue = record(await send(`${DAYTONA_API_URL}/sandbox/${pathValue(sandbox.id)}/toolbox-proxy-url`, { method: "GET" }, account.organizationId)).url;
|
||||
let proxy: URL;
|
||||
try { proxy = new URL(String(proxyValue)); }
|
||||
catch { throw failure(502, "daytona_proxy_invalid", "Daytona returned an invalid toolbox address."); }
|
||||
if (proxy.protocol !== "https:" || !/^proxy(?:-[a-z0-9-]+)?\.app\.daytona\.io$/.test(proxy.hostname)
|
||||
|| proxy.username || proxy.password || (proxy.port && proxy.port !== "443")
|
||||
|| !/^\/toolbox\/?$/.test(proxy.pathname) || proxy.search || proxy.hash) {
|
||||
throw failure(502, "daytona_proxy_invalid", "Daytona returned an unrecognized toolbox address. No credentials were forwarded.");
|
||||
}
|
||||
base = `${proxy.origin}/toolbox/${pathValue(sandbox.id)}`;
|
||||
}
|
||||
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, "daytona_arguments_invalid", "Invalid Daytona header argument.");
|
||||
projectedHeaders[parameter.name] = text;
|
||||
} else if (parameter.json || (value && typeof value === "object" && !Array.isArray(value))) {
|
||||
if (parameter.style === "deepObject") {
|
||||
for (const [key, child] of Object.entries(record(value))) {
|
||||
if (Array.isArray(child)) child.forEach(item => url.searchParams.append(`${parameter.name}[${key}]`, String(item)));
|
||||
else url.searchParams.append(`${parameter.name}[${key}]`, String(child));
|
||||
}
|
||||
} else url.searchParams.append(parameter.name, JSON.stringify(value));
|
||||
} 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(parameter.style === "spaceDelimited" ? " " : parameter.style === "pipeDelimited" ? "|" : ","));
|
||||
} else url.searchParams.append(parameter.name, String(value));
|
||||
}
|
||||
let body: BodyInit | undefined;
|
||||
if (operation.bodyContentType === "multipart/form-data") {
|
||||
const form = new FormData();
|
||||
if (Array.isArray(parameters.files)) {
|
||||
let total = 0;
|
||||
for (const item of parameters.files) {
|
||||
const file = record(item), bytes = decodeFile(file.contentBase64);
|
||||
total += bytes.length;
|
||||
if (total > MAX_BYTES) throw failure(422, "daytona_input_limit", "The total upload exceeds the limit.");
|
||||
form.append("files", new Blob([bytes]), String(file.path));
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(record(parameters.body))) {
|
||||
if (key === "file") form.append(key, new Blob([decodeFile(value)]), "upload");
|
||||
else form.append(key, String(value));
|
||||
}
|
||||
}
|
||||
// The guarded, DNS-pinned transport accepts bytes, not FormData. Use the
|
||||
// platform encoder to serialize parts and its matching boundary header.
|
||||
const encoded = new Request(DAYTONA_API_URL, { method: "POST", body: form });
|
||||
body = Buffer.from(await withinDeadline(() => encoded.arrayBuffer()));
|
||||
if (body.byteLength > MAX_BYTES) throw failure(422, "daytona_input_limit", "The multipart upload exceeds the limit.");
|
||||
projectedHeaders["Content-Type"] = encoded.headers.get("Content-Type")!;
|
||||
} else if (parameters.body !== undefined) {
|
||||
body = JSON.stringify(parameters.body);
|
||||
if (Buffer.byteLength(body) > MAX_BYTES) throw failure(422, "daytona_input_limit", "The request body exceeds the limit.");
|
||||
projectedHeaders["Content-Type"] = operation.bodyContentType === "*/*" ? "application/json" : operation.bodyContentType ?? "application/json";
|
||||
}
|
||||
// No automatic retry. A transport failure cannot prove a write did not run.
|
||||
return send(url.toString(), { method: operation.method, headers: projectedHeaders, body }, account.organizationId);
|
||||
}
|
||||
return { probe: identity, execute };
|
||||
}
|
||||
|
||||
type Binding = { companyId: string; agentId: string | null; runId?: string | null; responsibleUserId?: string | null };
|
||||
type Connection = { companyId: string; config: Record<string, unknown> };
|
||||
export async function executeDaytonaTool(input: {
|
||||
binding: Binding; connection: Connection; headers: Record<string, string>;
|
||||
request: DaytonaRequest; toolName: string; parameters: unknown;
|
||||
signal?: AbortSignal;
|
||||
deadlineAt?: number;
|
||||
}) {
|
||||
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);
|
||||
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.");
|
||||
const config = record(input.connection.config.methodConfig);
|
||||
return daytonaClient(input.headers, input.request, config.organizationId, input.signal, input.deadlineAt).execute(operation, record(input.parameters));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,5 @@
|
||||
import { stableHash, descriptorHash } from "./tool-catalog-hash.js";
|
||||
import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "./daytona.js";
|
||||
import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js";
|
||||
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
||||
import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js";
|
||||
@@ -2255,11 +2257,6 @@ function buildProfileDetails(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function stableHash(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(value, Object.keys(flattenKeys(value)).sort()))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function connectionSetupMutationFingerprint(
|
||||
row: typeof toolConnections.$inferSelect,
|
||||
@@ -2280,20 +2277,6 @@ function connectionSetupMutationFingerprint(
|
||||
});
|
||||
}
|
||||
|
||||
function flattenKeys(
|
||||
value: unknown,
|
||||
keys: Record<string, true> = {},
|
||||
): Record<string, true> {
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, nested] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
keys[key] = true;
|
||||
flattenKeys(nested, keys);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function normalizeToolDescriptor(tool: unknown): McpToolDescriptor | null {
|
||||
const record = asRecord(tool);
|
||||
@@ -2401,6 +2384,7 @@ export function classifyRisk(
|
||||
if (sourceTemplateKey === "browser-use-cloud") {
|
||||
return BROWSER_USE_TOOLS.find(t => t.name === tool.name)?.annotations.readOnlyHint ? "read" : "destructive";
|
||||
}
|
||||
if (sourceTemplateKey === "daytona") return daytonaRisk(tool.name);
|
||||
if (sourceTemplateKey === "railway") {
|
||||
const reviewed = railwayRisk(normalizedToolName);
|
||||
return reviewed === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true) ? "write" : reviewed;
|
||||
@@ -2866,19 +2850,6 @@ function recordValue(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function descriptorHash(
|
||||
tool: McpToolDescriptor,
|
||||
riskLevel: ToolRiskLevel,
|
||||
): string {
|
||||
return stableHash({
|
||||
name: tool.name,
|
||||
title: tool.title ?? null,
|
||||
description: tool.description ?? null,
|
||||
inputSchema: tool.inputSchema ?? {},
|
||||
annotations: tool.annotations ?? {},
|
||||
riskLevel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this error come from the OAuth endpoint gate (PAP-17099)? Such a refusal
|
||||
@@ -3688,7 +3659,7 @@ export function toolAccessService(
|
||||
function shouldQuarantineNewEntries(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): boolean {
|
||||
return asRecord(connection.config).quarantineNewEntries === true;
|
||||
return isDaytonaConnection(connection) || asRecord(connection.config).quarantineNewEntries === true;
|
||||
}
|
||||
|
||||
function isAttentionHealthStatus(
|
||||
@@ -7502,6 +7473,11 @@ export function toolAccessService(
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
assertSupportedConnection(connection);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
||||
if (isDaytonaConnection(connection)) {
|
||||
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;
|
||||
}
|
||||
if (isBrowserUseConnection(connection)) {
|
||||
const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor);
|
||||
await browserUseClient(headers, (url, init) => requestRemoteHttpEndpoint(new URL(url), init), connection.id).probe();
|
||||
@@ -7657,7 +7633,7 @@ export function toolAccessService(
|
||||
});
|
||||
for (const grant of grantsToCheck)
|
||||
await refreshManagedGitHubGrantAccess(connection, grant, actor);
|
||||
} else if (isBrowserUseConnection(connection)) {
|
||||
} else if (isBrowserUseConnection(connection) || isDaytonaConnection(connection)) {
|
||||
await discoverTools(connection, undefined, actor);
|
||||
} else if (isAgentMailConnection(connection)) {
|
||||
await validateAgentMailConnection(connection);
|
||||
@@ -7691,6 +7667,8 @@ export function toolAccessService(
|
||||
config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isDaytonaConnection(connection)
|
||||
? "Daytona API access is connected."
|
||||
: isBrowserUseConnection(connection)
|
||||
? "Browser Use API key is connected."
|
||||
: isAgentMailConnection(connection)
|
||||
@@ -7801,11 +7779,13 @@ export function toolAccessService(
|
||||
const existingByName = new Map(
|
||||
existingRows.map((entry) => [entry.toolName, entry]),
|
||||
);
|
||||
if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const nativeDaytona = isDaytonaConnection(connection);
|
||||
if (nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== "disabled").map((entry) => entry.id);
|
||||
const removedStatus = nativeDaytona ? "removed" : "disabled";
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus).map((entry) => entry.id);
|
||||
if (removedIds.length) await db.update(toolCatalogEntries)
|
||||
.set({ status: "disabled", quarantineReason: "mcp_tool_removed", updatedAt: refreshedAt })
|
||||
.set({ status: removedStatus, quarantineReason: nativeDaytona ? "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
|
||||
@@ -7843,7 +7823,7 @@ export function toolAccessService(
|
||||
? googleProfileValue
|
||||
: null;
|
||||
const preserveReviewedCatalog =
|
||||
(isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret") &&
|
||||
(isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret" || isDaytonaConnection(connection)) &&
|
||||
existingRows.length > 0;
|
||||
const quarantineOnRefresh =
|
||||
(!refreshOptions.enableAllByDefault || preserveReviewedCatalog) &&
|
||||
@@ -7865,7 +7845,7 @@ export function toolAccessService(
|
||||
quarantineOnRefresh &&
|
||||
(!existing || changed) &&
|
||||
existing?.status !== "disabled" &&
|
||||
(!safeDefault || riskLevel !== "read");
|
||||
(!safeDefault || riskLevel !== "read" || nativeDaytona);
|
||||
const providerPermanentlyBlocked = Boolean(
|
||||
googleProfile &&
|
||||
!isGoogleWorkspaceToolAllowed(googleProfile, descriptor),
|
||||
@@ -7946,7 +7926,7 @@ export function toolAccessService(
|
||||
}
|
||||
|
||||
const preserveQuarantine =
|
||||
isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret";
|
||||
isRailwayEndpoint(connection.config.url) || sourceTemplateKey === "enterpret" || isDaytonaConnection(connection);
|
||||
const normalizedConfig = preserveQuarantine
|
||||
? { ...connection.config, quarantineNewEntries: true }
|
||||
: refreshOptions.enableAllByDefault
|
||||
@@ -12835,7 +12815,7 @@ export function toolAccessService(
|
||||
// Grant-backed setup keeps the full discovered catalog selectable;
|
||||
// the wizard projects the app's action defaults into policies at
|
||||
// finish time instead of using catalog quarantine as access state.
|
||||
quarantineNewEntries: galleryEntry.slug === "railway" || galleryEntry.slug === "enterpret",
|
||||
quarantineNewEntries: ["railway", "enterpret", "daytona"].includes(galleryEntry.slug),
|
||||
...(remoteMcpConnector ? {
|
||||
mcpSessionRequired: true,
|
||||
mcpAuthMode: input.authMode ?? "auto",
|
||||
@@ -13163,7 +13143,7 @@ export function toolAccessService(
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name: applicationName,
|
||||
description: safeApplicationDescription,
|
||||
type: transport === "rest_api" && galleryEntry?.slug === "browser-use-cloud" ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: transport === "rest_api" && ["browser-use-cloud", "daytona"].includes(galleryEntry?.slug ?? "") ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry
|
||||
? {
|
||||
@@ -18123,7 +18103,7 @@ export function toolAccessService(
|
||||
companyId,
|
||||
applicationKey: normalizeKey(input.applicationName ?? input.name),
|
||||
name: input.applicationName ?? input.name,
|
||||
type: isBrowserUseConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
type: isBrowserUseConnection({ transport, config }) || isDaytonaConnection({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "active",
|
||||
metadata: {},
|
||||
})
|
||||
@@ -19145,6 +19125,7 @@ export function toolAccessService(
|
||||
listCatalog: async (
|
||||
connectionId: string,
|
||||
companyId?: string,
|
||||
actor?: ActorInfo,
|
||||
): Promise<ToolCatalogEntry[]> => {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
let rows = await db
|
||||
@@ -19152,17 +19133,29 @@ export function toolAccessService(
|
||||
.from(toolCatalogEntries)
|
||||
.where(eq(toolCatalogEntries.connectionId, connection.id))
|
||||
.orderBy(desc(toolCatalogEntries.updatedAt));
|
||||
const cacheExpired =
|
||||
// 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);
|
||||
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 existing = rowsByName.get(tool.name);
|
||||
return !existing || existing.versionHash !== descriptorHash(tool, daytonaRisk(tool.name));
|
||||
})
|
||||
);
|
||||
const cacheExpired = daytonaCatalogChanged || (
|
||||
connection.transport === "mcp_remote" &&
|
||||
connection.status !== "archived" &&
|
||||
(rows.length === 0 ||
|
||||
!connection.lastCatalogRefreshAt ||
|
||||
connection.lastCatalogRefreshAt.getTime() <=
|
||||
now().getTime() - catalogCacheTtlMs);
|
||||
now().getTime() - catalogCacheTtlMs));
|
||||
if (cacheExpired) {
|
||||
try {
|
||||
await singleFlight(catalogRefreshFlights, connection.id, () =>
|
||||
refreshCatalog(connection.id, {
|
||||
refreshCatalog(connection.id, nativeDaytona && actor ? actor : {
|
||||
actorType: "system",
|
||||
actorId: "tool_catalog_cache",
|
||||
}),
|
||||
@@ -19179,7 +19172,11 @@ export function toolAccessService(
|
||||
if (rows.length === 0) throw error;
|
||||
}
|
||||
}
|
||||
return rows.map((row) => toCatalogEntryForConnection(row, connection));
|
||||
// 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)))
|
||||
.map((row) => toCatalogEntryForConnection(row, connection));
|
||||
},
|
||||
|
||||
/** Recent tool-call events for one connection — drives App detail · Recent activity. */
|
||||
@@ -20194,6 +20191,7 @@ export function toolAccessService(
|
||||
input.connectionId,
|
||||
input.companyId,
|
||||
);
|
||||
if (isDaytonaConnection(connection)) throw forbidden("Daytona 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);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Keep catalog discovery and native-provider admission on the same saved contract.
|
||||
export function stableHash(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(value, Object.keys(flattenKeys(value)).sort()))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function flattenKeys(
|
||||
value: unknown,
|
||||
keys: Record<string, true> = {},
|
||||
): Record<string, true> {
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, nested] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
keys[key] = true;
|
||||
flattenKeys(nested, keys);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function descriptorHash(
|
||||
tool: { name: string; title?: string | null; description?: string | null; inputSchema?: Record<string, unknown>; annotations?: Record<string, unknown> },
|
||||
riskLevel: string,
|
||||
): string {
|
||||
return stableHash({
|
||||
name: tool.name,
|
||||
title: tool.title ?? null,
|
||||
description: tool.description ?? null,
|
||||
inputSchema: tool.inputSchema ?? {},
|
||||
annotations: tool.annotations ?? {},
|
||||
riskLevel,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { composeConnectionInstructions } from "./connection-instructions.js";
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { boundedMcpToolName } from "./mcp-tool-names.js";
|
||||
@@ -1398,8 +1399,9 @@ export function createToolGatewayService(
|
||||
const eligibleRows = rows.filter(
|
||||
({ catalogEntry, connection, application }) =>
|
||||
!isRetiredComposioConnection(connection) &&
|
||||
!(isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(catalogEntry)) &&
|
||||
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
|
||||
((isBrowserUseConnection(connection) && application.type === "rest_api") ||
|
||||
(((isBrowserUseConnection(connection) || isDaytonaConnection(connection)) && application.type === "rest_api") ||
|
||||
(connection.transport === "mcp_remote" &&
|
||||
application.type === "mcp_http") ||
|
||||
(connection.transport === "local_stdio" &&
|
||||
@@ -1425,7 +1427,7 @@ export function createToolGatewayService(
|
||||
({ catalogEntry, connection, application }, index) => {
|
||||
if (
|
||||
connection.transport !== "mcp_remote" &&
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection)
|
||||
connection.transport !== "local_stdio" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection)
|
||||
) {
|
||||
throw new Error(
|
||||
`Non-MCP connection ${connection.id} cannot be exposed through the MCP gateway`,
|
||||
@@ -4840,7 +4842,7 @@ export function createToolGatewayService(
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection))) {
|
||||
if (!connection || (connection.transport !== "mcp_remote" && !isBrowserUseConnection(connection) && !isDaytonaConnection(connection))) {
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
`Tool "${tool.name}" not found`,
|
||||
@@ -4863,6 +4865,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");
|
||||
}
|
||||
return { entry, connection };
|
||||
}
|
||||
|
||||
@@ -5986,6 +5991,17 @@ export function createToolGatewayService(
|
||||
ms = railwayCommandBudgetMs(parameters);
|
||||
}
|
||||
const grant = await resolveConnectionGrant(session, connection);
|
||||
if (isDaytonaConnection(connection)) {
|
||||
const budgetMs = Math.min(ms, 30_000);
|
||||
const deadlineAt = Date.now() + budgetMs;
|
||||
const signal = AbortSignal.timeout(budgetMs);
|
||||
const data = await executeDaytonaTool({ binding: session, connection,
|
||||
headers: await resolveCredentialHeaders(session, connection, grant),
|
||||
request: (url, init) => options.remoteHttpRequest ? options.remoteHttpRequest(url, init)
|
||||
: guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()),
|
||||
toolName: entry.toolName, parameters, signal, deadlineAt });
|
||||
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
|
||||
}
|
||||
if (isBrowserUseConnection(connection)) {
|
||||
const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters);
|
||||
return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } };
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Daytona uses the ordinary access and credential wizard", async ({ page, request }) => {
|
||||
const response = await request.post("/api/companies", { data: { name: `Daytona 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("Daytona");
|
||||
const card = page.getByRole("list", { name: "Connector list" }).getByRole("listitem").filter({ hasText: "Daytona" });
|
||||
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/daytona.svg"]')).toBeVisible();
|
||||
await card.getByRole("button", { name: "Add key Daytona", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connect Daytona", exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Daytona key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Your Daytona key", { exact: true })).toHaveAttribute("type", "password");
|
||||
await expect(page.getByLabel("Pin to organization ID", { 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.getByLabel("Pin to organization ID", { exact: true })).toBeVisible();
|
||||
// No key is submitted and no real provider connection is created. Gateway
|
||||
// lifecycle and provider reads are exercised in the database-backed suite.
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><g fill="#fff" clip-path="url(#a)"><path d="M184.634 227.709V83.723h-57.675v143.986zV83.723m.936 202.797-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.66-1.659-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.634 1.633 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.659 1.659 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864z"/><path d="m36.865 219.379 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.659 1.659"/><path d="m144.771 327.3 1.865-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.633-1.633m42.231 40.927H66.522v57.676h161.263zm58.814-.91-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-2.691 2.692 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.634 1.633 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 2.691-2.691-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-Line truncated
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><g fill="#000" clip-path="url(#a)"><path d="M184.634 227.709V83.723h-57.675v143.986zV83.723m.936 202.797-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.865-1.865-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-1.864-1.864-1.864-1.865-1.864-1.864-1.66-1.659-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.634 1.633 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.659 1.659 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864z"/><path d="m36.865 219.379 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.659 1.659"/><path d="m144.771 327.3 1.865-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.633-1.633m42.231 40.927H66.522v57.676h161.263zm58.814-.91-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.865 1.864-1.864 1.864-1.864 1.865-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.865 1.864-2.691 2.692 1.864 1.864 1.864 1.864 1.865 1.865 1.864 1.864 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.865 1.864 1.864 1.864 1.864 1.634 1.633 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.865-1.865 1.864-1.864 1.864-1.864 1.864-1.864 1.864-1.865 1.865-1.864 1.864-1.864 1.864-1.864 1.864-1.864 1.865-1.865 2.691-2.691-1.864-1.865-1.864-1.864-1.865-1.864-1.864-1.864-Line truncated
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -665,6 +665,13 @@
|
||||
"catalogVisible": false,
|
||||
"localAsset": "/brands/apps/monday.png",
|
||||
"darkAsset": "/brands/apps/monday-dark.png"
|
||||
},
|
||||
{
|
||||
"slug": "daytona",
|
||||
"provider": "Daytona",
|
||||
"catalogVisible": true,
|
||||
"localAsset": "/brands/apps/daytona.svg",
|
||||
"darkAsset": "/brands/apps/daytona-dark.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user