From 3a5cb3847c77a0e021fdc90da8cdce0005e99d46 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda <72461767+mislavivanda@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:52:12 +0200 Subject: [PATCH] feat(sandbox): add Daytona cloud sandbox backend plugin (#121554) * feat: @openclaw/daytona-sandbox inital version Signed-off-by: Mislav Ivanda * feat: plugin config params extended Signed-off-by: Mislav Ivanda * feat: implement ClawSweeper review notes Signed-off-by: Mislav Ivanda * fix(daytona): honor abort signals and clean up remote staging on failure Signed-off-by: Mislav Ivanda * fix(daytona): register launcher as knip entry and refresh manifest schema Signed-off-by: Mislav Ivanda * fix(daytona): cancellable session transport and auto-stopped sandbox restart Signed-off-by: Mislav Ivanda * fix(daytona): deny egress by default and arm launcher cleanup before startup Signed-off-by: Mislav Ivanda * fix(daytona): stop cancelled startup before submission * test(daytona): satisfy deferred race lint * test(release): update plugin publisher inventory * fix(daytona): close provisioning and PTY cleanup gaps * test(daytona): type PTY launcher mock * fix(acpx): avoid promise-returning line handler * fix(daytona): await PTY signal cleanup * fix(daytona): declare ClawHub install route * fix(daytona): forward PTY stdin EOF * fix(daytona): serialize PTY input * docs(daytona): document sandbox backend config --------- Signed-off-by: Mislav Ivanda Co-authored-by: Patrick Erichsen --- .github/labeler.yml | 5 + config/knip.config.ts | 4 + docs/docs.json | 1 + docs/gateway/config-agents.md | 12 +- docs/gateway/daytona.md | 169 ++++ docs/gateway/sandboxing.md | 46 +- docs/plugins/plugin-inventory.md | 4 +- docs/plugins/reference/daytona.md | 19 + .../acpx/test/fixtures/codex-app-server.mjs | 6 +- extensions/daytona/README.md | 50 ++ extensions/daytona/index.ts | 26 + extensions/daytona/openclaw.plugin.json | 320 +++++++ extensions/daytona/package.json | 45 + extensions/daytona/src/backend.e2e.test.ts | 312 +++++++ extensions/daytona/src/backend.test.ts | 826 ++++++++++++++++++ extensions/daytona/src/backend.ts | 742 ++++++++++++++++ extensions/daytona/src/client.ts | 109 +++ extensions/daytona/src/config.test.ts | 184 ++++ extensions/daytona/src/config.ts | 260 ++++++ .../daytona/src/daytona-exec-launcher.mjs | 294 +++++++ extensions/daytona/src/launcher-path.ts | 43 + extensions/daytona/src/launcher.test.ts | 296 +++++++ extensions/daytona/src/upload.ts | 113 +++ extensions/daytona/tsconfig.json | 3 + extensions/tlon/package.json | 4 +- package.json | 1 + pnpm-lock.yaml | 688 ++++++++++++++- .../lib/official-external-plugin-catalog.json | 18 + .../build-external-plugin-local-dist.test.ts | 3 +- test/scripts/release-plan-producer.test.ts | 6 +- 30 files changed, 4580 insertions(+), 29 deletions(-) create mode 100644 docs/gateway/daytona.md create mode 100644 docs/plugins/reference/daytona.md create mode 100644 extensions/daytona/README.md create mode 100644 extensions/daytona/index.ts create mode 100644 extensions/daytona/openclaw.plugin.json create mode 100644 extensions/daytona/package.json create mode 100644 extensions/daytona/src/backend.e2e.test.ts create mode 100644 extensions/daytona/src/backend.test.ts create mode 100644 extensions/daytona/src/backend.ts create mode 100644 extensions/daytona/src/client.ts create mode 100644 extensions/daytona/src/config.test.ts create mode 100644 extensions/daytona/src/config.ts create mode 100644 extensions/daytona/src/daytona-exec-launcher.mjs create mode 100644 extensions/daytona/src/launcher-path.ts create mode 100644 extensions/daytona/src/launcher.test.ts create mode 100644 extensions/daytona/src/upload.ts create mode 100644 extensions/daytona/tsconfig.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 1f6a901f82ba..0773cc967a8d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -375,6 +375,11 @@ - changed-files: - any-glob-to-any-file: - "extensions/copilot-proxy/**" +"extensions: daytona": + - changed-files: + - any-glob-to-any-file: + - "extensions/daytona/**" + - "docs/gateway/daytona.md" "extensions: diagnostics-otel": - changed-files: - any-glob-to-any-file: diff --git a/config/knip.config.ts b/config/knip.config.ts index 46209cfc9bff..b79aec83ad44 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -725,6 +725,10 @@ const config = { "harness.ts!", "media-understanding-provider.ts!", ]), + [`${BUNDLED_PLUGIN_ROOT_DIR}/daytona`]: bundledPluginWorkspace([ + // Copied to dist and spawned by the Daytona backend for sandbox execs. + "src/daytona-exec-launcher.mjs!", + ]), [`${BUNDLED_PLUGIN_ROOT_DIR}/deepgram`]: bundledPluginWorkspace(), [`${BUNDLED_PLUGIN_ROOT_DIR}/deepinfra`]: bundledPluginWorkspace(), [`${BUNDLED_PLUGIN_ROOT_DIR}/discord`]: bundledPluginWorkspace(), diff --git a/docs/docs.json b/docs/docs.json index 25efcae20f21..0994452b39f4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1714,6 +1714,7 @@ "gateway/security/rate-limiting", "gateway/operator-scopes", "gateway/sandboxing", + "gateway/daytona", "gateway/openshell", "gateway/sandbox-vs-tool-policy-vs-elevated", "gateway/permission-modes" diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 8d6af3b7c691..e3c4df0f0e25 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -772,7 +772,7 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing defaults: { sandbox: { mode: "non-main", // off (default) | non-main | all - backend: "docker", // docker (default) | podman | openshell | ssh + backend: "docker", // docker (default) | daytona | openshell | podman | ssh scope: "agent", // session | agent (default) | shared workspaceAccess: "none", // none (default) | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -866,12 +866,16 @@ Defaults shown above (`off`/`docker`/`agent`/`none`/`bookworm-slim` image/`none` **Backend:** +- `daytona`: Daytona-managed cloud runtime - `docker`: local Docker runtime (default) -- `ssh`: generic SSH-backed remote runtime - `openshell`: OpenShell-managed local or remote runtime +- `podman`: local Podman runtime using Docker-compatible settings +- `ssh`: generic SSH-backed remote runtime -When `backend: "openshell"` is selected, runtime-specific settings move to -`plugins.entries.openshell.config`. +Plugin-managed backends keep runtime-specific settings under their plugin entries: + +- Daytona: `plugins.entries.daytona.config`; see [Daytona](/gateway/daytona) +- OpenShell: `plugins.entries.openshell.config`; see [OpenShell](/gateway/openshell) **SSH backend config:** diff --git a/docs/gateway/daytona.md b/docs/gateway/daytona.md new file mode 100644 index 000000000000..84faabe2e524 --- /dev/null +++ b/docs/gateway/daytona.md @@ -0,0 +1,169 @@ +--- +summary: "Use Daytona cloud sandboxes as a sandbox backend for OpenClaw agents" +title: Daytona +read_when: + - You want cloud sandboxes instead of local Docker + - You are setting up the Daytona plugin + - You need agent tool execution isolated from the Gateway host +--- + +Daytona is a cloud sandbox backend: instead of running Docker containers +locally, OpenClaw creates [Daytona](https://www.daytona.io) sandboxes through +the Daytona API and executes commands and file operations over the Daytona +toolbox API (HTTPS). No SSH keys or inbound connectivity are required. + +The plugin reuses the same remote filesystem bridge as the generic +[SSH backend](/gateway/sandboxing#ssh-backend) with a remote-canonical +workspace model: the sandbox workspace is seeded once at creation and stays +canonical until you recreate it. + +## Prerequisites + +- Daytona plugin installed (`openclaw plugins install @openclaw/daytona-sandbox`) +- A Daytona API key (`https://app.daytona.io/dashboard/keys`) +- OpenClaw Gateway running on the host + +## Quick start + +```bash +openclaw plugins install @openclaw/daytona-sandbox +``` + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "daytona", + scope: "session", + workspaceAccess: "rw", + }, + }, + }, + plugins: { + entries: { + daytona: { + enabled: true, + config: { + apiKey: { source: "env", provider: "default", id: "DAYTONA_API_KEY" }, + }, + }, + }, + }, +} +``` + +Export `DAYTONA_API_KEY` in the Gateway environment (or store the key with a +SecretRef as above, or as a plaintext string). Restart the Gateway. On the +next agent turn OpenClaw creates a Daytona sandbox and routes tool execution +through it. Verify with: + +```bash +openclaw sandbox list +openclaw sandbox explain +``` + +New sandboxes block all network egress by default, matching the Docker +backend's no-network stance. If your agents need to install packages or reach +the network from inside the sandbox, opt in explicitly with +`networkBlockAll: false`, or grant selective egress with `networkAllowList` +or `domainAllowList`. + +## How execution works + +- **Sandbox per scope**: one Daytona sandbox per sandbox scope (`agent`, + `session`, or `shared`). Sandboxes are labeled `openclaw.sandbox=1` and + adopted across Gateway restarts through the OpenClaw sandbox registry. +- **Exec**: each `exec` call runs inside the sandbox through a Daytona session + (or a Daytona PTY when the tool requests a TTY). Exit codes, stdout, stderr, + stdin, and terminal resizes all flow through the toolbox API. +- **Files**: `read`, `write`, `edit`, `apply_patch`, and media reads go through + the sandbox filesystem bridge, so file tools operate on the remote workspace + with the same path and writability rules as the SSH backend. +- **Auto-stop**: Daytona stops idle sandboxes automatically (default 15 + minutes). OpenClaw restarts a stopped sandbox on the next use, so idle + sandboxes cost nothing while state stays warm. + +## Workspace model + +The session workspace is uploaded once when the sandbox is created +(remote-canonical, like the SSH backend). Host-local edits made after the seed +are not visible remotely until you recreate the sandbox: + +```bash +openclaw sandbox recreate --session +``` + +This deletes the Daytona sandbox; the next agent turn provisions a fresh one +and seeds it from the current local workspace. + +## Configuration reference + +All settings live under `plugins.entries.daytona.config`: + +| Key | Type | Default | Description | +| ------------------------- | ----------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiKey` | string, SecretRef | unset | Daytona API key. Falls back to the `DAYTONA_API_KEY` environment variable. | +| `apiUrl` | string | Daytona cloud | Daytona API base URL. Falls back to `DAYTONA_API_URL`. | +| `target` | string | Daytona default | Target region for new sandboxes. Falls back to `DAYTONA_TARGET`. | +| `snapshot` | string | Daytona default snapshot | Snapshot for new sandboxes. The image needs `sh`, `tar`, `base64`, `stat`, and `python3` on `PATH`. Mutually exclusive with `image`. | +| `image` | string | unset | Docker image for new sandboxes, pulled or built by Daytona on first create. Mutually exclusive with `snapshot`. | +| `resources` | object | unset | `{ cpu, gpu, memory, disk }` for image-based sandboxes (memory and disk in GiB). Omitted fields use the Daytona defaults (1 vCPU, 1 GB, 3 GiB). Snapshot sandboxes size from the snapshot. | +| `user` | string | snapshot default | OS user for the sandbox. Align the remote workspace dirs with that user's writable paths. | +| `volumes` | array | unset | Daytona volumes to mount, as `{ volumeId, mountPath }` entries. Reachable from `exec`; outside the file-tool workspace mounts. | +| `autoStopInterval` | integer (minutes) | `15` (Daytona default) | Minutes of inactivity before Daytona stops the sandbox. `0` keeps it running continuously. | +| `autoPauseInterval` | integer (minutes) | disabled | Minutes of inactivity before Daytona pauses the sandbox (VM-based runners; pause preserves memory state). At most one of auto-stop and auto-pause may be non-zero. | +| `autoArchiveInterval` | integer (minutes) | `7` days (Daytona) | Minutes a stopped sandbox waits before archiving to cold storage. `0` uses the Daytona maximum. | +| `autoDeleteInterval` | integer (minutes) | disabled | Minutes a sandbox may stay stopped before Daytona deletes it. `0` deletes immediately on stop. | +| `networkBlockAll` | boolean | `true` (egress blocked) | Block all sandbox network egress, matching the Docker backend's no-network default. Set `false` for open egress; configuring an allow list implies selective egress. | +| `networkAllowList` | string | unset | Comma-separated CIDR addresses the sandbox may reach. Setting this (with `networkBlockAll` unset) enables selective egress. | +| `domainAllowList` | string | unset | Comma-separated domains the sandbox may reach. Setting this (with `networkBlockAll` unset) enables selective egress. | +| `remoteWorkspaceDir` | string | `/home/daytona/workspace` | Absolute path of the session workspace inside the sandbox. | +| `remoteAgentWorkspaceDir` | string | `/home/daytona/agent` | Absolute path mirroring the real agent workspace when `workspaceAccess` is not `none`. | +| `timeoutSeconds` | number | `120` | Timeout for Daytona API operations (create, upload, filesystem commands). Image-based creates automatically get a higher floor to cover image pulls; raise this when declarative builds need longer. | + +## Lifecycle management + +```bash +# List all sandbox runtimes (Docker + Daytona) +openclaw sandbox list + +# Inspect effective policy +openclaw sandbox explain + +# Recreate (deletes the Daytona sandbox, re-seeds on next use) +openclaw sandbox recreate --session +``` + +Idle pruning (`agents.defaults.sandbox.prune`) treats Daytona runtimes the same +as Docker runtimes: pruned entries delete the Daytona sandbox. + +## Cost controls + +- `autoStopInterval` (default 15 minutes) stops idle sandboxes; stopped + sandboxes restart automatically on next use. `autoPauseInterval` pauses + instead, on sandbox classes that support pausing. +- `autoArchiveInterval` moves long-stopped sandboxes to cold storage; + `autoDeleteInterval` deletes sandboxes that stay stopped, if you prefer + Daytona-side cleanup in addition to OpenClaw pruning. +- OpenClaw prune (`sandbox.prune.idleHours` / `maxAgeDays`) deletes registered + sandboxes from the OpenClaw side. + +## Current limitations + +- Browser sandboxing is not supported on this backend. +- `sandbox.docker.*` settings (image, binds, network) do not apply; use + `snapshot`/`image` and the network allow-list options instead. + `sandbox.docker.binds` is rejected; Daytona `volumes` cover shared storage. +- Volume mount paths are reachable from `exec` commands only; the file tools + stay inside the managed workspace mounts. +- The workspace is seeded once (remote-canonical); there is no mirror mode. +- Exec stdin is line-oriented text (Daytona session input); binary stdin + streams are not preserved byte-for-byte in non-PTY execs. + +## Related + +- [Sandboxing overview](/gateway/sandboxing) +- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) +- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 37681e91b48e..77b124c5b7a6 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -26,11 +26,11 @@ Not sandboxed: Three independent settings control sandbox behavior: -| Setting | Key | Values | Default | -| ------- | --------------------------------- | -------------------------------------- | -------- | -| Mode | `agents.defaults.sandbox.mode` | `off`, `non-main`, `all` | `off` | -| Scope | `agents.defaults.sandbox.scope` | `agent`, `session`, `shared` | `agent` | -| Backend | `agents.defaults.sandbox.backend` | `docker`, `podman`, `ssh`, `openshell` | `docker` | +| Setting | Key | Values | Default | +| ------- | --------------------------------- | ------------------------------------------------- | -------- | +| Mode | `agents.defaults.sandbox.mode` | `off`, `non-main`, `all` | `off` | +| Scope | `agents.defaults.sandbox.scope` | `agent`, `session`, `shared` | `agent` | +| Backend | `agents.defaults.sandbox.backend` | `docker`, `podman`, `ssh`, `openshell`, `daytona` | `docker` | **Mode** controls when sandboxing applies: @@ -62,7 +62,7 @@ Non-shared runtime identity also includes the resolved agent workspace path. Thi The first use after upgrading from an older release creates non-shared runtimes and sandbox workspaces under the workspace-qualified identity. Existing non-shared runtimes are not adopted; this is an intentional one-time reset. They can age out through configured prune settings or be removed with `openclaw sandbox recreate`; the next use provisions the current identity. -**Backend** controls which runtime executes sandboxed tools. Docker and Podman share `agents.defaults.sandbox.docker`; SSH-specific config lives under `agents.defaults.sandbox.ssh`; OpenShell-specific config lives under `plugins.entries.openshell.config`. +**Backend** controls which runtime executes sandboxed tools. Docker and Podman share `agents.defaults.sandbox.docker`; SSH-specific config lives under `agents.defaults.sandbox.ssh`; OpenShell-specific config lives under `plugins.entries.openshell.config`; Daytona-specific config lives under `plugins.entries.daytona.config`. | | Docker or Podman backend | SSH | OpenShell | | ------------------- | ----------------------------------------- | ------------------------------ | --------------------------------------------------- | @@ -283,6 +283,39 @@ Use `backend: "openshell"` to sandbox tools in an OpenShell-managed remote envir For the full prerequisites, configuration reference, workspace-mode comparison, and lifecycle details, see [OpenShell](/gateway/openshell). +## Daytona backend + +Use `backend: "daytona"` to sandbox tools in [Daytona](https://www.daytona.io) cloud sandboxes. OpenClaw creates one Daytona sandbox per sandbox scope through the Daytona API and runs exec and file tools over the Daytona toolbox API (HTTPS); no SSH keys or inbound connectivity are required. The workspace model is remote-canonical like the SSH backend: seeded once at creation, re-seeded by `openclaw sandbox recreate`. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "daytona", + scope: "session", + workspaceAccess: "rw", + }, + }, + }, + plugins: { + entries: { + daytona: { + enabled: true, + config: { + apiKey: { source: "env", provider: "default", id: "DAYTONA_API_KEY" }, + }, + }, + }, + }, +} +``` + +New sandboxes block all network egress by default (matching the Docker no-network default); opt in with `networkBlockAll: false` or the allow-list options. Idle sandboxes auto-stop on the Daytona side (default 15 minutes) and restart on next use. Current limitations: sandbox browser is not supported, and `sandbox.docker.*` settings do not apply to this backend. + +For the full prerequisites, configuration reference, cost controls, and lifecycle details, see [Daytona](/gateway/daytona). + ## Workspace access `agents.defaults.sandbox.workspaceAccess` controls what the sandbox can see: @@ -593,6 +626,7 @@ Each agent can override sandbox + tools: `agents.entries.*.sandbox` and `agents. ## Related - [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence +- [Daytona](/gateway/daytona) -- cloud sandbox backend setup, cost controls, and config reference - [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference - [Sandbox configuration](/gateway/config-agents#agentsdefaultssandbox) - [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?" diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 54dec453358e..ccc3f00743ea 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -174,7 +174,7 @@ Each entry lists the package, distribution route, and description. ## Official external packages -90 plugins +91 plugins - **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management. @@ -210,6 +210,8 @@ Each entry lists the package, distribution route, and description. - **[copilot](/plugins/reference/copilot)** (`@openclaw/copilot`) - npm; ClawHub: `clawhub:@openclaw/copilot`. Registers the GitHub Copilot agent runtime. +- **[daytona](/plugins/reference/daytona)** (`@openclaw/daytona-sandbox`) - npm; ClawHub. OpenClaw sandbox backend that runs agent exec and file tools inside Daytona cloud sandboxes over the Daytona toolbox API. + - **[deepinfra](/plugins/reference/deepinfra)** (`@openclaw/deepinfra-provider`) - npm; ClawHub: `clawhub:@openclaw/deepinfra-provider`. Adds DeepInfra model provider support to OpenClaw. - **[deepseek](/plugins/reference/deepseek)** (`@openclaw/deepseek-provider`) - npm; ClawHub: `clawhub:@openclaw/deepseek-provider`. Adds DeepSeek model provider support to OpenClaw. diff --git a/docs/plugins/reference/daytona.md b/docs/plugins/reference/daytona.md new file mode 100644 index 000000000000..5c3bad4d0de1 --- /dev/null +++ b/docs/plugins/reference/daytona.md @@ -0,0 +1,19 @@ +--- +summary: "OpenClaw sandbox backend that runs agent exec and file tools inside Daytona cloud sandboxes over the Daytona toolbox API." +read_when: + - You are installing, configuring, or auditing the daytona plugin +title: "Daytona plugin" +--- + +# Daytona plugin + +OpenClaw sandbox backend that runs agent exec and file tools inside Daytona cloud sandboxes over the Daytona toolbox API. + +## Distribution + +- Package: `@openclaw/daytona-sandbox` +- Install route: npm; ClawHub + +## Surface + +plugin diff --git a/extensions/acpx/test/fixtures/codex-app-server.mjs b/extensions/acpx/test/fixtures/codex-app-server.mjs index 77e5cb04ab19..68b64af3929f 100755 --- a/extensions/acpx/test/fixtures/codex-app-server.mjs +++ b/extensions/acpx/test/fixtures/codex-app-server.mjs @@ -114,7 +114,7 @@ async function handle(method, params) { throw new Error(`unsupported fixture method: ${method}`); } -readline.createInterface({ input: process.stdin }).on("line", async (line) => { +async function handleLine(line) { let message; try { message = JSON.parse(line); @@ -142,4 +142,8 @@ readline.createInterface({ input: process.stdin }).on("line", async (line) => { error: { code: -32601, message: error instanceof Error ? error.message : String(error) }, }); } +} + +readline.createInterface({ input: process.stdin }).on("line", (line) => { + void handleLine(line); }); diff --git a/extensions/daytona/README.md b/extensions/daytona/README.md new file mode 100644 index 000000000000..ce04ccf679dc --- /dev/null +++ b/extensions/daytona/README.md @@ -0,0 +1,50 @@ +# OpenClaw Daytona Sandbox Plugin + +Sandbox backend that runs OpenClaw agent tool execution (`exec`, `read`, `write`, +`edit`, `apply_patch`, media reads) inside [Daytona](https://www.daytona.io) +cloud sandboxes over the Daytona toolbox API. The Gateway, agent loop, model +calls, and channels stay on the host. + +## Install + +```bash +openclaw plugins install @openclaw/daytona-sandbox +``` + +## Configure + +```jsonc +{ + "plugins": { + "entries": { + "daytona": { + "enabled": true, + "config": { + "apiKey": { "source": "env", "provider": "default", "id": "DAYTONA_API_KEY" }, + }, + }, + }, + }, + "agents": { + "defaults": { + "sandbox": { "mode": "all", "backend": "daytona" }, + }, + }, +} +``` + +`apiKey` accepts a plaintext string or a SecretRef and falls back to the +`DAYTONA_API_KEY` environment variable. + +## Facts + +- Package: `@openclaw/daytona-sandbox` (external official plugin). +- One Daytona sandbox per sandbox scope (`agent`, `session`, or `shared`), + adopted across restarts through the OpenClaw sandbox registry. +- Remote-canonical workspace: the session workspace is seeded once into the + sandbox at creation; `openclaw sandbox recreate` deletes the sandbox and + re-seeds on next use. +- The sandbox image needs `sh`, `tar`, `base64`, `stat`, and `python3` on + `PATH` (the Daytona default snapshot has all of them). + +Docs: https://docs.openclaw.ai/gateway/daytona diff --git a/extensions/daytona/index.ts b/extensions/daytona/index.ts new file mode 100644 index 000000000000..9cbc80b46dee --- /dev/null +++ b/extensions/daytona/index.ts @@ -0,0 +1,26 @@ +// Daytona sandbox plugin entry: registers the daytona sandbox backend. +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; +import { + createDaytonaSandboxBackendFactory, + createDaytonaSandboxBackendManager, +} from "./src/backend.js"; +import { createDaytonaPluginConfigSchema, resolveDaytonaPluginConfig } from "./src/config.js"; + +export default definePluginEntry({ + id: "daytona", + name: "Daytona Sandbox", + description: "Daytona cloud sandbox runtime for agent exec and file tools.", + configSchema: createDaytonaPluginConfigSchema(), + register(api) { + if (api.registrationMode !== "full") { + return; + } + const pluginConfig = resolveDaytonaPluginConfig(api.pluginConfig); + registerSandboxBackend("daytona", { + factory: createDaytonaSandboxBackendFactory({ pluginConfig, hostConfig: api.config }), + manager: createDaytonaSandboxBackendManager({ pluginConfig, hostConfig: api.config }), + resolveWorkdir: () => pluginConfig.remoteWorkspaceDir, + }); + }, +}); diff --git a/extensions/daytona/openclaw.plugin.json b/extensions/daytona/openclaw.plugin.json new file mode 100644 index 000000000000..d9ead5dd1c84 --- /dev/null +++ b/extensions/daytona/openclaw.plugin.json @@ -0,0 +1,320 @@ +{ + "id": "daytona", + "activation": { + "onStartup": false, + "onConfigPaths": [ + "plugins.entries.daytona" + ] + }, + "name": "Daytona Sandbox", + "description": "OpenClaw sandbox backend that runs agent exec and file tools inside Daytona cloud sandboxes over the Daytona toolbox API.", + "configSchema": { + "type": "object", + "properties": { + "apiKey": { + "anyOf": [ + { + "type": "string" + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "env" + }, + "provider": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,127}$" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "store" + }, + "provider": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "id": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,127}$" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "file" + }, + "provider": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "id": { + "type": "string" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "exec" + }, + "provider": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$" + }, + "id": { + "type": "string" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "additionalProperties": false + } + ] + } + ] + }, + "apiUrl": { + "type": "string", + "minLength": 1 + }, + "target": { + "type": "string", + "minLength": 1 + }, + "snapshot": { + "type": "string", + "minLength": 1 + }, + "image": { + "type": "string", + "minLength": 1 + }, + "resources": { + "type": "object", + "properties": { + "cpu": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "gpu": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "memory": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "disk": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "user": { + "type": "string", + "minLength": 1 + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "volumeId": { + "type": "string", + "minLength": 1 + }, + "mountPath": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "volumeId", + "mountPath" + ], + "additionalProperties": false + } + }, + "autoStopInterval": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "autoPauseInterval": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "autoArchiveInterval": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "autoDeleteInterval": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "networkBlockAll": { + "type": "boolean" + }, + "networkAllowList": { + "type": "string", + "minLength": 1 + }, + "domainAllowList": { + "type": "string", + "minLength": 1 + }, + "remoteWorkspaceDir": { + "type": "string", + "minLength": 1 + }, + "remoteAgentWorkspaceDir": { + "type": "string", + "minLength": 1 + }, + "timeoutSeconds": { + "type": "number", + "minimum": 1, + "maximum": 2147000 + } + }, + "additionalProperties": false + }, + "uiHints": { + "apiKey": { + "label": "Daytona API Key", + "help": "Daytona API key or SecretRef (fallback: DAYTONA_API_KEY env var).", + "sensitive": true, + "placeholder": "dtn_..." + }, + "apiUrl": { + "label": "Daytona API URL", + "help": "Optional Daytona API base URL (fallback: DAYTONA_API_URL env var). Defaults to Daytona cloud.", + "advanced": true + }, + "target": { + "label": "Target Region", + "help": "Optional Daytona target region for new sandboxes (fallback: DAYTONA_TARGET env var).", + "advanced": true + }, + "snapshot": { + "label": "Snapshot", + "help": "Daytona snapshot for new sandboxes. Defaults to the Daytona default snapshot. The image needs sh, tar, base64, stat, and python3 on PATH. Mutually exclusive with image." + }, + "image": { + "label": "Image", + "help": "Docker image for new sandboxes, pulled or built by Daytona on first create. Mutually exclusive with snapshot.", + "advanced": true + }, + "resources": { + "label": "Resources", + "help": "CPU, GPU, memory (GiB), and disk (GiB) for image-based sandboxes. Omitted fields use the Daytona defaults. Snapshot sandboxes size from the snapshot.", + "advanced": true + }, + "user": { + "label": "OS User", + "help": "OS user for the sandbox. When set, align remoteWorkspaceDir and remoteAgentWorkspaceDir with that user's writable paths.", + "advanced": true + }, + "volumes": { + "label": "Volumes", + "help": "Daytona volumes to mount, as { volumeId, mountPath } entries. Volume paths are reachable from exec commands but stay outside the file-tool workspace mounts.", + "advanced": true + }, + "autoStopInterval": { + "label": "Auto-stop Interval Minutes", + "help": "Minutes of inactivity before Daytona stops the sandbox; 0 keeps it running. Defaults to the Daytona default (15). Stopped sandboxes restart on next use." + }, + "autoPauseInterval": { + "label": "Auto-pause Interval Minutes", + "help": "Minutes of inactivity before Daytona pauses the sandbox; 0 disables. Pause needs VM-based runners and preserves memory state. Only one of auto-stop and auto-pause may be non-zero.", + "advanced": true + }, + "autoArchiveInterval": { + "label": "Auto-archive Interval Minutes", + "help": "Minutes a stopped sandbox waits before Daytona archives it to cold storage; 0 uses the Daytona maximum. Defaults to 7 days.", + "advanced": true + }, + "autoDeleteInterval": { + "label": "Auto-delete Interval Minutes", + "help": "Minutes a sandbox may stay stopped before Daytona deletes it; 0 deletes immediately on stop. Disabled by default.", + "advanced": true + }, + "networkBlockAll": { + "label": "Block Network", + "help": "Block all sandbox network egress. Enabled by default, matching the Docker backend's no-network default; set false for open egress.", + "advanced": true + }, + "networkAllowList": { + "label": "Network Allow List", + "help": "Comma-separated CIDR addresses the sandbox may reach. Setting this with Block Network unset enables selective egress.", + "advanced": true + }, + "domainAllowList": { + "label": "Domain Allow List", + "help": "Comma-separated domains the sandbox may reach. Setting this with Block Network unset enables selective egress.", + "advanced": true + }, + "remoteWorkspaceDir": { + "label": "Remote Workspace Dir", + "help": "Absolute path of the writable session workspace inside the Daytona sandbox.", + "advanced": true + }, + "remoteAgentWorkspaceDir": { + "label": "Remote Agent Dir", + "help": "Absolute path mirroring the real agent workspace when workspaceAccess is not none.", + "advanced": true + }, + "timeoutSeconds": { + "label": "API Timeout Seconds", + "help": "Timeout for Daytona API operations such as create, upload, and filesystem commands.", + "advanced": true + } + } +} diff --git a/extensions/daytona/package.json b/extensions/daytona/package.json new file mode 100644 index 000000000000..c3d5fe752b77 --- /dev/null +++ b/extensions/daytona/package.json @@ -0,0 +1,45 @@ +{ + "name": "@openclaw/daytona-sandbox", + "version": "2026.8.1", + "description": "OpenClaw sandbox backend for Daytona cloud sandboxes with remote workspaces over the Daytona toolbox API", + "repository": { + "type": "git", + "url": "https://github.com/openclaw/openclaw" + }, + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "dependencies": { + "@daytona/sdk": "0.201.0", + "zod": "4.4.3" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/daytona-sandbox", + "npmSpec": "@openclaw/daytona-sandbox", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + }, + "compat": { + "pluginApi": ">=2026.8.1" + }, + "build": { + "openclawVersion": "2026.8.1", + "bundledDist": false, + "staticAssets": [ + { + "source": "./src/daytona-exec-launcher.mjs", + "output": "daytona-exec-launcher.mjs" + } + ] + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + } +} diff --git a/extensions/daytona/src/backend.e2e.test.ts b/extensions/daytona/src/backend.e2e.test.ts new file mode 100644 index 000000000000..1fd950c7ed46 --- /dev/null +++ b/extensions/daytona/src/backend.e2e.test.ts @@ -0,0 +1,312 @@ +// Live Daytona backend E2E. Gated behind OPENCLAW_E2E_DAYTONA=1 plus a real +// DAYTONA_API_KEY because it creates and deletes a real cloud sandbox. +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + CreateSandboxBackendParams, + OpenClawConfig, + SandboxBackendHandle, +} from "openclaw/plugin-sdk/sandbox"; +import { + createSandboxBrowserConfig, + createSandboxPruneConfig, + createSandboxSshConfig, +} from "openclaw/plugin-sdk/test-fixtures"; +import { afterAll, describe, expect, it } from "vitest"; +import { + createDaytonaSandboxBackendFactory, + createDaytonaSandboxBackendManager, +} from "./backend.js"; +import { createDaytonaClient, resolveDaytonaConnection } from "./client.js"; +import { resolveDaytonaPluginConfig } from "./config.js"; + +type SandboxFsBridgeContext = Parameters< + NonNullable +>[0]["sandbox"]; + +const E2E_ENABLED = + process.env.OPENCLAW_E2E_DAYTONA === "1" && Boolean(process.env.DAYTONA_API_KEY); +const E2E_TIMEOUT_MS = 12 * 60 * 1000; + +const pluginConfig = resolveDaytonaPluginConfig({ + ...(process.env.OPENCLAW_E2E_DAYTONA_SNAPSHOT + ? { snapshot: process.env.OPENCLAW_E2E_DAYTONA_SNAPSHOT } + : {}), + autoDeleteInterval: 60, +}); +const hostConfig = {} as OpenClawConfig; +const createdRuntimeIds: string[] = []; +const tempDirs: string[] = []; + +afterAll(async () => { + const manager = createDaytonaSandboxBackendManager({ pluginConfig, hostConfig }); + for (const runtimeId of createdRuntimeIds) { + await manager + .removeRuntime({ + entry: { + containerName: runtimeId, + sessionKey: "agent:daytona-e2e", + createdAtMs: 0, + lastUsedAtMs: 0, + image: "default", + }, + config: hostConfig, + }) + .catch(() => {}); + } + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}, 120_000); + +async function createLiveParams(): Promise { + const workspaceDir = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-daytona-e2e-")), + ); + tempDirs.push(workspaceDir); + await fs.writeFile(path.join(workspaceDir, "seed-marker.txt"), "seeded-by-openclaw"); + return { + sessionKey: "agent:daytona-e2e:turn", + scopeKey: "agent:daytona-e2e", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: { + mode: "all", + backend: "daytona", + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + dockerTmpfsSource: "configured", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: [], + env: {}, + }, + ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"), + browser: createSandboxBrowserConfig(), + tools: { allow: ["*"], deny: [] }, + prune: createSandboxPruneConfig(), + }, + }; +} + +async function runBackendExec( + handle: SandboxBackendHandle, + params: { command: string; usePty?: boolean; env?: Record }, +): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + const spec = await handle.buildExecSpec({ + command: params.command, + env: params.env ?? {}, + usePty: params.usePty ?? false, + }); + const [executable, ...args] = spec.argv; + if (!executable) { + throw new Error("empty exec argv"); + } + const result = await new Promise<{ exitCode: number | null; stdout: string; stderr: string }>( + (resolve, reject) => { + const child = spawn(executable, args, { env: spec.env, stdio: ["pipe", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + child.on("error", reject); + child.stdin.end(); + child.on("close", (code) => { + resolve({ + exitCode: code, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); + }, + ); + await handle.finalizeExec?.({ + status: result.exitCode === 0 ? "completed" : "failed", + exitCode: result.exitCode, + timedOut: false, + token: spec.finalizeToken, + }); + return result; +} + +describe("daytona backend live e2e", () => { + // Image-based creates pull the image on first use, so this heavier path is + // double-gated: OPENCLAW_E2E_DAYTONA=1 plus OPENCLAW_E2E_DAYTONA_IMAGE=1. + it.runIf(E2E_ENABLED && process.env.OPENCLAW_E2E_DAYTONA_IMAGE === "1")( + "provisions an image-based sandbox with explicit resources", + async () => { + const imagePluginConfig = resolveDaytonaPluginConfig({ + image: "python:3.13-slim", + resources: { cpu: 1, memory: 2, disk: 5 }, + autoDeleteInterval: 60, + }); + const factory = createDaytonaSandboxBackendFactory({ + pluginConfig: imagePluginConfig, + hostConfig, + }); + const params = await createLiveParams(); + + const handle = await factory(params); + createdRuntimeIds.push(handle.runtimeId); + expect(handle.configLabel).toBe("python:3.13-slim"); + expect(handle.configLabelKind).toBe("Image"); + + const probe = await runBackendExec(handle, { + command: "python3 --version && cat seed-marker.txt && nproc", + }); + expect(probe.exitCode).toBe(0); + expect(probe.stdout).toContain("Python 3.13"); + expect(probe.stdout).toContain("seeded-by-openclaw"); + + const manager = createDaytonaSandboxBackendManager({ + pluginConfig: imagePluginConfig, + hostConfig, + }); + await manager.removeRuntime({ + entry: { + containerName: handle.runtimeId, + sessionKey: params.scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: handle.configLabel ?? "default", + }, + config: hostConfig, + }); + }, + E2E_TIMEOUT_MS, + ); + + it.runIf(E2E_ENABLED)( + "provisions, executes, bridges files, adopts, and removes a real sandbox", + async () => { + const factory = createDaytonaSandboxBackendFactory({ pluginConfig, hostConfig }); + const params = await createLiveParams(); + + const handle = await factory(params); + createdRuntimeIds.push(handle.runtimeId); + + // Exec lands in the Daytona sandbox with the seeded workspace as cwd. + const uname = await runBackendExec(handle, { + command: "uname -a && cat seed-marker.txt && printf '%s' \"$OC_E2E\"", + env: { OC_E2E: "env-flows" }, + }); + expect(uname.exitCode).toBe(0); + expect(uname.stdout).toContain("Linux"); + expect(uname.stdout).toContain("seeded-by-openclaw"); + expect(uname.stdout).toContain("env-flows"); + + // Exit codes and stderr propagate. + const failing = await runBackendExec(handle, { + command: "printf 'to-stderr' >&2; exit 7", + }); + expect(failing.exitCode).toBe(7); + expect(failing.stderr).toContain("to-stderr"); + + // PTY execs run through the Daytona PTY surface. + const pty = await runBackendExec(handle, { + command: "printf 'pty-marker'; exit 4", + usePty: true, + }); + expect(pty.exitCode).toBe(4); + expect(pty.stdout).toContain("pty-marker"); + + // Backend-owned workdir validation resolves real directories only. + await expect(handle.validateWorkdir?.(pluginConfig.remoteWorkspaceDir)).resolves.toBe( + pluginConfig.remoteWorkspaceDir, + ); + await expect(handle.validateWorkdir?.("/definitely-missing")).resolves.toBeNull(); + + // The fs bridge round-trips binary content without a host copy. + const bridgeContext: SandboxFsBridgeContext = { + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + workspaceAccess: "rw", + containerName: handle.runtimeId, + containerWorkdir: pluginConfig.remoteWorkspaceDir, + docker: {}, + backend: { runShellCommand: (command) => handle.runShellCommand(command) }, + }; + const bridge = handle.createFsBridge?.({ sandbox: bridgeContext }); + if (!bridge) { + throw new Error("daytona backend must provide an fs bridge"); + } + const binary = Buffer.from([0x00, 0x01, 0xfe, 0xff, 0x7f]); + await bridge.writeFile({ filePath: "bridge/binary.bin", data: binary, mkdir: true }); + const roundTrip = await bridge.readFile({ filePath: "bridge/binary.bin" }); + expect([...roundTrip]).toEqual([...binary]); + await expect( + fs.stat(path.join(params.workspaceDir, "bridge", "binary.bin")), + ).rejects.toThrow(); + const stat = await bridge.stat({ filePath: "bridge/binary.bin" }); + expect(stat).toMatchObject({ type: "file", size: binary.length }); + + // An aborted mutation is killed remotely before the abort is reported: + // the marker survives because the guarded rm never ran. + await bridge.writeFile({ filePath: "abort-marker.txt", data: "survives" }); + const abortController = new AbortController(); + const abortedMutation = handle.runShellCommand({ + script: `sleep 5 && rm -f ${pluginConfig.remoteWorkspaceDir}/abort-marker.txt`, + signal: abortController.signal, + }); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + abortController.abort(new Error("live abort probe")); + await expect(abortedMutation).rejects.toThrow("live abort probe"); + // Wait past the sleep window; if the remote command had survived the + // abort, the marker would be gone by now. + await new Promise((resolve) => { + setTimeout(resolve, 6000); + }); + const survivingMarker = await bridge.readFile({ filePath: "abort-marker.txt" }); + expect(survivingMarker.toString("utf8")).toBe("survives"); + + // An auto-stopped sandbox restarts on the next use for both transports. + const connection = await resolveDaytonaConnection({ config: hostConfig, pluginConfig }); + const client = await createDaytonaClient(connection); + const liveSandbox = await client.get(handle.runtimeId); + await liveSandbox.stop(); + const fsAfterStop = await handle.runShellCommand({ script: "printf fs-restarted" }); + expect(fsAfterStop.stdout.toString("utf8")).toBe("fs-restarted"); + await liveSandbox.stop(); + const execAfterStop = await runBackendExec(handle, { command: "printf exec-restarted" }); + expect(execAfterStop.exitCode).toBe(0); + expect(execAfterStop.stdout).toContain("exec-restarted"); + + // A fresh factory adopts the registered runtime instead of re-creating. + const adopted = await factory({ + ...params, + registeredRuntimeIds: [handle.runtimeId], + }); + expect(adopted.runtimeId).toBe(handle.runtimeId); + + // Manager sees the live sandbox and removes it. + const manager = createDaytonaSandboxBackendManager({ pluginConfig, hostConfig }); + const entry = { + containerName: handle.runtimeId, + sessionKey: params.scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: handle.configLabel ?? "default", + }; + await expect(manager.describeRuntime({ entry, config: hostConfig })).resolves.toMatchObject({ + running: true, + configLabelMatch: true, + }); + await manager.removeRuntime({ entry, config: hostConfig }); + await expect(manager.describeRuntime({ entry, config: hostConfig })).resolves.toMatchObject({ + running: false, + }); + }, + E2E_TIMEOUT_MS, + ); +}); diff --git a/extensions/daytona/src/backend.test.ts b/extensions/daytona/src/backend.test.ts new file mode 100644 index 000000000000..7a4cc45ad0e9 --- /dev/null +++ b/extensions/daytona/src/backend.test.ts @@ -0,0 +1,826 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + CreateSandboxBackendParams, + OpenClawConfig, + SandboxBackendHandle, +} from "openclaw/plugin-sdk/sandbox"; +import { + createSandboxBrowserConfig, + createSandboxPruneConfig, + createSandboxSshConfig, +} from "openclaw/plugin-sdk/test-fixtures"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveDaytonaPluginConfig, type ResolvedDaytonaPluginConfig } from "./config.js"; + +type SandboxFsBridgeContext = Parameters< + NonNullable +>[0]["sandbox"]; + +type FakeSandbox = { + id: string; + name: string; + state: string; + snapshot?: string; + start: ReturnType; + refreshData: ReturnType; + delete: ReturnType; + fs: { uploadFile: ReturnType; deleteFile: ReturnType }; + process: { + createSession: ReturnType; + executeSessionCommand: ReturnType; + deleteSession: ReturnType; + }; +}; + +type FakeClient = { + get: ReturnType; + create: ReturnType; +}; + +const clientMocks = vi.hoisted(() => ({ + createDaytonaClient: vi.fn(), + resolveDaytonaConnection: vi.fn(), +})); + +vi.mock("./client.js", () => ({ + createDaytonaClient: clientMocks.createDaytonaClient, + resolveDaytonaConnection: clientMocks.resolveDaytonaConnection, + isDaytonaNotFoundError: (error: unknown) => + (error as { statusCode?: number } | null)?.statusCode === 404, + withDaytonaRetry: async (_label: string, run: () => Promise) => await run(), +})); + +const { createDaytonaSandboxBackendFactory, createDaytonaSandboxBackendManager } = + await import("./backend.js"); + +const tempDirs: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + clientMocks.createDaytonaClient.mockReset(); + clientMocks.resolveDaytonaConnection.mockReset(); + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +async function makeTempDir(prefix: string): Promise { + // Canonicalize so macOS /var -> /private/var symlinks do not break + // remote-path assertions against `pwd -P` output. + const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix))); + tempDirs.push(dir); + return dir; +} + +function notFoundError(): Error & { statusCode: number } { + return Object.assign(new Error("sandbox not found"), { statusCode: 404 }); +} + +/** + * Fake Daytona sandbox that executes toolbox commands through the local + * /bin/sh and serves file uploads from the local filesystem, so the base64 + * command wrapper, tar seeding, and pinned fs mutations run for real. + */ +function createFakeSandbox(overrides?: Partial>) { + const sandbox: FakeSandbox = { + id: overrides?.id ?? `sbx-${randomBytes(6).toString("hex")}`, + name: "", + state: overrides?.state ?? "started", + snapshot: overrides?.snapshot, + start: vi.fn(async () => { + sandbox.state = "started"; + }), + refreshData: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + fs: { + uploadFile: vi.fn(async (source: Buffer | string, remotePath: string) => { + if (Buffer.isBuffer(source)) { + await fs.writeFile(remotePath, source); + return; + } + await fs.copyFile(source, remotePath); + }), + deleteFile: vi.fn(async (remotePath: string) => { + await fs.rm(remotePath, { force: true }); + }), + }, + process: { + createSession: vi.fn(async () => { + // The real toolbox refuses session creation on a stopped sandbox. + if (sandbox.state !== "started") { + throw new Error("sandbox is not running"); + } + }), + executeSessionCommand: vi.fn(async (_sessionId: string, request: { command: string }) => { + const result = spawnSync("/bin/sh", ["-c", request.command], { + maxBuffer: 64 * 1024 * 1024, + }); + return { + cmdId: `cmd-${randomBytes(4).toString("hex")}`, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + exitCode: result.status ?? 1, + }; + }), + deleteSession: vi.fn(async () => {}), + }, + }; + sandbox.name = `name-${sandbox.id}`; + return sandbox; +} + +function installFakeClient(params?: { + existing?: FakeSandbox[]; + created?: FakeSandbox; +}): FakeClient { + const existing = new Map((params?.existing ?? []).map((sandbox) => [sandbox.id, sandbox])); + const client: FakeClient = { + get: vi.fn(async (id: string) => { + const sandbox = existing.get(id); + if (!sandbox) { + throw notFoundError(); + } + return sandbox; + }), + create: vi.fn(async () => params?.created ?? createFakeSandbox()), + }; + clientMocks.createDaytonaClient.mockResolvedValue(client); + clientMocks.resolveDaytonaConnection.mockResolvedValue({ + apiKey: "test-api-key", + apiUrl: "https://api.daytona.test", + }); + return client; +} + +function createBackendSandboxConfig( + overrides?: Partial, +): CreateSandboxBackendParams["cfg"] { + return { + mode: "all", + backend: "daytona", + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + dockerTmpfsSource: "configured", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: [], + env: {}, + }, + ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"), + browser: createSandboxBrowserConfig(), + tools: { allow: ["*"], deny: [] }, + prune: createSandboxPruneConfig(), + ...overrides, + }; +} + +async function createTestSetup(params?: { + cfg?: Partial; + registeredRuntimeIds?: readonly string[]; + workspaceFiles?: Record; +}) { + const rootDir = await makeTempDir("openclaw-daytona-test-"); + const workspaceDir = path.join(rootDir, "local-workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + for (const [relative, content] of Object.entries(params?.workspaceFiles ?? {})) { + const filePath = path.join(workspaceDir, relative); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + } + const remoteWorkspaceDir = path.join(rootDir, "remote", "workspace"); + const remoteAgentWorkspaceDir = path.join(rootDir, "remote", "agent"); + const pluginConfig = resolveDaytonaPluginConfig({ + remoteWorkspaceDir, + remoteAgentWorkspaceDir, + }); + const createParams: CreateSandboxBackendParams = { + sessionKey: "agent:main:turn", + scopeKey: "agent:main", + ...(params?.registeredRuntimeIds ? { registeredRuntimeIds: params.registeredRuntimeIds } : {}), + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: createBackendSandboxConfig(params?.cfg), + }; + return { + rootDir, + workspaceDir, + remoteWorkspaceDir, + remoteAgentWorkspaceDir, + pluginConfig, + createParams, + }; +} + +function createFactory(pluginConfig: ResolvedDaytonaPluginConfig) { + return createDaytonaSandboxBackendFactory({ + pluginConfig, + hostConfig: {} as OpenClawConfig, + }); +} + +describe("daytona backend provisioning", () => { + it("rejects sandbox.docker.binds", async () => { + const setup = await createTestSetup({ + cfg: { + docker: { + ...createBackendSandboxConfig().docker, + binds: ["/host:/container"], + }, + }, + }); + installFakeClient(); + await expect(createFactory(setup.pluginConfig)(setup.createParams)).rejects.toThrow( + "does not support sandbox.docker.binds", + ); + }); + + it("creates a labeled sandbox and seeds the workspace through tar upload", async () => { + const setup = await createTestSetup({ + workspaceFiles: { "hello.txt": "hello daytona", "nested/data.txt": "nested" }, + }); + const created = createFakeSandbox(); + const client = installFakeClient({ created }); + + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + expect(client.create).toHaveBeenCalledWith( + { + snapshot: undefined, + labels: { + "openclaw.sandbox": "1", + "openclaw.scope": expect.stringMatching(/^[a-f0-9]{32}$/), + }, + user: undefined, + volumes: undefined, + autoStopInterval: undefined, + autoPauseInterval: undefined, + autoArchiveInterval: undefined, + autoDeleteInterval: undefined, + networkBlockAll: true, + networkAllowList: undefined, + domainAllowList: undefined, + }, + { timeout: 120 }, + ); + expect(handle.runtimeId).toBe(created.id); + expect(handle.runtimeLabel).toBe(created.name); + expect(handle.id).toBe("daytona"); + expect(handle.workdir).toBe(setup.remoteWorkspaceDir); + expect(handle.configLabel).toBe("default"); + expect(handle.configLabelKind).toBe("Snapshot"); + expect(handle.workdirValidation).toBe("backend"); + + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "hello.txt"), "utf8"), + ).resolves.toBe("hello daytona"); + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "nested", "data.txt"), "utf8"), + ).resolves.toBe("nested"); + }); + + it("serializes provisioning across factories for the same scope", async () => { + const setup = await createTestSetup({ workspaceFiles: { "seed.txt": "shared" } }); + const created = createFakeSandbox(); + const client = installFakeClient({ created }); + + const [first, second] = await Promise.all([ + createFactory(setup.pluginConfig)(setup.createParams), + createFactory(setup.pluginConfig)(setup.createParams), + ]); + + expect(client.create).toHaveBeenCalledTimes(1); + expect(first.runtimeId).toBe(created.id); + expect(second.runtimeId).toBe(created.id); + }); + + it("adopts a registered sandbox, skipping missing and unusable candidates", async () => { + const setup = await createTestSetup({ + registeredRuntimeIds: ["missing-id", "errored-id", "usable-id"], + }); + // The adopted sandbox already carries a seeded workspace root. + await fs.mkdir(setup.remoteWorkspaceDir, { recursive: true }); + const errored = createFakeSandbox({ id: "errored-id", state: "error" }); + const usable = createFakeSandbox({ id: "usable-id", state: "stopped" }); + const client = installFakeClient({ existing: [errored, usable] }); + + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + expect(handle.runtimeId).toBe("usable-id"); + expect(usable.start).toHaveBeenCalledTimes(1); + expect(client.create).not.toHaveBeenCalled(); + expect(usable.fs.uploadFile).not.toHaveBeenCalled(); + }); + + it("passes create-time sandbox settings through to Daytona", async () => { + const setup = await createTestSetup(); + const pluginConfig = resolveDaytonaPluginConfig({ + snapshot: "team-snap", + user: "runner", + volumes: [{ volumeId: "vol-1", mountPath: "/data/shared" }], + autoStopInterval: 0, + autoArchiveInterval: 240, + networkAllowList: "10.0.0.0/24", + domainAllowList: "registry.npmjs.org", + remoteWorkspaceDir: setup.remoteWorkspaceDir, + remoteAgentWorkspaceDir: setup.remoteAgentWorkspaceDir, + }); + const client = installFakeClient({ created: createFakeSandbox() }); + + const handle = await createFactory(pluginConfig)(setup.createParams); + + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + snapshot: "team-snap", + user: "runner", + volumes: [{ volumeId: "vol-1", mountPath: "/data/shared" }], + autoStopInterval: 0, + autoArchiveInterval: 240, + networkBlockAll: false, + networkAllowList: "10.0.0.0/24", + domainAllowList: "registry.npmjs.org", + }), + { timeout: 120 }, + ); + expect(handle.configLabel).toBe("team-snap"); + expect(handle.configLabelKind).toBe("Snapshot"); + }); + + it("creates image-based sandboxes with resources and a longer timeout floor", async () => { + const setup = await createTestSetup(); + const pluginConfig = resolveDaytonaPluginConfig({ + image: "python:3.13-slim", + resources: { cpu: 2, memory: 4, disk: 10 }, + remoteWorkspaceDir: setup.remoteWorkspaceDir, + remoteAgentWorkspaceDir: setup.remoteAgentWorkspaceDir, + }); + const client = installFakeClient({ created: createFakeSandbox() }); + + const handle = await createFactory(pluginConfig)(setup.createParams); + + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + image: "python:3.13-slim", + resources: { cpu: 2, memory: 4, disk: 10 }, + }), + { timeout: 600 }, + ); + expect(handle.configLabel).toBe("python:3.13-slim"); + expect(handle.configLabelKind).toBe("Image"); + }); + + it("removes the staged seed tar when the extract transport fails", async () => { + const setup = await createTestSetup({ workspaceFiles: { "seed.txt": "data" } }); + const created = createFakeSandbox(); + installFakeClient({ created }); + created.process.executeSessionCommand.mockRejectedValue(new Error("api 502")); + + await expect(createFactory(setup.pluginConfig)(setup.createParams)).rejects.toThrow("api 502"); + expect(created.delete).toHaveBeenCalledWith(120); + const deletedPaths = created.fs.deleteFile.mock.calls.map((call) => call[0] as string); + expect(deletedPaths.some((deletedPath) => deletedPath.startsWith("/tmp/openclaw-seed-"))).toBe( + true, + ); + }); + + it("refuses to seed workspaces containing symlinks that escape the tree", async () => { + const setup = await createTestSetup({ workspaceFiles: { "inside.txt": "data" } }); + await fs.symlink("/etc", path.join(setup.workspaceDir, "escape-link")); + const created = createFakeSandbox(); + installFakeClient({ created }); + + await expect(createFactory(setup.pluginConfig)(setup.createParams)).rejects.toThrow( + /refuses symlink escaping the workspace: escape-link/, + ); + expect(created.fs.uploadFile).not.toHaveBeenCalled(); + }); + + it("allows workspace-internal symlinks during seeding", async () => { + const setup = await createTestSetup({ workspaceFiles: { "inside.txt": "data" } }); + await fs.symlink( + path.join(setup.workspaceDir, "inside.txt"), + path.join(setup.workspaceDir, "internal-link"), + ); + installFakeClient({ created: createFakeSandbox() }); + + await createFactory(setup.pluginConfig)(setup.createParams); + + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "inside.txt"), "utf8"), + ).resolves.toBe("data"); + }); + + it("re-seeds an adopted sandbox whose workspace root is missing", async () => { + const setup = await createTestSetup({ + registeredRuntimeIds: ["reseed-id"], + workspaceFiles: { "seed.txt": "reseeded" }, + }); + const adopted = createFakeSandbox({ id: "reseed-id" }); + installFakeClient({ existing: [adopted] }); + + await createFactory(setup.pluginConfig)(setup.createParams); + + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "seed.txt"), "utf8"), + ).resolves.toBe("reseeded"); + }); +}); + +describe("daytona backend exec", () => { + it("builds a launcher exec spec with an owner-only payload file", async () => { + vi.stubEnv("OPENAI_API_KEY", "super-secret"); + vi.stubEnv("LANG", "en_US.UTF-8"); + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + const spec = await handle.buildExecSpec({ + command: "echo hello", + env: { OC_TEST: "1" }, + usePty: false, + }); + + expect(spec.argv[0]).toBe(process.execPath); + expect(spec.argv[1]).toMatch(/daytona-exec-launcher\.mjs$/); + expect(spec.argv[2]).toBe("--payload-file"); + expect(spec.stdinMode).toBe("pipe-open"); + expect(spec.env.OPENAI_API_KEY).toBeUndefined(); + expect(spec.env.LANG).toBe("en_US.UTF-8"); + + const payloadFile = spec.argv[3] ?? ""; + const stat = await fs.stat(payloadFile); + expect(stat.mode & 0o777).toBe(0o600); + const payload = JSON.parse(await fs.readFile(payloadFile, "utf8")) as Record; + expect(payload.apiKey).toBe("test-api-key"); + expect(payload.sandboxId).toBe(created.id); + expect(payload.usePty).toBe(false); + expect(payload.cwd).toBe(setup.remoteWorkspaceDir); + expect(payload.command).toContain("echo hello"); + expect(payload.command).toContain("cd "); + expect(payload.command).toContain(setup.remoteWorkspaceDir); + expect(payload.command).not.toContain("OC_TEST=1"); + expect(payload.env).toEqual({ OC_TEST: "1" }); + + await handle.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: spec.finalizeToken, + }); + await expect(fs.stat(payloadFile)).rejects.toThrow(); + }); + + it("rejects malformed commands before contacting Daytona", async () => { + const setup = await createTestSetup(); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + const executeCallsBefore = + (await clientMocks.createDaytonaClient.mock.results[0]?.value) !== undefined; + expect(executeCallsBefore).toBe(true); + + await expect( + handle.buildExecSpec({ command: "echo 'unterminated", env: {}, usePty: false }), + ).rejects.toThrow(); + }); +}); + +describe("daytona backend shell transport", () => { + it("separates stdout and stderr binary-safely and reports exit codes", async () => { + const setup = await createTestSetup(); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + const result = await handle.runShellCommand({ + script: `printf 'a\\000b'; printf 'oops' >&2; exit 5`, + allowFailure: true, + }); + + expect([...result.stdout]).toEqual([0x61, 0x00, 0x62]); + expect(result.stderr.toString("utf8")).toBe("oops"); + expect(result.code).toBe(5); + }); + + it("pipes stdin through the sandbox and forwards script args", async () => { + const setup = await createTestSetup(); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + const result = await handle.runShellCommand({ + script: `cat; printf ':%s' "$@"`, + args: ["first", "second arg"], + stdin: Buffer.from([0x00, 0x01, 0xff]), + }); + + expect([...result.stdout.subarray(0, 3)]).toEqual([0x00, 0x01, 0xff]); + expect(result.stdout.subarray(3).toString("utf8")).toBe(":first:second arg"); + expect(result.code).toBe(0); + }); + + it("rejects pre-aborted commands before contacting the sandbox", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + created.process.createSession.mockClear(); + created.process.executeSessionCommand.mockClear(); + + const controller = new AbortController(); + controller.abort(new Error("caller cancelled")); + await expect( + handle.runShellCommand({ script: "true", signal: controller.signal }), + ).rejects.toThrow("caller cancelled"); + expect(created.process.createSession).not.toHaveBeenCalled(); + expect(created.process.executeSessionCommand).not.toHaveBeenCalled(); + }); + + it("does not submit a command when aborted during stdin upload", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + created.process.executeSessionCommand.mockClear(); + let releaseUpload: (() => void) | undefined; + const pendingUpload = new Promise((resolve) => { + releaseUpload = resolve; + }); + created.fs.uploadFile.mockReturnValueOnce(pendingUpload); + + const controller = new AbortController(); + const pending = handle.runShellCommand({ + script: "cat", + stdin: Buffer.from("data"), + signal: controller.signal, + }); + await vi.waitFor(() => expect(releaseUpload).toBeTypeOf("function")); + controller.abort(new Error("caller cancelled during upload")); + releaseUpload?.(); + + await expect(pending).rejects.toThrow("caller cancelled during upload"); + expect(created.process.executeSessionCommand).not.toHaveBeenCalled(); + }); + + it("does not submit a command when aborted during session creation", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + created.process.executeSessionCommand.mockClear(); + let releaseSession: (() => void) | undefined; + const pendingSession = new Promise((resolve) => { + releaseSession = resolve; + }); + created.process.createSession.mockReturnValueOnce(pendingSession); + + const controller = new AbortController(); + const pending = handle.runShellCommand({ + script: "true", + signal: controller.signal, + }); + await vi.waitFor(() => expect(releaseSession).toBeTypeOf("function")); + controller.abort(new Error("caller cancelled during session creation")); + releaseSession?.(); + + await expect(pending).rejects.toThrow("caller cancelled during session creation"); + expect(created.process.executeSessionCommand).not.toHaveBeenCalled(); + }); + + it("kills the remote session before reporting an abort and scrubs staging", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + // The in-flight session command hangs; the abort fires only once the + // command is running so the staged stdin/out/err files already exist. + const controller = new AbortController(); + created.process.deleteSession.mockClear(); + created.process.executeSessionCommand.mockReturnValue(new Promise(() => {})); + + const pending = handle.runShellCommand({ + script: "cat", + stdin: Buffer.from("data"), + signal: controller.signal, + }); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(created.process.deleteSession).not.toHaveBeenCalled(); + controller.abort(new Error("caller cancelled")); + await expect(pending).rejects.toThrow("caller cancelled"); + // The rejection only travels through the abort path after the session + // delete settled, so the remote command is dead before callers observe + // the abort. + expect(created.process.deleteSession).toHaveBeenCalled(); + const deletedPaths = created.fs.deleteFile.mock.calls.map((call) => call[0] as string); + expect(deletedPaths.some((deletedPath) => deletedPath.startsWith("/tmp/openclaw-in-"))).toBe( + true, + ); + expect(deletedPaths.some((deletedPath) => deletedPath.startsWith("/tmp/openclaw-out-"))).toBe( + true, + ); + expect(deletedPaths.some((deletedPath) => deletedPath.startsWith("/tmp/openclaw-err-"))).toBe( + true, + ); + }); + + it("removes staged transport files when the toolbox call fails", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + created.process.executeSessionCommand.mockRejectedValue(new Error("api 502")); + + await expect( + handle.runShellCommand({ script: "cat", stdin: Buffer.from("data") }), + ).rejects.toThrow("api 502"); + const deletedPaths = created.fs.deleteFile.mock.calls.map((call) => call[0] as string); + expect(deletedPaths.some((deletedPath) => deletedPath.startsWith("/tmp/openclaw-in-"))).toBe( + true, + ); + }); + + it("restarts an auto-stopped sandbox on the next filesystem operation", async () => { + const setup = await createTestSetup(); + const created = createFakeSandbox(); + installFakeClient({ created }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + created.start.mockClear(); + // Simulate the Daytona idle auto-stop between two tool calls. + created.state = "stopped"; + + const result = await handle.runShellCommand({ script: "printf restarted" }); + + expect(created.start).toHaveBeenCalledTimes(1); + expect(result.stdout.toString("utf8")).toBe("restarted"); + }); + + it("throws stderr text for failed commands unless allowFailure is set", async () => { + const setup = await createTestSetup(); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + + await expect(handle.runShellCommand({ script: `printf 'boom' >&2; exit 3` })).rejects.toThrow( + "boom", + ); + }); + + it("validates workdirs against managed remote roots", async () => { + const setup = await createTestSetup(); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + const nestedDir = path.join(setup.remoteWorkspaceDir, "nested"); + await fs.mkdir(nestedDir, { recursive: true }); + + await expect(handle.validateWorkdir?.(nestedDir)).resolves.toBe(nestedDir); + await expect( + handle.validateWorkdir?.(path.join(setup.remoteWorkspaceDir, "missing")), + ).resolves.toBeNull(); + await expect(handle.validateWorkdir?.("/etc")).resolves.toBeNull(); + }); +}); + +describe("daytona fs bridge", () => { + async function createBridgeSetup() { + const setup = await createTestSetup({ workspaceFiles: { "existing.txt": "seeded" } }); + installFakeClient({ created: createFakeSandbox() }); + const handle = await createFactory(setup.pluginConfig)(setup.createParams); + const context: SandboxFsBridgeContext = { + workspaceDir: setup.workspaceDir, + agentWorkspaceDir: setup.workspaceDir, + workspaceAccess: "rw", + containerName: handle.runtimeId, + containerWorkdir: setup.remoteWorkspaceDir, + docker: {}, + backend: { runShellCommand: (params) => handle.runShellCommand(params) }, + }; + const bridge = handle.createFsBridge?.({ sandbox: context }); + if (!bridge) { + throw new Error("daytona backend must provide an fs bridge"); + } + return { setup, bridge }; + } + + it("writes, reads, renames, and removes files in the remote workspace", async () => { + const { setup, bridge } = await createBridgeSetup(); + + await bridge.writeFile({ filePath: "notes/todo.txt", data: "remember", mkdir: true }); + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "notes", "todo.txt"), "utf8"), + ).resolves.toBe("remember"); + + const read = await bridge.readFile({ filePath: "notes/todo.txt" }); + expect(read.toString("utf8")).toBe("remember"); + + await bridge.rename({ from: "notes/todo.txt", to: "notes/done.txt" }); + await expect( + fs.readFile(path.join(setup.remoteWorkspaceDir, "notes", "done.txt"), "utf8"), + ).resolves.toBe("remember"); + await expect( + fs.stat(path.join(setup.remoteWorkspaceDir, "notes", "todo.txt")), + ).rejects.toThrow(); + + await bridge.remove({ filePath: "notes", recursive: true }); + await expect(fs.stat(path.join(setup.remoteWorkspaceDir, "notes"))).rejects.toThrow(); + }); + + // The bridge stat op shells out to GNU `stat -c`; sandbox images are Linux, + // while macOS dev machines carry BSD stat, so this proof runs on Linux CI. + it.runIf(process.platform === "linux")("stats files through the remote transport", async () => { + const { bridge } = await createBridgeSetup(); + await bridge.writeFile({ filePath: "stat-me.txt", data: "12345678" }); + const stat = await bridge.stat({ filePath: "stat-me.txt" }); + expect(stat).toMatchObject({ type: "file", size: 8 }); + await expect(bridge.stat({ filePath: "missing.txt" })).resolves.toBeNull(); + }); + + it("enforces read limits", async () => { + const { bridge } = await createBridgeSetup(); + await bridge.writeFile({ filePath: "big.txt", data: "0123456789" }); + await expect(bridge.readFile({ filePath: "big.txt", maxBytes: 4 })).rejects.toThrow(); + await expect(bridge.readFile({ filePath: "big.txt", maxBytes: 10 })).resolves.toBeDefined(); + }); + + it("rejects paths escaping the managed mounts", async () => { + const { bridge } = await createBridgeSetup(); + await expect(bridge.readFile({ filePath: "/etc/passwd" })).rejects.toThrow( + /escapes allowed mounts/, + ); + }); +}); + +describe("daytona backend manager", () => { + it("describes runtimes from live sandbox state", async () => { + const sandbox = createFakeSandbox({ id: "sbx-desc", snapshot: "custom-snap" }); + installFakeClient({ existing: [sandbox] }); + const manager = createDaytonaSandboxBackendManager({ + pluginConfig: resolveDaytonaPluginConfig(undefined), + hostConfig: {} as OpenClawConfig, + }); + + const entry = { + containerName: "sbx-desc", + sessionKey: "agent:main", + createdAtMs: 0, + lastUsedAtMs: 0, + image: "default", + }; + await expect(manager.describeRuntime({ entry, config: {} as OpenClawConfig })).resolves.toEqual( + { + running: true, + actualConfigLabel: "custom-snap", + configLabelMatch: true, + }, + ); + + sandbox.state = "stopped"; + await expect( + manager.describeRuntime({ + entry: { ...entry, image: "other-snapshot" }, + config: {} as OpenClawConfig, + }), + ).resolves.toEqual({ + running: false, + actualConfigLabel: "custom-snap", + configLabelMatch: false, + }); + }); + + it("treats missing sandboxes as not running and removes idempotently", async () => { + const sandbox = createFakeSandbox({ id: "sbx-remove" }); + installFakeClient({ existing: [sandbox] }); + const manager = createDaytonaSandboxBackendManager({ + pluginConfig: resolveDaytonaPluginConfig(undefined), + hostConfig: {} as OpenClawConfig, + }); + const missingEntry = { + containerName: "gone", + sessionKey: "agent:main", + createdAtMs: 0, + lastUsedAtMs: 0, + image: "default", + }; + + await expect( + manager.describeRuntime({ entry: missingEntry, config: {} as OpenClawConfig }), + ).resolves.toEqual({ running: false, configLabelMatch: true }); + await expect( + manager.removeRuntime({ entry: missingEntry, config: {} as OpenClawConfig }), + ).resolves.toBeUndefined(); + + await manager.removeRuntime({ + entry: { ...missingEntry, containerName: "sbx-remove" }, + config: {} as OpenClawConfig, + }); + expect(sandbox.delete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/daytona/src/backend.ts b/extensions/daytona/src/backend.ts new file mode 100644 index 000000000000..d2c80c138461 --- /dev/null +++ b/extensions/daytona/src/backend.ts @@ -0,0 +1,742 @@ +// Daytona sandbox backend: sandbox lifecycle, exec spec building, and remote shell transport. +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + buildRemoteCommand, + buildRemoteWorkdirValidationCommand, + buildValidatedExecRemoteCommand, + createRemoteShellSandboxFsBridge, + resolvePreferredOpenClawTmpDir, + sanitizeEnvVars, + type CreateSandboxBackendParams, + type OpenClawConfig, + type RemoteShellSandboxHandle, + type SandboxBackendCommandParams, + type SandboxBackendCommandResult, + type SandboxBackendFactory, + type SandboxBackendHandle, + type SandboxBackendManager, + type SandboxBackendRuntimeInfo, +} from "openclaw/plugin-sdk/sandbox"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + createDaytonaClient, + isDaytonaNotFoundError, + resolveDaytonaConnection, + withDaytonaRetry, + type Daytona, + type Sandbox, +} from "./client.js"; +import { resolveDaytonaPluginConfig, type ResolvedDaytonaPluginConfig } from "./config.js"; +import { resolveDaytonaLauncherPath } from "./launcher-path.js"; +import { uploadDirectoryToDaytonaSandbox } from "./upload.js"; + +type DaytonaExecLauncherPayload = { + apiKey: string; + apiUrl?: string; + target?: string; + sandboxId: string; + command: string; + cwd: string; + env: Record; + usePty: boolean; +}; + +type PendingDaytonaExec = { + payloadDir: string; +}; + +// Snapshot label shown when the sandbox uses the Daytona org default snapshot. +const DEFAULT_SNAPSHOT_LABEL = "default"; + +// Image-based creates pull or build the image before the sandbox starts, so +// they get a higher timeout floor than snapshot creates. +const IMAGE_CREATE_TIMEOUT_FLOOR_SECONDS = 600; + +function resolveConfiguredBaseLabel(pluginConfig: ResolvedDaytonaPluginConfig): string { + return pluginConfig.snapshot ?? pluginConfig.image ?? DEFAULT_SNAPSHOT_LABEL; +} + +// Sandboxes in these states cannot be started again; adoption skips them so a +// fresh sandbox replaces the retired runtime id in the registry. Stopped, +// archived, paused, transitional, and unknown states stay adoptable on +// purpose: start() either recovers them or fails loudly, while skipping +// unknown states would silently mint a new sandbox per run on SDK/server +// version skew. +const UNUSABLE_SANDBOX_STATES = new Set(["destroyed", "destroying", "error", "build_failed"]); + +// Seeded sandbox ids for this process. Skips one remote existence probe per +// factory call; a fresh process re-probes, so stale entries only cost a probe. +const seededDaytonaSandboxes = new Set(); + +// Factories for the same scope can be constructed concurrently from the same +// empty registry snapshot. Share provisioning across implementations so only +// one remote sandbox is created before core records the returned runtime id. +const daytonaProvisioningByScope = new Map>(); + +function hashScopeKey(scopeKey: string): string { + return createHash("sha256").update(scopeKey).digest("hex").slice(0, 32); +} + +function isRemotePathInsideRoot(root: string, candidate: string): boolean { + const normalizedRoot = path.posix.normalize(root).replace(/\/+$/, "") || "/"; + const normalizedCandidate = path.posix.normalize(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +async function isExistingDirectory(candidate: string): Promise { + try { + return (await fs.stat(candidate)).isDirectory(); + } catch { + return false; + } +} + +export function createDaytonaSandboxBackendFactory(params: { + pluginConfig: ResolvedDaytonaPluginConfig; + hostConfig: OpenClawConfig; +}): SandboxBackendFactory { + return async (createParams) => + await createDaytonaSandboxBackend({ + pluginConfig: params.pluginConfig, + hostConfig: params.hostConfig, + createParams, + }); +} + +async function createDaytonaSandboxBackend(params: { + pluginConfig: ResolvedDaytonaPluginConfig; + hostConfig: OpenClawConfig; + createParams: CreateSandboxBackendParams; +}): Promise { + if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("Daytona sandbox backend does not support sandbox.docker.binds."); + } + const impl = new DaytonaSandboxBackendImpl(params); + // The Daytona sandbox id is the runtime id, so the factory must resolve or + // create the sandbox before it can hand out a handle (docker does the same). + await impl.ensureSandbox(); + return impl.asHandle(); +} + +class DaytonaSandboxBackendImpl { + private ensurePromise: Promise | null = null; + private ensuredSandbox: Sandbox | null = null; + private client: Daytona | null = null; + private refreshedSkillsForNextExecWorkdir: string | null = null; + + constructor( + private readonly params: { + pluginConfig: ResolvedDaytonaPluginConfig; + hostConfig: OpenClawConfig; + createParams: CreateSandboxBackendParams; + }, + ) {} + + private get pluginConfig(): ResolvedDaytonaPluginConfig { + return this.params.pluginConfig; + } + + private get remoteSkillsWorkspaceDir(): string { + return path.posix.join(this.pluginConfig.remoteWorkspaceDir, ".openclaw", "sandbox-skills"); + } + + private get timeoutSeconds(): number { + return Math.max(1, Math.ceil(this.pluginConfig.timeoutMs / 1000)); + } + + asHandle(): SandboxBackendHandle & RemoteShellSandboxHandle { + const sandbox = this.requireSandbox(); + return { + id: "daytona", + runtimeId: sandbox.id, + runtimeLabel: sandbox.name || sandbox.id, + workdir: this.pluginConfig.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: resolveConfiguredBaseLabel(this.pluginConfig), + configLabelKind: this.pluginConfig.image ? "Image" : "Snapshot", + workdirValidation: "backend", + validateWorkdir: async (workdir) => await this.validateWorkdir(workdir), + discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir), + workdirRoots: [ + this.pluginConfig.remoteWorkspaceDir, + this.pluginConfig.remoteAgentWorkspaceDir, + ], + remoteWorkspaceDir: this.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.pluginConfig.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const remoteWorkdir = workdir ?? this.pluginConfig.remoteWorkspaceDir; + const remoteCommand = buildValidatedExecRemoteCommand({ + command, + workdir: remoteWorkdir, + env: {}, + }); + const ensured = await this.ensureSandbox(); + if (!this.consumeRefreshedSkillsForNextExec(remoteWorkdir)) { + await this.refreshRemoteSkillsWorkspace(); + } + const connection = await resolveDaytonaConnection({ + config: this.params.hostConfig, + pluginConfig: this.pluginConfig, + }); + const payload: DaytonaExecLauncherPayload = { + apiKey: connection.apiKey, + apiUrl: connection.apiUrl, + target: connection.target, + sandboxId: ensured.id, + command: remoteCommand, + cwd: remoteWorkdir, + env, + usePty, + }; + const payloadDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-daytona-"), + ); + const payloadFile = path.join(payloadDir, "payload.json"); + // The payload carries the API key: owner-only fresh file, deleted by + // the launcher on read and by finalizeExec when the spawn never ran. + await fs.writeFile(payloadFile, JSON.stringify(payload), { flag: "wx", mode: 0o600 }); + return { + argv: [process.execPath, resolveDaytonaLauncherPath(), "--payload-file", payloadFile], + env: sanitizeEnvVars(process.env).allowed, + stdinMode: "pipe-open", + finalizeToken: { payloadDir } satisfies PendingDaytonaExec, + }; + }, + finalizeExec: async ({ token }) => { + const payloadDir = isRecord(token) ? token.payloadDir : undefined; + if (typeof payloadDir === "string") { + await fs.rm(payloadDir, { recursive: true, force: true }); + } + }, + runShellCommand: async (command) => await this.runRemoteShellScript(command), + createFsBridge: ({ sandbox: sandboxContext }) => + createRemoteShellSandboxFsBridge({ + sandbox: sandboxContext, + runtime: this.asHandle(), + }), + runRemoteShellScript: async (command) => await this.runRemoteShellScript(command), + }; + } + + private requireSandbox(): Sandbox { + if (!this.ensuredSandbox) { + throw new Error("Daytona sandbox runtime is not provisioned yet."); + } + return this.ensuredSandbox; + } + + async ensureSandbox(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + const scopeKey = this.params.createParams.scopeKey; + // Concurrent exec/fs calls and separate factory implementations share one + // provisioning attempt. Failures reset both owners for a later retry. + const pending = daytonaProvisioningByScope.get(scopeKey) ?? this.ensureSandboxInner(); + this.ensurePromise = pending; + daytonaProvisioningByScope.set(scopeKey, pending); + try { + const sandbox = await pending; + this.ensuredSandbox = sandbox; + return sandbox; + } catch (error) { + this.ensurePromise = null; + throw error; + } finally { + if (daytonaProvisioningByScope.get(scopeKey) === pending) { + daytonaProvisioningByScope.delete(scopeKey); + } + } + } + + private async getClient(): Promise { + if (this.client) { + return this.client; + } + const connection = await resolveDaytonaConnection({ + config: this.params.hostConfig, + pluginConfig: this.pluginConfig, + }); + this.client = await createDaytonaClient(connection); + return this.client; + } + + private async ensureSandboxInner(): Promise { + const client = await this.getClient(); + const adopted = await this.adoptRegisteredSandbox(client); + if (adopted) { + await this.startSandboxIfNeeded(adopted); + if (!seededDaytonaSandboxes.has(adopted.id)) { + await this.seedWorkspaceIfMissing(adopted); + seededDaytonaSandboxes.add(adopted.id); + } + return adopted; + } + const baseParams = { + labels: { + "openclaw.sandbox": "1", + "openclaw.scope": hashScopeKey(this.params.createParams.scopeKey), + }, + user: this.pluginConfig.user, + volumes: this.pluginConfig.volumes, + autoStopInterval: this.pluginConfig.autoStopInterval, + autoPauseInterval: this.pluginConfig.autoPauseInterval, + autoArchiveInterval: this.pluginConfig.autoArchiveInterval, + autoDeleteInterval: this.pluginConfig.autoDeleteInterval, + networkBlockAll: this.pluginConfig.networkBlockAll, + networkAllowList: this.pluginConfig.networkAllowList, + domainAllowList: this.pluginConfig.domainAllowList, + }; + // Config resolution rejects snapshot+image together, so this branch picks + // the create overload rather than encoding a precedence policy. + const sandbox = this.pluginConfig.image + ? await client.create( + { + ...baseParams, + image: this.pluginConfig.image, + resources: this.pluginConfig.resources, + }, + { timeout: Math.max(this.timeoutSeconds, IMAGE_CREATE_TIMEOUT_FLOOR_SECONDS) }, + ) + : await client.create( + { ...baseParams, snapshot: this.pluginConfig.snapshot }, + { timeout: this.timeoutSeconds }, + ); + try { + await this.seedWorkspace(sandbox); + } catch (error) { + // Core cannot register a handle until seeding succeeds, so a newly + // created runtime must be deleted here or it becomes undiscoverable. + await sandbox.delete(this.timeoutSeconds).catch(() => {}); + throw error; + } + seededDaytonaSandboxes.add(sandbox.id); + return sandbox; + } + + private async adoptRegisteredSandbox(client: Daytona): Promise { + for (const runtimeId of this.params.createParams.registeredRuntimeIds ?? []) { + let sandbox: Sandbox; + try { + sandbox = await withDaytonaRetry("daytona get", () => client.get(runtimeId)); + } catch (error) { + if (isDaytonaNotFoundError(error)) { + continue; + } + throw error; + } + if (sandbox.state && UNUSABLE_SANDBOX_STATES.has(sandbox.state)) { + continue; + } + return sandbox; + } + return null; + } + + private async startSandboxIfNeeded(sandbox: Sandbox): Promise { + if (sandbox.state === "started") { + return; + } + try { + await sandbox.start(this.timeoutSeconds); + } catch (error) { + // start() races sandbox auto-start and concurrent adopters; a sandbox + // that reports started after the failure is usable. + await sandbox.refreshData().catch(() => {}); + const refreshedState: string | undefined = sandbox.state; + if (refreshedState !== "started") { + throw error; + } + } + } + + private async seedWorkspaceIfMissing(sandbox: Sandbox): Promise { + const probe = await this.runWrappedRemoteCommand( + sandbox, + buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + this.pluginConfig.remoteWorkspaceDir, + ]), + {}, + ); + if (probe.stdout.toString("utf8").trim() === "1") { + return; + } + await this.seedWorkspace(sandbox); + } + + private async seedWorkspace(sandbox: Sandbox): Promise { + await this.uploadDirectory( + sandbox, + this.params.createParams.workspaceDir, + this.pluginConfig.remoteWorkspaceDir, + ); + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.uploadDirectory( + sandbox, + this.params.createParams.agentWorkspaceDir, + this.pluginConfig.remoteAgentWorkspaceDir, + ); + } + } + + private async uploadDirectory( + sandbox: Sandbox, + localDir: string, + remoteDir: string, + ): Promise { + await uploadDirectoryToDaytonaSandbox({ + sandbox, + localDir, + remoteDir, + timeoutMs: this.pluginConfig.timeoutMs, + runRemoteShellScript: async ({ script, args }) => + await this.runWrappedRemoteCommand( + sandbox, + buildRemoteCommand(["/bin/sh", "-c", script, "openclaw-sandbox-upload", ...(args ?? [])]), + {}, + ), + runRemoteOperation: async (run) => await this.withStartedSandbox(sandbox, run), + }); + } + + private async validateWorkdir(workdir: string): Promise { + const sandbox = await this.ensureSandbox(); + let refreshedSkillsForWorkdir: string | null = null; + try { + if (isRemotePathInsideRoot(this.remoteSkillsWorkspaceDir, workdir)) { + await this.refreshRemoteSkillsWorkspace(); + refreshedSkillsForWorkdir = workdir; + this.refreshedSkillsForNextExecWorkdir = workdir; + } + const result = await this.runWrappedRemoteCommand( + sandbox, + buildRemoteWorkdirValidationCommand({ + workdir, + root: this.resolveWorkdirValidationRoot(workdir), + }), + { allowFailure: true }, + ); + const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : ""; + if (refreshedSkillsForWorkdir) { + this.refreshedSkillsForNextExecWorkdir = resolvedWorkdir || null; + } + return resolvedWorkdir || null; + } catch (error) { + if ( + refreshedSkillsForWorkdir && + this.refreshedSkillsForNextExecWorkdir === refreshedSkillsForWorkdir + ) { + this.refreshedSkillsForNextExecWorkdir = null; + } + throw error; + } + } + + private discardPreparedWorkdir(workdir: string): void { + if (this.refreshedSkillsForNextExecWorkdir === workdir) { + this.refreshedSkillsForNextExecWorkdir = null; + } + } + + private consumeRefreshedSkillsForNextExec(workdir: string): boolean { + if (this.refreshedSkillsForNextExecWorkdir !== workdir) { + this.refreshedSkillsForNextExecWorkdir = null; + return false; + } + this.refreshedSkillsForNextExecWorkdir = null; + return true; + } + + private resolveWorkdirValidationRoot(workdir: string): string { + const roots = [this.pluginConfig.remoteAgentWorkspaceDir, this.pluginConfig.remoteWorkspaceDir]; + return ( + roots.find((root) => isRemotePathInsideRoot(root, workdir)) ?? + this.pluginConfig.remoteWorkspaceDir + ); + } + + private async refreshRemoteSkillsWorkspace(): Promise { + if ( + this.params.createParams.cfg.workspaceAccess !== "rw" || + !this.params.createParams.skillsWorkspaceDir + ) { + return; + } + const sandbox = await this.ensureSandbox(); + await this.runWrappedRemoteCommand( + sandbox, + buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + "openclaw-sandbox-clear", + this.remoteSkillsWorkspaceDir, + ]), + {}, + ); + if (!(await isExistingDirectory(this.params.createParams.skillsWorkspaceDir))) { + return; + } + await this.uploadDirectory( + sandbox, + this.params.createParams.skillsWorkspaceDir, + this.remoteSkillsWorkspaceDir, + ); + } + + private async runRemoteShellScript( + command: SandboxBackendCommandParams, + ): Promise { + const sandbox = await this.ensureSandbox(); + await this.refreshRemoteSkillsWorkspace(); + return await this.runWrappedRemoteCommand( + sandbox, + buildRemoteCommand([ + "/bin/sh", + "-c", + command.script, + "openclaw-sandbox-fs", + ...(command.args ?? []), + ]), + { + stdin: command.stdin, + allowFailure: command.allowFailure, + signal: command.signal, + }, + ); + } + + /** + * Run a shell command through a per-call Daytona session with separated, + * binary-safe streams. The session transport exists for cancellation: + * deleting the session kills the running remote command, so an abort stops + * the mutation before the caller is told it stopped. Session output is not + * binary-safe, so the command redirects both streams to files and emits + * them base64-encoded on stdout. + */ + private async runWrappedRemoteCommand( + sandbox: Sandbox, + rawCommand: string, + options: { stdin?: Buffer | string; allowFailure?: boolean; signal?: AbortSignal }, + ): Promise { + options.signal?.throwIfAborted(); + const token = randomBytes(8).toString("hex"); + const stdinPath = options.stdin === undefined ? null : `/tmp/openclaw-in-${token}`; + const outPath = `/tmp/openclaw-out-${token}`; + const errPath = `/tmp/openclaw-err-${token}`; + const stagedPaths = stdinPath ? [stdinPath, outPath, errPath] : [outPath, errPath]; + const separator = `__openclaw-daytona-${token}__`; + const wrapped = [ + `{ ${rawCommand}${stdinPath ? ` < ${stdinPath}` : ""} ; } > ${outPath} 2> ${errPath}`, + "oc_ec=$?", + `base64 < ${outPath}`, + `printf '%s' '${separator}'`, + `base64 < ${errPath}`, + `rm -f ${outPath} ${errPath}${stdinPath ? ` ${stdinPath}` : ""}`, + // Subshell exit reports the command status without killing the session + // shell; a top-level exit hangs the synchronous session response. + "( exit $oc_ec )", + ].join("; "); + const sessionId = `openclaw-fs-${token}`; + let response: { stdout?: string; exitCode?: number | null }; + try { + if (stdinPath) { + const data = + typeof options.stdin === "string" ? Buffer.from(options.stdin, "utf8") : options.stdin; + await this.withStartedSandbox(sandbox, () => + sandbox.fs.uploadFile(data ?? Buffer.alloc(0), stdinPath, this.timeoutSeconds), + ); + options.signal?.throwIfAborted(); + } + await this.withStartedSandbox(sandbox, () => sandbox.process.createSession(sessionId)); + response = await this.runCancellableSessionCommand( + sandbox, + sessionId, + wrapped, + options.signal, + ); + } catch (error) { + // Deleting the session terminates a still-running remote command, and + // the sandbox persists per scope, so staged transport files must not + // outlive a failed or aborted operation. Missing files are ignored. + await sandbox.process.deleteSession(sessionId).catch(() => {}); + await this.removeRemoteStagingFiles(sandbox, stagedPaths); + throw error; + } + // Per-call sessions are single use; release the daemon-side shell. + await sandbox.process.deleteSession(sessionId).catch(() => {}); + const merged = response.stdout ?? ""; + const separatorIndex = merged.indexOf(separator); + if (separatorIndex < 0) { + throw new Error( + `Daytona sandbox command transport produced unexpected output: ${merged.slice(0, 200)}`, + ); + } + const stdout = Buffer.from(merged.slice(0, separatorIndex), "base64"); + const stderr = Buffer.from(merged.slice(separatorIndex + separator.length), "base64"); + const code = response.exitCode ?? 1; + if (code !== 0 && !options.allowFailure) { + throw new Error( + stderr.toString("utf8").trim() || `Daytona sandbox command failed with exit code ${code}`, + ); + } + return { stdout, stderr, code }; + } + + /** + * Execute one session command synchronously; on abort, kill the remote + * command by deleting its session and only then report the abort, so a + * cancelled mutation cannot keep changing sandbox state after rejection. + */ + private async runCancellableSessionCommand( + sandbox: Sandbox, + sessionId: string, + command: string, + signal?: AbortSignal, + ): Promise<{ stdout?: string; exitCode?: number | null }> { + signal?.throwIfAborted(); + const execution = sandbox.process.executeSessionCommand( + sessionId, + { command, runAsync: false, suppressInputEcho: true }, + this.timeoutSeconds, + ); + if (!signal) { + return await execution; + } + let removeAbortListener: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + const onAbort = () => { + void sandbox.process + .deleteSession(sessionId) + .catch(() => {}) + .then(() => { + reject( + signal.reason instanceof Error + ? signal.reason + : new Error("Daytona sandbox command aborted"), + ); + }); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", onAbort); + }); + try { + return await Promise.race([execution, aborted]); + } finally { + removeAbortListener?.(); + // Silence rejections from the losing branch after settle. + execution.catch(() => {}); + } + } + + /** + * Daytona auto-stops idle sandboxes, and a cached handle can outlive that. + * First-touch failures get one refresh-start-retry so a sandbox stopped + * between tool calls restarts on next use, matching the documented model. + */ + private async withStartedSandbox(sandbox: Sandbox, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (isDaytonaNotFoundError(error)) { + throw error; + } + await sandbox.refreshData().catch(() => {}); + const state: string | undefined = sandbox.state; + if (state === "started" || (state && UNUSABLE_SANDBOX_STATES.has(state))) { + throw error; + } + await sandbox.start(this.timeoutSeconds); + return await run(); + } + } + + private async removeRemoteStagingFiles(sandbox: Sandbox, stagedPaths: string[]): Promise { + for (const stagedPath of stagedPaths) { + await sandbox.fs.deleteFile(stagedPath).catch(() => {}); + } + } +} + +function resolveDaytonaPluginConfigFromConfig( + config: OpenClawConfig, + fallback: ResolvedDaytonaPluginConfig, +): ResolvedDaytonaPluginConfig { + const raw = config.plugins?.entries?.daytona?.config; + if (raw === undefined) { + return fallback; + } + try { + return resolveDaytonaPluginConfig(raw); + } catch { + return fallback; + } +} + +export function createDaytonaSandboxBackendManager(params: { + pluginConfig: ResolvedDaytonaPluginConfig; + hostConfig: OpenClawConfig; +}): SandboxBackendManager { + const getSandboxForEntry = async (config: OpenClawConfig, containerName: string) => { + const pluginConfig = resolveDaytonaPluginConfigFromConfig(config, params.pluginConfig); + const connection = await resolveDaytonaConnection({ config, pluginConfig }); + const client = await createDaytonaClient(connection); + return { + pluginConfig, + sandbox: await withDaytonaRetry("daytona get", () => client.get(containerName)), + }; + }; + return { + async describeRuntime({ entry, config }): Promise { + const pluginConfig = resolveDaytonaPluginConfigFromConfig(config, params.pluginConfig); + const configuredLabel = resolveConfiguredBaseLabel(pluginConfig); + try { + const { sandbox } = await getSandboxForEntry(config, entry.containerName); + return { + running: sandbox.state === "started", + actualConfigLabel: sandbox.snapshot ?? DEFAULT_SNAPSHOT_LABEL, + configLabelMatch: entry.image === configuredLabel, + }; + } catch (error) { + if (isDaytonaNotFoundError(error)) { + return { running: false, configLabelMatch: entry.image === configuredLabel }; + } + throw error; + } + }, + async removeRuntime({ entry, config }): Promise { + let sandbox: Sandbox; + try { + ({ sandbox } = await getSandboxForEntry(config, entry.containerName)); + } catch (error) { + if (isDaytonaNotFoundError(error)) { + return; + } + throw error; + } + const timeoutSeconds = Math.max( + 1, + Math.ceil( + resolveDaytonaPluginConfigFromConfig(config, params.pluginConfig).timeoutMs / 1000, + ), + ); + await withDaytonaRetry("daytona delete", () => sandbox.delete(timeoutSeconds)); + }, + }; +} diff --git a/extensions/daytona/src/client.ts b/extensions/daytona/src/client.ts new file mode 100644 index 000000000000..aa5ae5dd77ea --- /dev/null +++ b/extensions/daytona/src/client.ts @@ -0,0 +1,109 @@ +// Daytona API client construction, credential resolution, and transient-error retry. +import type { Daytona, Sandbox } from "@daytona/sdk"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/sandbox"; +import { resolveConfiguredSecretInputWithFallback } from "openclaw/plugin-sdk/secret-input-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { ResolvedDaytonaPluginConfig } from "./config.js"; + +export type { Daytona, Sandbox }; + +type DaytonaConnection = { + apiKey: string; + apiUrl?: string; + target?: string; +}; + +const DAYTONA_API_KEY_PATH = "plugins.entries.daytona.config.apiKey"; + +/** Resolve the Daytona connection settings from plugin config with env fallbacks. */ +export async function resolveDaytonaConnection(params: { + config: OpenClawConfig; + pluginConfig: ResolvedDaytonaPluginConfig; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; + const resolved = await resolveConfiguredSecretInputWithFallback({ + config: params.config, + env, + value: params.pluginConfig.apiKey, + path: DAYTONA_API_KEY_PATH, + readFallback: () => env.DAYTONA_API_KEY, + }); + if (!resolved.value) { + throw new Error( + [ + "Daytona sandbox backend needs an API key.", + resolved.unresolvedRefReason ?? + `Set ${DAYTONA_API_KEY_PATH} or export DAYTONA_API_KEY in the Gateway environment.`, + ].join(" "), + ); + } + return { + apiKey: resolved.value, + apiUrl: params.pluginConfig.apiUrl ?? (env.DAYTONA_API_URL?.trim() || undefined), + target: params.pluginConfig.target ?? (env.DAYTONA_TARGET?.trim() || undefined), + }; +} + +type DaytonaSdkModule = typeof import("@daytona/sdk"); + +let daytonaSdkModule: Promise | undefined; + +// The Daytona SDK pulls a large HTTP/client dependency tree, so it stays a +// lazy import to keep plugin registration and discovery loads light. +async function loadDaytonaSdk(): Promise { + daytonaSdkModule ??= import("@daytona/sdk"); + return await daytonaSdkModule; +} + +export async function createDaytonaClient(connection: DaytonaConnection): Promise { + const sdk = await loadDaytonaSdk(); + return new sdk.Daytona({ + apiKey: connection.apiKey, + apiUrl: connection.apiUrl, + target: connection.target, + }); +} + +function readDaytonaStatusCode(error: unknown): number | undefined { + if (!isRecord(error)) { + return undefined; + } + const statusCode = error.statusCode; + return typeof statusCode === "number" ? statusCode : undefined; +} + +/** True when a Daytona API error means the sandbox or resource does not exist. */ +export function isDaytonaNotFoundError(error: unknown): boolean { + return readDaytonaStatusCode(error) === 404; +} + +function isTransientDaytonaError(error: unknown): boolean { + const statusCode = readDaytonaStatusCode(error); + if (statusCode === 502 || statusCode === 503 || statusCode === 504) { + return true; + } + const code = isRecord(error) ? error.code : undefined; + return code === "ECONNRESET" || code === "ETIMEDOUT" || code === "EAI_AGAIN"; +} + +const TRANSIENT_RETRY_DELAYS_MS = [300, 900]; + +/** Retry short idempotent Daytona control-plane calls across transient API failures. */ +export async function withDaytonaRetry(label: string, run: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) { + try { + return await run(); + } catch (error) { + lastError = error; + if (!isTransientDaytonaError(error) || attempt === TRANSIENT_RETRY_DELAYS_MS.length) { + throw error; + } + await new Promise((resolve) => { + setTimeout(resolve, TRANSIENT_RETRY_DELAYS_MS[attempt]); + }); + } + } + throw lastError instanceof Error ? lastError : new Error(`${label} failed`); +} diff --git a/extensions/daytona/src/config.test.ts b/extensions/daytona/src/config.test.ts new file mode 100644 index 000000000000..e78380ca9a5d --- /dev/null +++ b/extensions/daytona/src/config.test.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createDaytonaPluginConfigSchema, resolveDaytonaPluginConfig } from "./config.js"; + +describe("resolveDaytonaPluginConfig", () => { + it("returns defaults when config is missing", () => { + expect(resolveDaytonaPluginConfig(undefined)).toEqual({ + networkBlockAll: true, + remoteWorkspaceDir: "/home/daytona/workspace", + remoteAgentWorkspaceDir: "/home/daytona/agent", + timeoutMs: 120_000, + }); + }); + + it.each([ + ["denies egress by default", {}, true], + [ + "treats configured allow lists as explicit selective egress", + { networkAllowList: "10.0.0.0/24" }, + false, + ], + [ + "treats domain allow lists as explicit selective egress", + { domainAllowList: "example.com" }, + false, + ], + ["keeps explicit egress opt-in", { networkBlockAll: false }, false], + [ + "keeps explicit blockAll over allow lists", + { networkBlockAll: true, networkAllowList: "10.0.0.0/24" }, + true, + ], + ])("%s", (_name, config, expected) => { + expect(resolveDaytonaPluginConfig(config).networkBlockAll).toBe(expected); + }); + + it("resolves configured values", () => { + const resolved = resolveDaytonaPluginConfig({ + apiKey: "dtn_test", + apiUrl: "https://daytona.example.com/api", + target: "us", + snapshot: "my-snapshot", + user: "runner", + volumes: [{ volumeId: "vol-1", mountPath: "/data/shared/" }], + autoStopInterval: 0, + autoPauseInterval: 30, + autoArchiveInterval: 120, + autoDeleteInterval: 60, + networkBlockAll: true, + networkAllowList: "10.0.0.0/24,192.168.0.0/16", + domainAllowList: "registry.npmjs.org,pypi.org", + remoteWorkspaceDir: "/workspaces/session/", + remoteAgentWorkspaceDir: "/workspaces-agent", + timeoutSeconds: 30.7, + }); + expect(resolved).toEqual({ + apiKey: "dtn_test", + apiUrl: "https://daytona.example.com/api", + target: "us", + snapshot: "my-snapshot", + image: undefined, + resources: undefined, + user: "runner", + volumes: [{ volumeId: "vol-1", mountPath: "/data/shared" }], + autoStopInterval: 0, + autoPauseInterval: 30, + autoArchiveInterval: 120, + autoDeleteInterval: 60, + networkBlockAll: true, + networkAllowList: "10.0.0.0/24,192.168.0.0/16", + domainAllowList: "registry.npmjs.org,pypi.org", + remoteWorkspaceDir: "/workspaces/session", + remoteAgentWorkspaceDir: "/workspaces-agent", + timeoutMs: 30_700, + }); + }); + + it("resolves image-based sandbox config with resources", () => { + const resolved = resolveDaytonaPluginConfig({ + image: "python:3.13-slim", + resources: { cpu: 2, memory: 4, disk: 10 }, + }); + expect(resolved.image).toBe("python:3.13-slim"); + expect(resolved.resources).toEqual({ cpu: 2, memory: 4, disk: 10 }); + expect(resolved.snapshot).toBeUndefined(); + }); + + it("accepts SecretRef apiKey values", () => { + const resolved = resolveDaytonaPluginConfig({ + apiKey: { source: "env", provider: "default", id: "DAYTONA_API_KEY" }, + }); + expect(resolved.apiKey).toEqual({ + source: "env", + provider: "default", + id: "DAYTONA_API_KEY", + }); + }); + + it("normalizes remote paths and keeps them absolute", () => { + const resolved = resolveDaytonaPluginConfig({ + remoteWorkspaceDir: "/srv/../srv/workspace", + }); + expect(resolved.remoteWorkspaceDir).toBe("/srv/workspace"); + }); + + it.each([ + ["relative path", { remoteWorkspaceDir: "workspace" }, /must be an absolute POSIX path/], + ["root path", { remoteWorkspaceDir: "/" }, /must not be the filesystem root/], + [ + "nested roots", + { remoteWorkspaceDir: "/data", remoteAgentWorkspaceDir: "/data/agent" }, + /distinct, non-nested/, + ], + [ + "equal roots", + { remoteWorkspaceDir: "/data", remoteAgentWorkspaceDir: "/data" }, + /distinct, non-nested/, + ], + [ + "snapshot combined with image", + { snapshot: "snap", image: "python:3.13-slim" }, + /mutually exclusive/, + ], + ["resources without image", { resources: { cpu: 2 } }, /resources require image/], + [ + "both idle intervals non-zero", + { autoStopInterval: 15, autoPauseInterval: 30 }, + /cannot both be non-zero/, + ], + [ + "relative volume mountPath", + { volumes: [{ volumeId: "vol-1", mountPath: "data" }] }, + /must be an absolute POSIX path/, + ], + [ + "volume mounted over the workspace root", + { volumes: [{ volumeId: "vol-1", mountPath: "/home/daytona/workspace/cache" }] }, + /must not overlap the managed workspace dirs/, + ], + [ + "volumes nested inside each other", + { + volumes: [ + { volumeId: "vol-1", mountPath: "/data" }, + { volumeId: "vol-2", mountPath: "/data/nested" }, + ], + }, + /must not overlap each other/, + ], + ])("rejects %s", (_name, config, message) => { + expect(() => resolveDaytonaPluginConfig(config)).toThrow(message); + }); + + it.each([ + ["unknown keys", { unknown: true }], + ["negative autoStopInterval", { autoStopInterval: -1 }], + ["fractional autoStopInterval", { autoStopInterval: 1.5 }], + ["empty snapshot", { snapshot: " " }], + ["oversized timeout", { timeoutSeconds: 2_147_001 }], + ["invalid secret ref", { apiKey: { source: "env", provider: "default", id: "lowercase" } }], + ["zero resource units", { image: "python:3.13-slim", resources: { cpu: 0 } }], + ["unknown resource keys", { image: "python:3.13-slim", resources: { vram: 1 } }], + ["empty volume id", { volumes: [{ volumeId: " ", mountPath: "/data" }] }], + ["unknown volume keys", { volumes: [{ volumeId: "vol-1", mountPath: "/data", ro: true }] }], + ])("rejects %s", (_name, config) => { + expect(() => resolveDaytonaPluginConfig(config)).toThrow(/Invalid daytona plugin config/); + }); +}); + +describe("createDaytonaPluginConfigSchema", () => { + it("matches the manifest config schema", () => { + const manifestPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "openclaw.plugin.json", + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { + configSchema: unknown; + }; + expect(createDaytonaPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema); + }); +}); diff --git a/extensions/daytona/src/config.ts b/extensions/daytona/src/config.ts new file mode 100644 index 000000000000..ebfaa4aa0e07 --- /dev/null +++ b/extensions/daytona/src/config.ts @@ -0,0 +1,260 @@ +// Daytona sandbox plugin config schema and resolution. +import path from "node:path"; +import { buildPluginConfigSchema, type OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; +import { + formatPluginConfigIssue, + mapPluginConfigIssues, +} from "openclaw/plugin-sdk/extension-shared"; +import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime"; +import { buildOptionalSecretInputSchema, type SecretInput } from "openclaw/plugin-sdk/secret-input"; +import { z } from "zod"; + +type DaytonaSandboxResources = { + cpu?: number; + gpu?: number; + memory?: number; + disk?: number; +}; + +type DaytonaVolumeMount = { + volumeId: string; + mountPath: string; +}; + +export type ResolvedDaytonaPluginConfig = { + apiKey?: SecretInput; + apiUrl?: string; + target?: string; + snapshot?: string; + image?: string; + resources?: DaytonaSandboxResources; + user?: string; + volumes?: DaytonaVolumeMount[]; + autoStopInterval?: number; + autoPauseInterval?: number; + autoArchiveInterval?: number; + autoDeleteInterval?: number; + networkBlockAll: boolean; + networkAllowList?: string; + domainAllowList?: string; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + timeoutMs: number; +}; + +const DEFAULT_REMOTE_WORKSPACE_DIR = "/home/daytona/workspace"; +const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/home/daytona/agent"; +const DEFAULT_TIMEOUT_MS = 120_000; + +const nonEmptyTrimmedString = (message: string) => + z.string({ error: message }).trim().min(1, { error: message }); + +const optionalMinutesInterval = (field: string) => + z + .int({ error: `${field} must be an integer number of minutes >= 0` }) + .min(0, { error: `${field} must be an integer number of minutes >= 0` }) + .optional(); + +const optionalResourceUnits = (field: string) => + z + .int({ error: `${field} must be an integer >= 1` }) + .min(1, { error: `${field} must be an integer >= 1` }) + .optional(); + +const DaytonaPluginConfigSchema = z.strictObject({ + apiKey: buildOptionalSecretInputSchema(), + apiUrl: nonEmptyTrimmedString("apiUrl must be a non-empty string").optional(), + target: nonEmptyTrimmedString("target must be a non-empty string").optional(), + snapshot: nonEmptyTrimmedString("snapshot must be a non-empty string").optional(), + image: nonEmptyTrimmedString("image must be a non-empty string").optional(), + resources: z + .strictObject({ + cpu: optionalResourceUnits("resources.cpu"), + gpu: optionalResourceUnits("resources.gpu"), + memory: optionalResourceUnits("resources.memory"), + disk: optionalResourceUnits("resources.disk"), + }) + .optional(), + user: nonEmptyTrimmedString("user must be a non-empty string").optional(), + volumes: z + .array( + z.strictObject({ + volumeId: nonEmptyTrimmedString("volumes[].volumeId must be a non-empty string"), + mountPath: nonEmptyTrimmedString("volumes[].mountPath must be a non-empty string"), + }), + { error: "volumes must be an array of { volumeId, mountPath } objects" }, + ) + .optional(), + autoStopInterval: optionalMinutesInterval("autoStopInterval"), + autoPauseInterval: optionalMinutesInterval("autoPauseInterval"), + autoArchiveInterval: optionalMinutesInterval("autoArchiveInterval"), + autoDeleteInterval: optionalMinutesInterval("autoDeleteInterval"), + networkBlockAll: z.boolean({ error: "networkBlockAll must be a boolean" }).optional(), + networkAllowList: nonEmptyTrimmedString("networkAllowList must be a non-empty string").optional(), + domainAllowList: nonEmptyTrimmedString("domainAllowList must be a non-empty string").optional(), + remoteWorkspaceDir: nonEmptyTrimmedString( + "remoteWorkspaceDir must be a non-empty string", + ).optional(), + remoteAgentWorkspaceDir: nonEmptyTrimmedString( + "remoteAgentWorkspaceDir must be a non-empty string", + ).optional(), + timeoutSeconds: z + .number({ + error: `timeoutSeconds must be a number between 1 and ${MAX_TIMER_TIMEOUT_SECONDS}`, + }) + .min(1, { error: "timeoutSeconds must be a number >= 1" }) + .max(MAX_TIMER_TIMEOUT_SECONDS, { + error: `timeoutSeconds must be a number <= ${MAX_TIMER_TIMEOUT_SECONDS}`, + }) + .optional(), +}); + +function normalizeDaytonaRemotePath( + value: string | undefined, + fallback: string, + fieldName: string, +): string { + const candidate = value ?? fallback; + const normalized = path.posix.normalize(candidate.trim() || fallback); + if (!normalized.startsWith("/")) { + throw new Error(`Daytona ${fieldName} must be an absolute POSIX path: ${candidate}`); + } + const trimmed = normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized; + if (trimmed === "/") { + throw new Error(`Daytona ${fieldName} must not be the filesystem root: ${candidate}`); + } + return trimmed; +} + +function pathsOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); +} + +export function createDaytonaPluginConfigSchema(): OpenClawPluginConfigSchema { + return buildPluginConfigSchema(DaytonaPluginConfigSchema, { + safeParse(value) { + if (value === undefined) { + return { success: true, data: undefined }; + } + const parsed = DaytonaPluginConfigSchema.safeParse(value); + if (parsed.success) { + return { success: true, data: parsed.data }; + } + return { + success: false, + error: { + issues: mapPluginConfigIssues(parsed.error.issues), + }, + }; + }, + }); +} + +export function resolveDaytonaPluginConfig(value: unknown): ResolvedDaytonaPluginConfig { + if (value === undefined) { + return { + networkBlockAll: true, + remoteWorkspaceDir: DEFAULT_REMOTE_WORKSPACE_DIR, + remoteAgentWorkspaceDir: DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + timeoutMs: DEFAULT_TIMEOUT_MS, + }; + } + + const parsed = DaytonaPluginConfigSchema.safeParse(value); + if (!parsed.success) { + const message = formatPluginConfigIssue(parsed.error.issues[0]); + throw new Error(`Invalid daytona plugin config: ${message}`); + } + const cfg = parsed.data; + if (cfg.snapshot && cfg.image) { + throw new Error( + "Daytona snapshot and image are mutually exclusive; configure one base per sandbox.", + ); + } + if (cfg.resources && !cfg.image) { + // Daytona applies explicit resources only to image-based creates; snapshot + // sandboxes inherit the resources baked into the snapshot. + throw new Error("Daytona resources require image; snapshot sandboxes size from the snapshot."); + } + if (cfg.autoStopInterval && cfg.autoPauseInterval) { + // Daytona API contract: at most one of the two intervals may be non-zero. + throw new Error( + "Daytona autoStopInterval and autoPauseInterval cannot both be non-zero; pick one idle policy.", + ); + } + const remoteWorkspaceDir = normalizeDaytonaRemotePath( + cfg.remoteWorkspaceDir, + DEFAULT_REMOTE_WORKSPACE_DIR, + "remoteWorkspaceDir", + ); + const remoteAgentWorkspaceDir = normalizeDaytonaRemotePath( + cfg.remoteAgentWorkspaceDir, + DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + "remoteAgentWorkspaceDir", + ); + // Distinct roots keep workspace/agent mount resolution unambiguous in the + // shared remote fs bridge; nested roots would shadow each other. + if (pathsOverlap(remoteWorkspaceDir, remoteAgentWorkspaceDir)) { + throw new Error( + `Daytona remoteWorkspaceDir and remoteAgentWorkspaceDir must be distinct, non-nested paths: ${remoteWorkspaceDir}, ${remoteAgentWorkspaceDir}`, + ); + } + const volumes = cfg.volumes?.map((volume, index) => { + const mountPath = normalizeDaytonaRemotePath( + volume.mountPath, + volume.mountPath, + `volumes[${index}].mountPath`, + ); + // Volumes mounted over the managed workspace roots would fight seeding and + // the fs bridge mount table. + if ( + pathsOverlap(mountPath, remoteWorkspaceDir) || + pathsOverlap(mountPath, remoteAgentWorkspaceDir) + ) { + throw new Error( + `Daytona volumes[${index}].mountPath must not overlap the managed workspace dirs: ${mountPath}`, + ); + } + return { volumeId: volume.volumeId, mountPath }; + }); + if (volumes) { + for (let index = 1; index < volumes.length; index += 1) { + const mountPath = volumes[index]?.mountPath ?? ""; + const conflict = volumes + .slice(0, index) + .find((earlier) => pathsOverlap(earlier.mountPath, mountPath)); + if (conflict) { + throw new Error( + `Daytona volumes mount paths must not overlap each other: ${conflict.mountPath}, ${mountPath}`, + ); + } + } + } + return { + apiKey: cfg.apiKey, + apiUrl: cfg.apiUrl, + target: cfg.target, + snapshot: cfg.snapshot, + image: cfg.image, + resources: cfg.resources, + user: cfg.user, + volumes, + autoStopInterval: cfg.autoStopInterval, + autoPauseInterval: cfg.autoPauseInterval, + autoArchiveInterval: cfg.autoArchiveInterval, + autoDeleteInterval: cfg.autoDeleteInterval, + // Egress is denied by default, matching the Docker backend's no-network + // stance. Configured allow lists are Daytona's selective-egress mode, so + // they imply explicit egress instead of being silently disabled by an + // implied blockAll (verified live: blockAll blocks allow-listed hosts too). + networkBlockAll: cfg.networkBlockAll ?? !(cfg.networkAllowList || cfg.domainAllowList), + networkAllowList: cfg.networkAllowList, + domainAllowList: cfg.domainAllowList, + remoteWorkspaceDir, + remoteAgentWorkspaceDir, + timeoutMs: + typeof cfg.timeoutSeconds === "number" + ? Math.floor(cfg.timeoutSeconds * 1000) + : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/extensions/daytona/src/daytona-exec-launcher.mjs b/extensions/daytona/src/daytona-exec-launcher.mjs new file mode 100644 index 000000000000..17fbc21d356b --- /dev/null +++ b/extensions/daytona/src/daytona-exec-launcher.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node +// Bridges one OpenClaw sandbox exec into a Daytona sandbox over the toolbox API. +// Spawned by the daytona sandbox backend with a payload file describing the run. + +import { randomBytes } from "node:crypto"; +import { readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const EXIT_POLL_INTERVAL_MS = 500; +const SIGNAL_NUMBERS = new Map([ + ["SIGHUP", 1], + ["SIGINT", 2], + ["SIGTERM", 15], +]); + +export function decodePayload(argv) { + const payloadFileIndex = argv.indexOf("--payload-file"); + if (payloadFileIndex < 0) { + throw new Error("Missing --payload-file"); + } + const payloadFile = argv[payloadFileIndex + 1]; + if (!payloadFile) { + throw new Error("Missing --payload-file value"); + } + const payloadJson = readFileSync(payloadFile, "utf8"); + // The payload carries the Daytona API key; drop it from disk as soon as it + // is read so the secret only lives in this process. + rmSync(path.dirname(payloadFile), { force: true, recursive: true }); + return JSON.parse(payloadJson); +} + +export function shellEscape(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function buildSessionCommand(command, env = {}) { + const exports = Object.entries(env).map(([key, value]) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) { + throw new Error( + `Invalid Daytona sandbox environment variable name ${JSON.stringify(key)}; use a POSIX variable name.`, + ); + } + if (value.includes("\0")) { + throw new Error( + `Invalid Daytona sandbox environment variable ${JSON.stringify(key)}; values must not contain NUL bytes.`, + ); + } + return `export ${key}=${shellEscape(value)}`; + }); + return exports.length > 0 ? `${exports.join("; ")}; exec ${command}` : command; +} + +function formatError(error) { + if (error && typeof error === "object" && typeof error.stack === "string") { + return error.stack; + } + return String(error); +} + +function signalExitCode(signal) { + const signalNumber = SIGNAL_NUMBERS.get(signal); + return signalNumber === undefined ? 1 : 128 + signalNumber; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Register signal cleanup before any remote call so a timeout or cancellation + * SIGTERM during startup still kills the remote session or PTY. An accepted + * command must never keep running after this launcher reports it stopped. + */ +export function registerCleanupSignals(cleanup, options = {}) { + const onSignal = options.onSignal ?? ((signal, handler) => process.on(signal, handler)); + const exit = options.exit ?? ((code) => process.exit(code)); + // Callers await this promise before reporting a signal exit; otherwise + // main's process.exit can beat remote teardown and leave the command running. + const state = { interrupted: null, cleanupPromise: Promise.resolve() }; + for (const signal of SIGNAL_NUMBERS.keys()) { + onSignal(signal, () => { + if (state.interrupted) { + return; + } + state.interrupted = signal; + state.cleanupPromise = cleanup() + .catch(() => {}) + .then(() => {}) + .finally(() => exit(signalExitCode(signal))); + }); + } + return state; +} + +export async function runPtyExec(sandbox, payload, options = {}) { + const ptyId = `openclaw-pty-${randomBytes(6).toString("hex")}`; + // Cleanup is armed before the PTY exists; killing an id that was never + // created fails harmlessly inside the catch. + const signalState = registerCleanupSignals(() => sandbox.process.killPtySession(ptyId), options); + let ptyHandle; + try { + ptyHandle = await sandbox.process.createPty({ + id: ptyId, + cwd: payload.cwd, + envs: payload.env, + cols: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + onData: (data) => { + process.stdout.write(Buffer.from(data)); + }, + }); + await ptyHandle.waitForConnection(); + if (signalState.interrupted) { + await signalState.cleanupPromise; + return signalExitCode(signalState.interrupted); + } + // `exec` replaces the interactive shell so the PTY session ends with the + // command and reports its exit code. The command stays single quoted, which + // keeps embedded newlines inside one shell word for the line-based PTY. + await ptyHandle.sendInput(`exec /bin/sh -c ${shellEscape(payload.command)}\n`); + if (signalState.interrupted) { + await signalState.cleanupPromise; + return signalExitCode(signalState.interrupted); + } + + const stdin = options.stdin ?? process.stdin; + let inputQueue = Promise.resolve(); + const enqueueInput = (data) => { + // Preserve terminal byte order across the SDK's async send boundary; + // EOT must never overtake the final stdin chunk. + inputQueue = inputQueue.then(() => ptyHandle.sendInput(data)).catch(() => {}); + }; + stdin.on("data", (chunk) => { + enqueueInput(new Uint8Array(chunk)); + }); + // A pipe-open caller closes stdin to signal EOF. PTYs have no half-close, + // so forward terminal EOT or commands waiting for EOF can hang indefinitely. + stdin.on("end", () => { + enqueueInput("\x04"); + }); + stdin.resume(); + process.stdout.on("resize", () => { + void ptyHandle + .resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24) + .catch(() => {}); + }); + + const result = await ptyHandle.wait(); + if (signalState.interrupted) { + await signalState.cleanupPromise; + return signalExitCode(signalState.interrupted); + } + if (result.error && result.exitCode === undefined) { + process.stderr.write(`[daytona-sandbox] pty failed: ${result.error}\n`); + return 1; + } + return result.exitCode ?? 0; + } catch (error) { + // A rejected connection, input, or wait does not prove the remote process + // stopped. Kill by id before reporting failure, then release the socket. + await sandbox.process.killPtySession(ptyId).catch(() => {}); + throw error; + } finally { + await ptyHandle?.disconnect().catch(() => {}); + } +} + +export async function runSessionExec(sandbox, payload, options = {}) { + const sessionId = `openclaw-exec-${randomBytes(6).toString("hex")}`; + const deleteSession = async () => { + await sandbox.process.deleteSession(sessionId).catch(() => {}); + }; + // Cleanup is armed before the session exists; deleting the session kills a + // remote command that was accepted while this launcher was being torn down. + const signalState = registerCleanupSignals(deleteSession, options); + await sandbox.process.createSession(sessionId); + try { + if (signalState.interrupted) { + return signalExitCode(signalState.interrupted); + } + const execution = await sandbox.process.executeSessionCommand(sessionId, { + command: buildSessionCommand(payload.command, payload.env), + runAsync: true, + suppressInputEcho: true, + }); + if (signalState.interrupted) { + return signalExitCode(signalState.interrupted); + } + const commandId = execution.cmdId; + if (!commandId) { + throw new Error("Daytona did not return a command id for the exec session"); + } + + process.stdin.on("data", (chunk) => { + void sandbox.process + .sendSessionCommandInput(sessionId, commandId, chunk.toString("utf8")) + .catch(() => {}); + }); + process.stdin.resume(); + + // Track emitted lengths so the post-exit catch-up fetch only appends + // output the live stream missed. The stream can end while the command is + // still running, so command lifecycle never depends on it. + let stdoutEmitted = 0; + let stderrEmitted = 0; + const emitStdout = (chunk) => { + stdoutEmitted += chunk.length; + process.stdout.write(chunk); + }; + const emitStderr = (chunk) => { + stderrEmitted += chunk.length; + process.stderr.write(chunk); + }; + void sandbox.process + .getSessionCommandLogs(sessionId, commandId, emitStdout, emitStderr) + .catch(() => {}); + + let exitCode; + let pollFailures = 0; + for (;;) { + if (signalState.interrupted) { + return signalExitCode(signalState.interrupted); + } + try { + const command = await sandbox.process.getSessionCommand(sessionId, commandId); + exitCode = command.exitCode; + pollFailures = 0; + } catch (error) { + pollFailures += 1; + if (pollFailures >= 5) { + throw error; + } + } + if (exitCode !== undefined && exitCode !== null) { + break; + } + await sleep(EXIT_POLL_INTERVAL_MS); + } + + const finalLogs = await sandbox.process.getSessionCommandLogs(sessionId, commandId); + const stdoutTail = (finalLogs.stdout ?? "").slice(stdoutEmitted); + const stderrTail = (finalLogs.stderr ?? "").slice(stderrEmitted); + if (stdoutTail) { + process.stdout.write(stdoutTail); + } + if (stderrTail) { + process.stderr.write(stderrTail); + } + return exitCode; + } finally { + await deleteSession(); + } +} + +function isMain() { + const mainPath = process.argv[1]; + if (!mainPath) { + return false; + } + return import.meta.url === pathToFileURL(path.resolve(mainPath)).href; +} + +export async function main() { + let exitCode; + try { + const payload = decodePayload(process.argv.slice(2)); + const { Daytona } = await import("@daytona/sdk"); + const client = new Daytona({ + apiKey: payload.apiKey, + apiUrl: payload.apiUrl, + target: payload.target, + }); + const sandbox = await client.get(payload.sandboxId); + if (sandbox.state !== "started") { + // Daytona auto-stops idle sandboxes; restart before running so an exec + // after an idle gap works instead of failing on a stopped sandbox. + await sandbox.start(); + } + exitCode = payload.usePty + ? await runPtyExec(sandbox, payload) + : await runSessionExec(sandbox, payload); + } catch (error) { + process.stderr.write(`[daytona-sandbox] ${formatError(error)}\n`); + exitCode = 127; + } + process.exit(exitCode ?? 1); +} + +if (isMain()) { + void main(); +} diff --git a/extensions/daytona/src/launcher-path.ts b/extensions/daytona/src/launcher-path.ts new file mode 100644 index 000000000000..90726ccfe24c --- /dev/null +++ b/extensions/daytona/src/launcher-path.ts @@ -0,0 +1,43 @@ +// Locates the exec launcher shipped with this plugin across source and dist layouts. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const LAUNCHER_FILE_NAME = "daytona-exec-launcher.mjs"; + +function isDaytonaPluginRoot(dir: string): boolean { + return ( + fs.existsSync(path.join(dir, "openclaw.plugin.json")) && + fs.existsSync(path.join(dir, "package.json")) + ); +} + +function resolveDaytonaPluginRoot(moduleUrl: string): string { + let cursor = path.dirname(fileURLToPath(moduleUrl)); + for (let i = 0; i < 6; i += 1) { + if (isDaytonaPluginRoot(cursor)) { + return cursor; + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + throw new Error(`[daytona] cannot locate plugin root from ${moduleUrl}`); +} + +export function resolveDaytonaLauncherPath(moduleUrl: string = import.meta.url): string { + const root = resolveDaytonaPluginRoot(moduleUrl); + const candidates = [ + path.join(root, "src", LAUNCHER_FILE_NAME), + path.join(root, LAUNCHER_FILE_NAME), + path.join(root, "dist", LAUNCHER_FILE_NAME), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + throw new Error(`[daytona] launcher not found; searched ${candidates.join(", ")}`); +} diff --git a/extensions/daytona/src/launcher.test.ts b/extensions/daytona/src/launcher.test.ts new file mode 100644 index 000000000000..2d774cbf4dba --- /dev/null +++ b/extensions/daytona/src/launcher.test.ts @@ -0,0 +1,296 @@ +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +const launcherPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "daytona-exec-launcher.mjs", +); + +const loadLauncher = async () => + (await import(pathToFileURL(launcherPath).href)) as { + decodePayload: (argv: string[]) => unknown; + shellEscape: (value: string) => string; + buildSessionCommand: (command: string, env?: Record) => string; + registerCleanupSignals: ( + cleanup: () => Promise, + options?: { + onSignal?: (signal: string, handler: () => void) => void; + exit?: (code: number) => void; + }, + ) => { interrupted: string | null }; + runSessionExec: ( + sandbox: { + process: { + createSession: ReturnType; + executeSessionCommand: ReturnType; + deleteSession: ReturnType; + }; + }, + payload: { command: string }, + options?: { + onSignal?: (signal: string, handler: () => void) => void; + exit?: (code: number) => void; + }, + ) => Promise; + runPtyExec: ( + sandbox: { + process: { + createPty: ReturnType; + killPtySession: ReturnType; + }; + }, + payload: { command: string; cwd: string; env: Record }, + options?: { + onSignal?: (signal: string, handler: () => void) => void; + exit?: (code: number) => void; + stdin?: EventEmitter & { resume: () => void }; + }, + ) => Promise; + }; + +describe("daytona exec launcher", () => { + it("decodes the payload file and removes the payload directory", async () => { + const launcher = await loadLauncher(); + const payloadDir = mkdtempSync(path.join(tmpdir(), "daytona-launcher-test-")); + const payloadFile = path.join(payloadDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ sandboxId: "sbx-1", usePty: false })); + + const payload = launcher.decodePayload(["--payload-file", payloadFile]); + + expect(payload).toEqual({ sandboxId: "sbx-1", usePty: false }); + expect(existsSync(payloadDir)).toBe(false); + }); + + it("rejects missing payload arguments", async () => { + const launcher = await loadLauncher(); + expect(() => launcher.decodePayload([])).toThrow("Missing --payload-file"); + expect(() => launcher.decodePayload(["--payload-file"])).toThrow( + "Missing --payload-file value", + ); + }); + + it("escapes shell words for the PTY exec wrapper", async () => { + const launcher = await loadLauncher(); + expect(launcher.shellEscape("plain")).toBe("'plain'"); + expect(launcher.shellEscape("with 'quote'")).toBe(`'with '"'"'quote'"'"''`); + }); + + it("stages session environment without putting values in launcher argv", async () => { + const launcher = await loadLauncher(); + expect(launcher.buildSessionCommand("printf ok", { TOKEN: "a'b" })).toBe( + `export TOKEN='a'"'"'b'; exec printf ok`, + ); + expect(() => launcher.buildSessionCommand("true", { "BAD-NAME": "1" })).toThrow( + "use a POSIX variable name", + ); + expect(() => launcher.buildSessionCommand("true", { TOKEN: "bad\0value" })).toThrow( + "must not contain NUL bytes", + ); + }); + + it("runs remote cleanup before exiting when a signal arrives", async () => { + const launcher = await loadLauncher(); + const events: string[] = []; + const handlers = new Map void>(); + let resolveCleanup: (() => void) | undefined; + const cleanup = () => { + events.push("cleanup-start"); + return new Promise((resolve) => { + resolveCleanup = () => { + events.push("cleanup-done"); + resolve(); + }; + }); + }; + + const state = launcher.registerCleanupSignals(cleanup, { + onSignal: (signal, handler) => handlers.set(signal, handler), + exit: (code) => events.push(`exit-${code}`), + }); + + // Handlers for every forwarded signal are armed synchronously, before any + // remote startup call could have been awaited. + expect([...handlers.keys()].toSorted()).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]); + expect(state.interrupted).toBeNull(); + + handlers.get("SIGTERM")?.(); + expect(state.interrupted).toBe("SIGTERM"); + expect(events).toEqual(["cleanup-start"]); + resolveCleanup?.(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + // Exit happens only after the remote cleanup settled. + expect(events).toEqual(["cleanup-start", "cleanup-done", "exit-143"]); + }); + + it("does not submit a command when signalled during session creation", async () => { + const launcher = await loadLauncher(); + const handlers = new Map void>(); + let releaseSession: (() => void) | undefined; + const sandbox = { + process: { + createSession: vi.fn( + () => + new Promise((resolve) => { + releaseSession = resolve; + }), + ), + executeSessionCommand: vi.fn(), + deleteSession: vi.fn(async () => {}), + }, + }; + + const pending = launcher.runSessionExec( + sandbox, + { command: "touch should-not-exist" }, + { + onSignal: (signal, handler) => handlers.set(signal, handler), + exit: () => {}, + }, + ); + await vi.waitFor(() => expect(releaseSession).toBeTypeOf("function")); + handlers.get("SIGTERM")?.(); + releaseSession?.(); + + await expect(pending).resolves.toBe(143); + expect(sandbox.process.executeSessionCommand).not.toHaveBeenCalled(); + }); + + it("kills and disconnects a PTY when startup input fails", async () => { + const launcher = await loadLauncher(); + const ptyHandle = { + waitForConnection: vi.fn(async () => {}), + sendInput: vi.fn(async () => { + throw new Error("send failed"); + }), + disconnect: vi.fn(async () => {}), + }; + const sandbox = { + process: { + createPty: vi.fn(async (_options: { id: string }) => ptyHandle), + killPtySession: vi.fn(async () => {}), + }, + }; + + await expect( + launcher.runPtyExec( + sandbox, + { command: "true", cwd: "/workspace", env: {} }, + { onSignal: () => {}, exit: () => {} }, + ), + ).rejects.toThrow("send failed"); + + const ptyId = sandbox.process.createPty.mock.calls[0]?.[0]?.id; + expect(ptyId).toMatch(/^openclaw-pty-/u); + expect(sandbox.process.killPtySession).toHaveBeenCalledWith(ptyId); + expect(ptyHandle.disconnect).toHaveBeenCalledTimes(1); + }); + + it("waits for PTY termination before returning after a signal", async () => { + const launcher = await loadLauncher(); + const handlers = new Map void>(); + let resolveWait: ((result: { exitCode: number }) => void) | undefined; + let resolveKill: (() => void) | undefined; + const ptyHandle = { + waitForConnection: vi.fn(async () => {}), + sendInput: vi.fn(async () => {}), + wait: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveWait = resolve; + }), + ), + disconnect: vi.fn(async () => {}), + }; + const sandbox = { + process: { + createPty: vi.fn(async (_options: { id: string }) => ptyHandle), + killPtySession: vi.fn( + () => + new Promise((resolve) => { + resolveKill = resolve; + }), + ), + }, + }; + + const pending = launcher.runPtyExec( + sandbox, + { command: "sleep 30", cwd: "/workspace", env: {} }, + { + onSignal: (signal, handler) => handlers.set(signal, handler), + exit: () => {}, + }, + ); + await vi.waitFor(() => expect(ptyHandle.wait).toHaveBeenCalledTimes(1)); + + handlers.get("SIGTERM")?.(); + resolveWait?.({ exitCode: 0 }); + const settled = vi.fn(); + void pending.then(settled, settled); + await vi.waitFor(() => expect(sandbox.process.killPtySession).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(settled).not.toHaveBeenCalled(); + + resolveKill?.(); + await expect(pending).resolves.toBe(143); + expect(ptyHandle.disconnect).toHaveBeenCalledTimes(1); + }); + + it("forwards stdin data before EOF to a Daytona PTY", async () => { + const launcher = await loadLauncher(); + const stdin = Object.assign(new EventEmitter(), { resume: vi.fn() }); + let resolveWait: ((result: { exitCode: number }) => void) | undefined; + let resolveData: (() => void) | undefined; + const ptyHandle = { + waitForConnection: vi.fn(async () => {}), + sendInput: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveData = resolve; + }), + ) + .mockResolvedValue(undefined), + wait: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveWait = resolve; + }), + ), + disconnect: vi.fn(async () => {}), + }; + const sandbox = { + process: { + createPty: vi.fn(async () => ptyHandle), + killPtySession: vi.fn(async () => {}), + }, + }; + + const pending = launcher.runPtyExec( + sandbox, + { command: "cat", cwd: "/workspace", env: {} }, + { onSignal: () => {}, exit: () => {}, stdin }, + ); + await vi.waitFor(() => expect(ptyHandle.wait).toHaveBeenCalledTimes(1)); + + stdin.emit("data", Buffer.from("hello")); + stdin.emit("end"); + await vi.waitFor(() => expect(ptyHandle.sendInput).toHaveBeenCalledTimes(2)); + + resolveData?.(); + await vi.waitFor(() => expect(ptyHandle.sendInput).toHaveBeenLastCalledWith("\x04")); + + resolveWait?.({ exitCode: 0 }); + await expect(pending).resolves.toBe(0); + }); +}); diff --git a/extensions/daytona/src/upload.ts b/extensions/daytona/src/upload.ts new file mode 100644 index 000000000000..374ced299ea9 --- /dev/null +++ b/extensions/daytona/src/upload.ts @@ -0,0 +1,113 @@ +// Local directory upload into a Daytona sandbox via a tar file over the toolbox API. +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/sandbox"; +import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; +import type { Sandbox } from "./client.js"; + +/** + * Reject symlinks that escape the uploaded tree so extracting the tar inside + * the sandbox cannot recreate links pointing at host-private paths. + */ +async function assertSafeDaytonaUploadSymlinks(localDir: string): Promise { + const rootDir = path.resolve(localDir); + const resolvedRoot = await fs.realpath(rootDir); + await walkDirectory(rootDir); + + async function walkDirectory(currentDir: string): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + if (entry.isSymbolicLink()) { + const relativePath = path.relative(rootDir, entryPath).split(path.sep).join("/"); + let resolvedTarget: string; + try { + resolvedTarget = await fs.realpath(entryPath); + } catch { + throw new Error( + `Daytona sandbox upload refuses broken symlink in the workspace: ${relativePath}`, + ); + } + if (resolvedTarget !== resolvedRoot && !isPathInside(resolvedRoot, resolvedTarget)) { + throw new Error( + `Daytona sandbox upload refuses symlink escaping the workspace: ${relativePath}`, + ); + } + continue; + } + if (entry.isDirectory()) { + await walkDirectory(entryPath); + } + } + } +} + +function createLocalTarFile(localDir: string, tarPath: string): Promise { + return new Promise((resolve, reject) => { + const tar = spawn("tar", ["-C", localDir, "-cf", tarPath, "."], { + stdio: ["ignore", "ignore", "pipe"], + }); + const stderr: Buffer[] = []; + tar.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))); + tar.on("error", reject); + tar.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + Buffer.concat(stderr).toString("utf8").trim() || `tar exited with code ${code ?? 1}`, + ), + ); + }); + }); +} + +/** + * Upload a local directory into the sandbox by shipping one tar file through + * the toolbox files API and extracting it remotely. Tar keeps permissions, + * executable bits, and empty directories that per-file uploads would lose. + */ +export async function uploadDirectoryToDaytonaSandbox(params: { + sandbox: Sandbox; + localDir: string; + remoteDir: string; + timeoutMs: number; + runRemoteShellScript: (params: { + script: string; + args?: string[]; + }) => Promise<{ stdout: Buffer; stderr: Buffer; code: number }>; + /** Wraps direct toolbox calls so auto-stopped sandboxes restart first. */ + runRemoteOperation?: (run: () => Promise) => Promise; +}): Promise { + await assertSafeDaytonaUploadSymlinks(params.localDir); + await withTempWorkspace( + { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-daytona-upload-" }, + async (workspace) => { + const tarPath = workspace.path("openclaw-seed.tar"); + await createLocalTarFile(params.localDir, tarPath); + const remoteTarPath = `/tmp/openclaw-seed-${randomBytes(12).toString("hex")}.tar`; + const runRemoteOperation = params.runRemoteOperation ?? (async (run) => await run()); + await runRemoteOperation(() => + params.sandbox.fs.uploadFile(tarPath, remoteTarPath, Math.ceil(params.timeoutMs / 1000)), + ); + try { + await params.runRemoteShellScript({ + // Extraction failures must still remove the staged tar, and the + // original extract exit code has to survive the cleanup. + script: 'mkdir -p -- "$1" && tar -xf "$2" -C "$1"; ec=$?; rm -f -- "$2"; exit $ec', + args: [params.remoteDir, remoteTarPath], + }); + } catch (error) { + // The sandbox persists per scope; a transport failure must not leave + // the staged workspace tar behind. The extract script removes it on + // the normal path, so a missing file here is fine. + await params.sandbox.fs.deleteFile(remoteTarPath).catch(() => {}); + throw error; + } + }, + ); +} diff --git a/extensions/daytona/tsconfig.json b/extensions/daytona/tsconfig.json new file mode 100644 index 000000000000..c40eba47b3b4 --- /dev/null +++ b/extensions/daytona/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.package-boundary.base.json" +} diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 06c5837cf4bc..4db4500a106b 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -8,8 +8,8 @@ }, "type": "module", "dependencies": { - "@aws-sdk/client-s3": "3.1113.0", - "@aws-sdk/s3-request-presigner": "3.1113.0", + "@aws-sdk/client-s3": "3.1117.0", + "@aws-sdk/s3-request-presigner": "3.1117.0", "@tloncorp/tlon-skill": "0.5.0", "@urbit/aura": "3.0.0", "zod": "4.4.3" diff --git a/package.json b/package.json index 6f8d7e3bd231..962f375910b8 100644 --- a/package.json +++ b/package.json @@ -275,6 +275,7 @@ "!dist/extensions/cloudflare-ai-gateway/**", "!dist/extensions/codex/**", "!dist/extensions/copilot/**", + "!dist/extensions/daytona/**", "!dist/extensions/deepinfra/**", "!dist/extensions/deepseek/**", "!dist/extensions/diagnostics-otel/**", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee207271c231..af89be747906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -735,6 +735,19 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/daytona: + dependencies: + '@daytona/sdk': + specifier: 0.201.0 + version: 0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/deepgram: devDependencies: '@openclaw/plugin-sdk': @@ -1891,11 +1904,11 @@ importers: extensions/tlon: dependencies: '@aws-sdk/client-s3': - specifier: 3.1113.0 - version: 3.1113.0 + specifier: 3.1117.0 + version: 3.1117.0 '@aws-sdk/s3-request-presigner': - specifier: 3.1113.0 - version: 3.1113.0 + specifier: 3.1117.0 + version: 3.1117.0 '@tloncorp/tlon-skill': specifier: 0.5.0 version: 0.5.0 @@ -2677,8 +2690,8 @@ packages: resolution: {integrity: sha512-T5faVvwt6o1+O+afIxrPLrr/hpwmtg6DKnntwdHr+hm5TfLkZJ/g0KddMouTJVG97fAMsfQEkGk/ceWN1lBlqw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1113.0': - resolution: {integrity: sha512-NRqdtohoMRyWkEeeznfG1KPN08dclCbl+HFuLPB2v8qPcgoNmTFlLKl9ELiR6hsGXQ4Ur3qvRnoJVEk8ND74pg==} + '@aws-sdk/client-s3@3.1117.0': + resolution: {integrity: sha512-M/zyjg0u0Sxm73sInvyDb9+/YUuAOz8/xxmcmj0quJ7NXllZqXPxzjti3oVDz/lQ5mDf7iuxBLc0Y3deJI2hRw==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.977.8': @@ -2757,6 +2770,12 @@ packages: resolution: {integrity: sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==} engines: {node: '>=20.0.0'} + '@aws-sdk/lib-storage@3.1117.0': + resolution: {integrity: sha512-ksBywRN9EjUqhuXpjCYdumJg9QB9r9Jbeaa2oUj4fWIK5A11LV8vEP0qdBgt63QUHgRglNF+nbD4xlLyooJanA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.1117.0 + '@aws-sdk/middleware-eventstream@3.972.29': resolution: {integrity: sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==} engines: {node: '>=20.0.0'} @@ -2777,8 +2796,8 @@ packages: resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1113.0': - resolution: {integrity: sha512-FdbHboJSscXRHnqOGRLN+MvtcYNlxw1+XWMKcKi72nBPxpSTGQA+/zmxEBTlkCvTdIPtl/g383nNY0RiKYPmWQ==} + '@aws-sdk/s3-request-presigner@3.1117.0': + resolution: {integrity: sha512-4aivFw3OXy83wuh3utC2F6bgNg4SCdcCLOSXRvYF23qEL0OzGvkjqzLTU+T191laBKjbU/m6nJLFrZsEtOo9ow==} engines: {node: '>=20.0.0'} '@aws-sdk/signature-v4-multi-region@3.996.46': @@ -3138,6 +3157,18 @@ packages: '@d-fischer/typed-event-emitter@3.3.3': resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==} + '@daytona/analytics-api-client@0.201.0': + resolution: {integrity: sha512-dO6rNFoL24l0NsumnDqvWJH560tuaukYHXUJI1JIBH94P/4m3Ha4k87q61cwrwumWHtKWqlkJXvVk8a6iSlpnQ==} + + '@daytona/api-client@0.201.0': + resolution: {integrity: sha512-Bmk+sXbLdUNNd1G41a0NTNKiKQeNLfeo2Of7WLu49MrmyxqSd0ECXKG3ND8jI+VEJAwuyfs3u/vj36Gd8i0Vjg==} + + '@daytona/sdk@0.201.0': + resolution: {integrity: sha512-nldIlBhSTClWeZl8SCd+0Jhf1GqubA7++2pqru8oWsAiuPOKHSHAhvHc/VW3oYoEkSMFiKCIWMJkLH128xIT9w==} + + '@daytona/toolbox-api-client@0.201.0': + resolution: {integrity: sha512-rNPi4e3D0bg5xAY13h1zG1YzrK6gdbk5qV+0ibT9HeSj7hvUuuEUVWKFvdk24+VqsYbbno8OmF8/2Z2V6ZIdwg==} + '@discord/embedded-app-sdk@2.5.0': resolution: {integrity: sha512-FNoe5PbSkoKEbqPubaBmq6tlEMOftMjR3gu55YrdrrKmBfKKM4i9P/Z+Zxl0RZdJcKviBwVcTyYMMPM/aGex/w==} @@ -3639,6 +3670,9 @@ packages: '@huggingface/transformers@3.0.2': resolution: {integrity: sha512-lTyS81eQazMea5UCehDGFMfdcNRZyei7XQLH5X6j4AhA/18Ka0+5qPgMxUxuZLU4xkv60aY2KNz9Yzthv6WVJg==} + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -4310,6 +4344,10 @@ packages: resolution: {integrity: sha512-aiZFvWmP/ndpS3em5xVtiPlj1k6asA8ueoNxGxekA1024EZlCwnICPZklG6g2sU4Wlntp9AYocLCr5/iuvL7hw==} engines: {node: ^22.18.0 || >=24.11.0} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.221.0': resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} engines: {node: '>=8.0.0'} @@ -4318,6 +4356,12 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/configuration@0.220.0': + resolution: {integrity: sha512-glfIVKnZevRin8fY/9uES/mhRtMT1lGINLHc9MIo5fTQZXswEEHamJtgjv4MTtzgnhHGC92mIS/0lzAUZMyE0w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/configuration@0.221.0': resolution: {integrity: sha512-uE9y56Zdi9Gt/RdxYnVOo3YmFZkKJJMA0gqtBe8wh8gdtF5Asqe+Oh/TWiDtFb1s+31jNY4CWgnfIB1KOITfFA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4330,66 +4374,132 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/context-async-hooks@2.9.0': + resolution: {integrity: sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.10.0': resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-grpc@0.220.0': + resolution: {integrity: sha512-s0sRPCSlXYqlgObOpCftomJllp3LfUL9FobQ5csg2172ydVhSEnu1ptpsVBJadazs5nUNp7vDuLE03FAFWTLOQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.221.0': resolution: {integrity: sha512-txG1G0IrYSsKKMeiWZfj/i5cQmWB+h+hf3HzPpF3RqZVwp+iQQEIsv8Vtmzy6RWVdHdJZfygmVrBI39YTBvWcw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.221.0': resolution: {integrity: sha512-nKXkr4Tomi6fjYVOf+ytcW3dZAVr4v4Bv5gsT6dr2gvpUPJpKgHB4XbMufMsPotRE3g0XH2GwVVCkN2w6SON+Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.220.0': + resolution: {integrity: sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.221.0': resolution: {integrity: sha512-AH6EY+47gXFaWYgG3hfeOneGiE9xIZGtDBk+9g0sM8NZWzsQhhmqPbQQXJzS7pyCh5jRRr2nYNXVrkCmoojRvQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.220.0': + resolution: {integrity: sha512-U128izvJfX/dW9jRGP0gIfadR1Hg7ft3UEGIeRxLFK70m2BWw6AtNCOnsUygpw2zCgR/ygdWbGpcL6TmhW0ZGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.221.0': resolution: {integrity: sha512-KOgCtO15FC6C1T/xOqBcr7EyUs7B+7yomGNb5Y97d3s38rPbCCk5sewkmE2b0/itOkQ/PptX8CLlD+kn2mEtTg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.220.0': + resolution: {integrity: sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.221.0': resolution: {integrity: sha512-sRfCKbOzgy8xZQV2as0RzIZlnCmCseCKZGLfRcrpo2CBngJDr+rPtX0zkG0+oUCV5kfQPUoW3W3C96Ag3Y/Clg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.220.0': + resolution: {integrity: sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.221.0': resolution: {integrity: sha512-YMF4LveY2I3yhw61rn6nmC9FE8U24IZHPeKU1Duc5+sbwjMd8FwZAwba318ImdThCg/HuVQvhm2y6bfgNPnfYg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.220.0': + resolution: {integrity: sha512-JZD5DL/NBpVd2BHefvYosm3G40UZ/KzExLv5tc0eZe0CtrsHHtcOk3YPUxR2EINmUeBf8+w5UReTV8fFPn95lA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.221.0': resolution: {integrity: sha512-kW79a20qWESIuAdDrxzg9WKM98twV/NBWBFRAH57ap/+ssZhiCo0hckzKT0zpuwR/gSHrFAQhJL0bYDrnEM34g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0': + resolution: {integrity: sha512-bv1xmNhmNwIM6MdUBw4yYuJeVcEViVLk3uD69vOQMwueHBnfyl/u0HnBlB1FNY/Te0UOzJzvcbyR8wN6b+iGbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.221.0': resolution: {integrity: sha512-zXminlZedtq9LvOW64CnNkOqk15zV75k8JgtdTuWFge6+jk2m4GmAUm6L2eIiG1o2a2bZxXw2PDrszm+bps0IA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.220.0': + resolution: {integrity: sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.221.0': resolution: {integrity: sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.220.0': + resolution: {integrity: sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.221.0': resolution: {integrity: sha512-Z9i2T7vgZbWe9rSLYxXVIbeW+XyzUq4rZanW3ZyVNwVDqCsh0EJKUgBWWQ0CZfeuUA+RQPzKgJQHMuWAUnKqXw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4402,24 +4512,60 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-zipkin@2.9.0': + resolution: {integrity: sha512-RwINoce2BH8T4obT5pMcAla2sWma1YZvYuaktWmTluQ0PkQdvv5D060rWI1+kawX+J2qBRcMbwrZJJNcMJUauQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-http@0.220.0': + resolution: {integrity: sha512-Szt4dO2Boz2CDr38DaSw/lnqwhwKl+IAdgNGEGgSm2Anb+fwPtIAGmIwkhsLLN69QQZQE96JxjMKYY4rlRkYKw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.221.0': resolution: {integrity: sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.221.0': resolution: {integrity: sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.220.0': + resolution: {integrity: sha512-/eIkBPMBTIvM3x/0mDX4aJeSkYifYClnBPr68PL1h5LV4VQv4+SV6CGrpiZ4fIWDnobVmhTWCm1J/QRdAWUfvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.221.0': resolution: {integrity: sha512-rQDmNgyiGCTrescjnzH2ntVyUKVIq6I2UjuK8+stT/Xg0ZOT71FVJqwjFdspQl6Yol/Yqsut9bDo+ame8oTmDQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.221.0': resolution: {integrity: sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4432,6 +4578,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/propagator-b3@2.9.0': + resolution: {integrity: sha512-WrOT1WsOUG+B7hstD2RYoMPIOK76G8E9AQHhMjUvrQaGx/oA7rPWQvvr1Rqv7+yy4R0ZMVwWLC4vW2xnkgWPAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/propagator-jaeger@2.10.0': resolution: {integrity: sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4444,6 +4596,18 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.221.0': resolution: {integrity: sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4456,6 +4620,18 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.220.0': + resolution: {integrity: sha512-wHtGyHhSKHNH3fym33xRu4Ef/HXTFvX8eQ42xdQdEO9LYx9Y2qNyBDJytyqVlvmo6abWZlNYTUthuAGUMYqYnQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-node@0.221.0': resolution: {integrity: sha512-UbYuvtBrQQB5Prsh9KOKy4kxzexFxfMs5MkteHeWMoswsEB7kiNhyUVkAOFW/qsEzNHtrkgyghrD2ilZJa+5YA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4468,18 +4644,36 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.9.0': + resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-node@2.10.0': resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace-node@2.9.0': + resolution: {integrity: sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace@2.10.0': resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -5349,6 +5543,9 @@ packages: resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==} engines: {node: '>= 10'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -6180,6 +6377,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -6187,6 +6387,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -6267,6 +6471,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + clawpdf@0.3.1: resolution: {integrity: sha512-HZ8gz3cm8arzT/V2CnMZlHlRJbsrUYQ71u+A4nymeyvNnYzpZ8RQwkr2EIZiXCOzRkzFy8sOiHBXBuSUDDcXqQ==} engines: {node: '>=22'} @@ -6583,6 +6790,13 @@ packages: resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} engines: {node: '>=8.10.0'} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + enhanced-resolve@5.24.3: resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} engines: {node: '>=10.13.0'} @@ -6621,6 +6835,9 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -6710,6 +6927,10 @@ packages: resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} engines: {node: '>=22'} + expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -6846,6 +7067,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7006,6 +7230,10 @@ packages: resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} + homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + hono@4.13.2: resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} engines: {node: '>=16.9.0'} @@ -7114,6 +7342,10 @@ packages: resolution: {integrity: sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==} engines: {node: '>=18'} + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -7243,6 +7475,11 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -8201,6 +8438,10 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + parse5@5.1.0: resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==} @@ -8479,6 +8720,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -8671,6 +8916,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + shiki@4.3.1: resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} engines: {node: '>=20'} @@ -8748,6 +8997,14 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + socks-proxy-agent@10.1.0: resolution: {integrity: sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==} engines: {node: '>= 20'} @@ -8829,6 +9086,13 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -8843,6 +9107,9 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -9484,6 +9751,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -9764,7 +10035,7 @@ snapshots: '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1113.0': + '@aws-sdk/client-s3@3.1117.0': dependencies: '@aws-sdk/checksums': 3.1000.29 '@aws-sdk/core': 3.977.8 @@ -9774,7 +10045,7 @@ snapshots: '@aws-sdk/types': 3.974.5 '@smithy/core': 3.33.3 '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.2 + '@smithy/node-http-handler': 4.11.3 '@smithy/types': 4.17.2 tslib: 2.8.1 @@ -9977,6 +10248,16 @@ snapshots: '@smithy/types': 4.17.2 tslib: 2.8.1 + '@aws-sdk/lib-storage@3.1117.0(@aws-sdk/client-s3@3.1117.0)': + dependencies: + '@aws-sdk/client-s3': 3.1117.0 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.29': dependencies: '@aws-sdk/types': 3.974.5 @@ -10025,7 +10306,7 @@ snapshots: '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1113.0': + '@aws-sdk/s3-request-presigner@3.1117.0': dependencies: '@aws-sdk/core': 3.977.8 '@aws-sdk/signature-v4-multi-region': 3.996.46 @@ -10615,6 +10896,62 @@ snapshots: dependencies: tslib: 2.8.1 + '@daytona/analytics-api-client@0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - debug + - supports-color + + '@daytona/api-client@0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - debug + - supports-color + + '@daytona/sdk@0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@aws-sdk/client-s3': 3.1117.0 + '@aws-sdk/lib-storage': 3.1117.0(@aws-sdk/client-s3@3.1117.0) + '@daytona/analytics-api-client': 0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + '@daytona/api-client': 0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + '@daytona/toolbox-api-client': 0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + '@iarna/toml': 2.2.5 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + busboy: 1.6.0 + dotenv: 17.4.2 + expand-tilde: 2.0.2 + fast-glob: 3.3.3 + form-data: 4.0.6 + isomorphic-ws: 5.0.0(ws@8.21.3) + pathe: 2.0.3 + shell-quote: 1.10.0 + socket.io-client: 4.8.3(supports-color@10.2.2) + tar: 7.5.22 + tslib: 2.8.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@daytona/toolbox-api-client@0.201.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - debug + - supports-color + '@discord/embedded-app-sdk@2.5.0': dependencies: '@types/lodash.transform': 4.6.9 @@ -10976,6 +11313,8 @@ snapshots: - '@types/node' optional: true + '@iarna/toml@2.2.5': {} + '@img/colour@1.1.0': optional: true @@ -11641,12 +11980,22 @@ snapshots: '@openclaw/uirouter@0.1.1': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.221.0': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api@1.9.1': {} + '@opentelemetry/configuration@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + '@opentelemetry/configuration@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11657,11 +12006,25 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11670,6 +12033,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11677,6 +12049,13 @@ snapshots: '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11684,6 +12063,18 @@ snapshots: '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11691,6 +12082,15 @@ snapshots: '@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11700,6 +12100,16 @@ snapshots: '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11707,6 +12117,14 @@ snapshots: '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-prometheus@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11715,6 +12133,15 @@ snapshots: '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11723,6 +12150,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11730,6 +12166,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11745,6 +12190,33 @@ snapshots: '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-zipkin@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-http@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11754,12 +12226,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.4 @@ -11768,6 +12254,16 @@ snapshots: '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11783,6 +12279,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11794,6 +12295,20 @@ snapshots: '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11808,6 +12323,45 @@ snapshots: '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/configuration': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + '@opentelemetry/sdk-node@0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11849,6 +12403,14 @@ snapshots: '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11856,6 +12418,13 @@ snapshots: '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -11863,6 +12432,13 @@ snapshots: '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oxc-project/types@0.140.0': {} @@ -12591,6 +13167,8 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -13401,6 +13979,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -13410,6 +13993,10 @@ snapshots: dependencies: run-applescript: 7.1.0 + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} cac@7.0.0: {} @@ -13489,6 +14076,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + cjs-module-lexer@2.2.1: {} + clawpdf@0.3.1: {} cliui@6.0.0: @@ -13759,6 +14348,20 @@ snapshots: encoding-japanese@2.2.0: {} + engine.io-client@6.6.6(supports-color@10.2.2): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@10.2.2) + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + enhanced-resolve@5.24.3: dependencies: graceful-fs: 4.2.11 @@ -13784,6 +14387,8 @@ snapshots: es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -13943,6 +14548,10 @@ snapshots: which-command: 0.1.0 yoctocolors: 2.2.0 + expand-tilde@2.0.2: + dependencies: + homedir-polyfill: 1.0.3 + expect-type@1.4.0: {} express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): @@ -14124,6 +14733,8 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -14339,6 +14950,10 @@ snapshots: highlight.js@11.12.0: {} + homedir-polyfill@1.0.3: + dependencies: + parse-passwd: 1.0.0 + hono@4.13.2: {} hookable@6.1.1: {} @@ -14474,6 +15089,12 @@ snapshots: es-module-lexer: 2.3.1 module-details-from-path: 1.0.4 + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 2.3.2 + module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} @@ -14569,6 +15190,10 @@ snapshots: isobject@3.0.1: {} + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -15789,6 +16414,8 @@ snapshots: parse-ms@4.0.0: {} + parse-passwd@1.0.0: {} + parse5@5.1.0: {} parse5@7.3.0: @@ -16038,6 +16665,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -16308,6 +16941,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.10.0: {} + shiki@4.3.1: dependencies: '@shikijs/core': 4.3.1 @@ -16413,6 +17048,24 @@ snapshots: smol-toml@1.8.0: {} + socket.io-client@4.8.3(supports-color@10.2.2): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@10.2.2) + engine.io-client: 6.6.6(supports-color@10.2.2) + socket.io-parser: 4.2.7(supports-color@10.2.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7(supports-color@10.2.2): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + socks-proxy-agent@10.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 @@ -16486,6 +17139,13 @@ snapshots: std-env@4.2.0: {} + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + streamsearch@1.1.0: {} + streamx@2.28.0: dependencies: events-universal: 1.0.1 @@ -16510,6 +17170,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -17097,6 +17761,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + y18n@4.0.3: {} y18n@5.0.8: {} diff --git a/scripts/lib/official-external-plugin-catalog.json b/scripts/lib/official-external-plugin-catalog.json index 68da4c8ebe0f..824006d7feb1 100644 --- a/scripts/lib/official-external-plugin-catalog.json +++ b/scripts/lib/official-external-plugin-catalog.json @@ -93,6 +93,24 @@ } } }, + { + "name": "@openclaw/daytona-sandbox", + "description": "OpenClaw Daytona cloud sandbox backend", + "source": "official", + "kind": "plugin", + "openclaw": { + "plugin": { + "id": "daytona", + "label": "Daytona Sandbox" + }, + "install": { + "clawhubSpec": "clawhub:@openclaw/daytona-sandbox", + "npmSpec": "@openclaw/daytona-sandbox", + "defaultChoice": "npm", + "minHostVersion": ">=2026.8.1" + } + } + }, { "name": "@openclaw/diagnostics-otel", "description": "OpenClaw diagnostics OpenTelemetry exporter", diff --git a/test/scripts/build-external-plugin-local-dist.test.ts b/test/scripts/build-external-plugin-local-dist.test.ts index 82eabbc24bb8..1db5f363a41f 100644 --- a/test/scripts/build-external-plugin-local-dist.test.ts +++ b/test/scripts/build-external-plugin-local-dist.test.ts @@ -13,7 +13,7 @@ describe("external plugin local dist build", () => { const packageDirs = listExternalPluginLocalDistPackageDirs(); const excludedPluginIds = collectRootPackageExcludedExtensionDirs(); - expect(packageDirs).toHaveLength(63); + expect(packageDirs).toHaveLength(64); expect(packageDirs).toEqual( expect.arrayContaining([ "extensions/diffs", @@ -21,6 +21,7 @@ describe("external plugin local dist build", () => { "extensions/slack", "extensions/sms", "extensions/mxc", + "extensions/daytona", ]), ); expect(packageDirs).not.toContain("extensions/whatsapp"); diff --git a/test/scripts/release-plan-producer.test.ts b/test/scripts/release-plan-producer.test.ts index a74bbabd774f..4fdbdd7bcbf8 100644 --- a/test/scripts/release-plan-producer.test.ts +++ b/test/scripts/release-plan-producer.test.ts @@ -1115,7 +1115,7 @@ mutateModule.syncBuiltinESMExports(); ); }); - it("matches the exact current publisher inventory: 93 npm and 89 ClawHub packages", () => { + it("matches the exact current publisher inventory: 94 npm and 90 ClawHub packages", () => { const root = tempDirs.make("openclaw-release-plan-current-"); const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: resolve("."), @@ -1141,8 +1141,8 @@ mutateModule.syncBuiltinESMExports(); const clawHubPackages = plan.inventory.packages.filter((entry) => entry.targets.includes("clawhub"), ); - expect(npmPackages).toHaveLength(93); - expect(clawHubPackages).toHaveLength(89); + expect(npmPackages).toHaveLength(94); + expect(clawHubPackages).toHaveLength(90); const coreNpmPackages = new Set([ "@openclaw/ai", "@openclaw/gateway-client",