mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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?"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json"
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1113.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1113.0",
|
||||
"@aws-sdk/client-s3": "3.1117.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1117.0",
|
||||
"@tloncorp/tlon-skill": "0.5.0",
|
||||
"@urbit/aura": "3.0.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
Generated
+677
-11
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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user