feat(sandbox): add Daytona cloud sandbox backend plugin (#121554)

* feat: @openclaw/daytona-sandbox inital version

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* feat: plugin config params extended

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* feat: implement ClawSweeper review notes

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* fix(daytona): honor abort signals and clean up remote staging on failure

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* fix(daytona): register launcher as knip entry and refresh manifest schema

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* fix(daytona): cancellable session transport and auto-stopped sandbox restart

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* fix(daytona): deny egress by default and arm launcher cleanup before startup

Signed-off-by: Mislav Ivanda <mislavivanda454@gmail.com>

* 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 <mislavivanda454@gmail.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Mislav Ivanda
2026-08-27 05:52:12 +02:00
committed by GitHub
parent 3255882803
commit 3a5cb3847c
30 changed files with 4580 additions and 29 deletions
+5
View File
@@ -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:
+4
View File
@@ -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(),
+1
View File
@@ -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"
+8 -4
View File
@@ -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:**
+169
View File
@@ -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 <sessionKey>
```
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 <sessionKey>
```
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)
+40 -6
View File
@@ -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?"
+3 -1
View File
@@ -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.
+19
View File
@@ -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
+5 -1
View File
@@ -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);
});
+50
View File
@@ -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
+26
View File
@@ -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,
});
},
});
+320
View File
@@ -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
}
}
}
+45
View File
@@ -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
}
}
}
+312
View File
@@ -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<SandboxBackendHandle["createFsBridge"]>
>[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<CreateSandboxBackendParams> {
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<string, string> },
): 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,
);
});
+826
View File
@@ -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<SandboxBackendHandle["createFsBridge"]>
>[0]["sandbox"];
type FakeSandbox = {
id: string;
name: string;
state: string;
snapshot?: string;
start: ReturnType<typeof vi.fn>;
refreshData: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
fs: { uploadFile: ReturnType<typeof vi.fn>; deleteFile: ReturnType<typeof vi.fn> };
process: {
createSession: ReturnType<typeof vi.fn>;
executeSessionCommand: ReturnType<typeof vi.fn>;
deleteSession: ReturnType<typeof vi.fn>;
};
};
type FakeClient = {
get: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
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 <T>(_label: string, run: () => Promise<T>) => 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<string> {
// 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<Pick<FakeSandbox, "id" | "state" | "snapshot">>) {
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"]>,
): 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<CreateSandboxBackendParams["cfg"]>;
registeredRuntimeIds?: readonly string[];
workspaceFiles?: Record<string, string>;
}) {
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<string, unknown>;
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<void>((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<void>((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);
});
});
+742
View File
@@ -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<string, string>;
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<string>();
// 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<string, Promise<Sandbox>>();
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<boolean> {
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<SandboxBackendHandle> {
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<Sandbox> | 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<Sandbox> {
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<Daytona> {
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<Sandbox> {
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<Sandbox | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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<SandboxBackendCommandResult> {
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<SandboxBackendCommandResult> {
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<never>((_, 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<T>(sandbox: Sandbox, run: () => Promise<T>): Promise<T> {
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<void> {
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<SandboxBackendRuntimeInfo> {
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<void> {
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));
},
};
}
+109
View File
@@ -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<DaytonaConnection> {
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<DaytonaSdkModule> | 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> {
daytonaSdkModule ??= import("@daytona/sdk");
return await daytonaSdkModule;
}
export async function createDaytonaClient(connection: DaytonaConnection): Promise<Daytona> {
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<T>(label: string, run: () => Promise<T>): Promise<T> {
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`);
}
+184
View File
@@ -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);
});
});
+260
View File
@@ -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,
};
}
@@ -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();
}
+43
View File
@@ -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(", ")}`);
}
+296
View File
@@ -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, string>) => string;
registerCleanupSignals: (
cleanup: () => Promise<unknown>,
options?: {
onSignal?: (signal: string, handler: () => void) => void;
exit?: (code: number) => void;
},
) => { interrupted: string | null };
runSessionExec: (
sandbox: {
process: {
createSession: ReturnType<typeof vi.fn>;
executeSessionCommand: ReturnType<typeof vi.fn>;
deleteSession: ReturnType<typeof vi.fn>;
};
},
payload: { command: string },
options?: {
onSignal?: (signal: string, handler: () => void) => void;
exit?: (code: number) => void;
},
) => Promise<number>;
runPtyExec: (
sandbox: {
process: {
createPty: ReturnType<typeof vi.fn>;
killPtySession: ReturnType<typeof vi.fn>;
};
},
payload: { command: string; cwd: string; env: Record<string, string> },
options?: {
onSignal?: (signal: string, handler: () => void) => void;
exit?: (code: number) => void;
stdin?: EventEmitter & { resume: () => void };
},
) => Promise<number>;
};
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<string, () => void>();
let resolveCleanup: (() => void) | undefined;
const cleanup = () => {
events.push("cleanup-start");
return new Promise<void>((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<string, () => void>();
let releaseSession: (() => void) | undefined;
const sandbox = {
process: {
createSession: vi.fn(
() =>
new Promise<void>((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<string, () => 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<void>((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<void>((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);
});
});
+113
View File
@@ -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<void> {
const rootDir = path.resolve(localDir);
const resolvedRoot = await fs.realpath(rootDir);
await walkDirectory(rootDir);
async function walkDirectory(currentDir: string): Promise<void> {
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<void> {
return new Promise<void>((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?: <T>(run: () => Promise<T>) => Promise<T>;
}): Promise<void> {
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;
}
},
);
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.package-boundary.base.json"
}
+2 -2
View File
@@ -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"
+1
View File
@@ -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/**",
+677 -11
View File
File diff suppressed because it is too large Load Diff
@@ -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",
@@ -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");
+3 -3
View File
@@ -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",