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:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-15 14:05:26 -07:00
1 parent 61831ba7b8
commit 0b62c736a0
25 files changed
+17140 -55

No files matched your search

+146
View File
@@ -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."
}
]
}
+9
View File
@@ -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`.
+7
View File
@@ -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.
+7
View File
@@ -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[];
+1 -1
View File
@@ -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),
);
+1
View File
@@ -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/"
}
+142
View File
@@ -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();
+53
View File
@@ -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([]);
});
});
+1 -1
View File
@@ -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) => {
+258
View File
@@ -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
+45 -47
View File
@@ -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);
+37
View File
@@ -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,
});
}
+19 -3
View File
@@ -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" } } };
+25
View File
@@ -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.
});
+1
View File
@@ -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

+1
View File
@@ -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

+7
View File
@@ -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"
}
]
}