refactor(sandbox): share container backend with Podman

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
sallyom
2026-07-15 19:57:53 -04:00
parent 99a87cd822
commit 4ba02b83e8
63 changed files with 1952 additions and 1531 deletions
-6
View File
@@ -316,7 +316,6 @@
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"
- "extensions/podman/**"
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
@@ -325,7 +324,6 @@
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/install/podman.md"
- "docs/tools/multi-agent-sandbox-tools.md"
"agents":
@@ -699,10 +697,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/openshell/**"
"extensions: podman":
- changed-files:
- any-glob-to-any-file:
- "extensions/podman/**"
"extensions: parallel":
- changed-files:
- any-glob-to-any-file:
-4
View File
@@ -99,10 +99,6 @@
"source": "OpenAI provider",
"target": "OpenAI provider"
},
{
"source": "Podman plugin",
"target": "Podman 插件"
},
{
"source": "Mantis",
"target": "Mantis"
+7 -3
View File
@@ -300,12 +300,16 @@ more restrictive; a weaker duplicate claim is rejected (allow-lists are
subsets, deny-lists are supersets, required booleans are fixed).
Container posture rules (`sandbox.containers.*`) are checked only against
evidence the matched agent's sandbox backend can expose. Docker and Podman
container sandboxes expose the shared container posture settings. If a backend cannot
observe a rule you enabled for it, policy reports
evidence the matched agent's sandbox backend can expose. The Docker and Podman
backends expose the same `sandbox.docker.*` container posture settings. If a
backend cannot observe a rule you enabled for it, policy reports
`policy/sandbox-container-posture-unobservable` instead of passing; scope
container rules to the agent groups that use a backend which can expose them.
Backend authorization uses the configured identity. `backend: "docker"`
requires `allowBackends: ["docker"]`, while `backend: "podman"` requires
`allowBackends: ["podman"]`.
Top-level `ingress.session.requireDmScope` stays global; `session.dmScope` is
not channel-attributable evidence, so it cannot be scoped by `channelIds`.
+1 -1
View File
@@ -102,7 +102,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand
"defaults": {
"sandbox": {
"mode": "all", // off, non-main, all
"backend": "docker", // docker, podman, ssh, openshell (plugin-provided)
"backend": "docker", // docker, ssh, openshell (plugin-provided)
"scope": "agent", // session, agent, shared
"docker": {
"image": "openclaw-sandbox:bookworm-slim",
-8
View File
@@ -7223,14 +7223,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/podman.md
- Route: /plugins/reference/podman
- Headings:
- H1: Podman plugin
- H2: Distribution
- H2: Surface
## plugins/reference/policy.md
- Route: /plugins/reference/policy
+2 -2
View File
@@ -727,7 +727,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) | openshell | podman | ssh
backend: "docker", // docker (default) | podman | openshell | ssh
scope: "agent", // session | agent (default) | shared
workspaceAccess: "none", // none (default) | ro | rw
workspaceRoot: "~/.openclaw/sandboxes",
@@ -946,7 +946,7 @@ noVNC observer access is password-protected and brokered through a one-time, aut
</Accordion>
Browser sandboxing is Docker-only. `sandbox.docker.binds` applies to Docker and Podman container sandboxes.
Browser sandboxing requires the Docker engine. `sandbox.docker.binds` applies to both the Docker and Podman backends.
Build images (from a source checkout):
+42 -40
View File
@@ -26,11 +26,11 @@ Not sandboxed:
Three independent settings control sandbox behavior:
| Setting | Key | Values | Default |
| ------- | --------------------------------- | ---------------------------- | -------- |
| Mode | `agents.defaults.sandbox.mode` | `off`, `non-main`, `all` | `off` |
| Scope | `agents.defaults.sandbox.scope` | `agent`, `session`, `shared` | `agent` |
| Backend | `agents.defaults.sandbox.backend` | `docker`, `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` | `docker` |
**Mode** controls when sandboxing applies:
@@ -48,17 +48,17 @@ 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. 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`.
| | Docker | Podman | SSH | OpenShell |
| ------------------- | -------------------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- |
| **Where it runs** | Local container | Local Podman container | Any SSH-accessible host | OpenShell managed sandbox |
| **Setup** | `scripts/sandbox-setup.sh` | Podman plugin + sandbox image | SSH key + target host | OpenShell plugin enabled |
| **Workspace model** | Bind-mount or copy | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` |
| **Network control** | `docker.network` (default: none) | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell |
| **Browser sandbox** | Supported | Not supported yet | Not supported | Not supported yet |
| **Bind mounts** | `docker.binds` | `docker.binds` | N/A | N/A |
| **Best for** | Local dev, full isolation | Rootless/local Podman installs | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync |
| | Docker or Podman backend | SSH | OpenShell |
| ------------------- | ----------------------------------------- | ------------------------------ | --------------------------------------------------- |
| **Where it runs** | Local Docker or Podman container | Any SSH-accessible host | OpenShell managed sandbox |
| **Setup** | Docker and/or Podman | SSH key + target host | OpenShell plugin enabled |
| **Workspace model** | Bind-mount or copy | Remote-canonical (seed once) | `mirror` or `remote` |
| **Network control** | `docker.network` (default: none) | Depends on remote host | Depends on OpenShell |
| **Browser sandbox** | Docker engine only | Not supported | Not supported yet |
| **Bind mounts** | `docker.binds` | N/A | N/A |
| **Best for** | Local development and container isolation | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync |
## Supported capability matrix
@@ -85,7 +85,7 @@ and [Plugin execution model](/plugins/architecture#execution-model).
## Docker backend
Docker is the default backend once sandboxing is enabled. It runs tools and sandbox browsers locally through the Docker daemon socket (`/var/run/docker.sock`); isolation comes from Docker namespaces.
The Docker backend runs tools locally through the `docker` CLI. Its selection and error behavior are unchanged; it does not probe or fall back to Podman.
Defaults: `network: "none"` (no egress), `readOnlyRoot: true`, `capDrop: ["ALL"]`, image `openclaw-sandbox:bookworm-slim`.
@@ -119,7 +119,7 @@ OpenClaw also creates Docker sandbox containers with an init process and
mounted read-only at `/agent`; write operations to the agent workspace are
rejected, while the configured tmpfs paths remain writable.
To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to Docker's `--gpus` flag and requires a compatible host runtime such as NVIDIA Container Toolkit.
To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to the selected container engine's Docker-compatible `--gpus` flag and requires compatible host GPU setup.
<Warning>
**Docker-out-of-Docker (DooD) constraints**
@@ -145,9 +145,11 @@ On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `wor
## Podman backend
Use `backend: "podman"` when you want local container sandboxing through Podman instead of Docker. The Podman sandbox backend is implemented as a bundled plugin and reuses the same `sandbox.docker.*` container settings as the Docker backend: image, workdir, network, read-only root, tmpfs, capabilities, environment, resource limits, custom binds, and setup command.
Use `sandbox.backend: "podman"` to select the native `podman` CLI directly. This is a built-in backend, not a plugin. It does not probe or select Docker, even when the `docker` executable is installed.
For rootless Podman, the backend creates default-user containers with Podman's `keep-id` user namespace and the current uid/gid so writable workspace bind mounts remain writable from inside the sandbox. If you set `sandbox.docker.user`, OpenClaw leaves that user mapping to your Podman/image configuration.
Podman reuses the existing `sandbox.docker.*` settings and the active native `podman` CLI context; it adds no separate connection configuration surface.
Rootless Podman defaults to `--userns=keep-id` for writable workspace mounts. A long-lived sandbox can reserve subordinate IDs and block unrelated `--userns=auto` workloads; remove it before starting those workloads. Set `sandbox.docker.user` to control the container user; rootful Podman otherwise uses the workspace owner when available.
```json5
{
@@ -167,33 +169,31 @@ For rootless Podman, the backend creates default-user containers with Podman's `
},
},
},
plugins: {
entries: {
podman: {
enabled: true,
config: {
command: "podman",
// Optional: use one Podman remote connection style.
// connection: "dev",
// url: "unix:///run/user/1000/podman/podman.sock",
},
},
},
},
}
```
Build or pull the sandbox image into the Podman store selected by the plugin config before enabling the backend. From a source checkout, the same Dockerfile can be built with Podman:
Build or pull the sandbox image into the selected Podman store before enabling the backend. From a source checkout, build the same sandbox Dockerfile with Podman:
```bash
podman build -t openclaw-sandbox:bookworm-slim -f scripts/docker/sandbox/Dockerfile .
```
Podman backend limits:
Podman notes:
- Browser sandboxing is not supported yet; keep `sandbox.browser.enabled` off or use the Docker backend.
- `sandbox.docker.gpus` is Docker-only; Podman GPU/device setup is host-specific and should be handled with a custom image/runtime outside this backend for now.
- `connection` and `url` are mutually exclusive plugin config options because Podman accepts only one remote target selection for a command invocation.
- Browser sandboxing is not supported by Podman; keep `sandbox.browser.enabled` off, or install Docker and select `backend: "docker"`.
- Local Podman engines and Podman Machine are supported. Podman Machine bind sources must be under the host home directory, which is its default shared volume. Arbitrary remote Podman connections are rejected; use the SSH backend for remote execution.
- Custom `tmpfs` or bind mounts must not cover `/run/podman-init`; OpenClaw rejects them so sandbox cleanup continues to work.
<Warning>
**Podman-outside-of-Podman constraints**
A containerized Gateway creates sibling sandboxes through the host's local Podman engine or Podman Machine.
- **Use host paths consistently**: configure `workspace` with its host absolute path, then mount the complete state root and workspace into the Gateway at those same paths. Otherwise the sandbox may mount the workspace while the Gateway cannot write heartbeat or skill-workspace files.
- **Podman Machine setup**: bind sources must be under the host home directory. Set the Gateway `HOME` to that path and point `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, and `OPENCLAW_CONFIG_DIR` at the canonical mounted state root. The image needs a compatible Podman client, its named connection and SSH identity, plus a dedicated writable SSH directory for known-host metadata.
- **Keep Podman access Gateway-only**: never mount the engine socket, connection material, or SSH identity into agent sandboxes. Arbitrary remote connections are unsupported; use the SSH backend instead.
</Warning>
## SSH backend
@@ -459,7 +459,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
</Step>
</Steps>
By default, Docker sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`.
By default, local container sandboxes run with **no network**. Override with `agents.defaults.sandbox.docker.network`.
<Note>
Package installation and certificate-store changes are image provisioning, not
@@ -523,9 +523,11 @@ Paths:
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `docker.network: "container:<id>"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
- `user` must be root for package installs. Docker can omit `user` or set
`user: "0:0"`; rootless Podman must set `user: "0:0"` because omission
defaults to the invoking user through `--userns=keep-id`.
- Sandbox exec does **not** inherit host `process.env`. Use `agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys.
- Values in `agents.defaults.sandbox.docker.env` are passed as explicit Docker container environment variables. Anyone with Docker daemon access can inspect them with Docker metadata commands such as `docker inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable.
- Values in `agents.defaults.sandbox.docker.env` are passed as explicit container environment variables. Anyone with access to the selected container engine can inspect them with metadata commands such as `docker inspect` or `podman inspect`. Use a custom image, mounted secret file, or another secret delivery path if that metadata exposure is not acceptable.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -351,7 +351,7 @@ Dedicated doc: [Sandboxing](/gateway/sandboxing)
Two complementary approaches:
- **Full Gateway in Docker** (container boundary): [Docker](/install/docker)
- **Tool sandbox** (`agents.defaults.sandbox`; host gateway + sandbox-isolated tools; Docker is the default backend): [Sandboxing](/gateway/sandboxing)
- **Tool sandbox** (`agents.defaults.sandbox`; host gateway + sandbox-isolated tools; built-in Docker and Podman backends): [Sandboxing](/gateway/sandboxing)
<Note>
To prevent cross-agent access, keep `agents.defaults.sandbox.scope` at `"agent"` (default) or use `"session"` for stricter per-session isolation. `scope: "shared"` uses a single container or workspace.
+1 -1
View File
@@ -394,7 +394,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
</Accordion>
<Accordion title="Can I keep DMs personal but make groups public/sandboxed with one agent?">
Yes, if private traffic is **DMs** and public traffic is **groups**. Set `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in the configured sandbox backend while the main DM session stays on-host. Docker is the default backend once sandboxing is enabled. Restrict tools available in sandboxed sessions via `tools.sandbox.tools`.
Yes, if private traffic is **DMs** and public traffic is **groups**. Set `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in the configured sandbox backend while the main DM session stays on-host. Select `backend: "docker"` for Docker or `backend: "podman"` for Podman. Restrict tools available in sandboxed sessions via `tools.sandbox.tools`.
Setup walkthrough: [Groups: personal DMs + public groups](/channels/groups#pattern-personal-dms-public-groups-single-agent). Key reference: [Gateway configuration](/gateway/config-agents#agentsdefaultssandbox).
+1 -1
View File
@@ -8,7 +8,7 @@ title: "Docker"
Docker is **optional**. Use it for an isolated, throwaway gateway environment or a host without local installs. If you already develop on your own machine, use the normal install flow instead.
The default sandbox backend uses Docker when `agents.defaults.sandbox` is enabled, but sandboxing is off by default and does not require the gateway itself to run in Docker. Podman, SSH, and OpenShell sandbox backends are also available; see [Sandboxing](/gateway/sandboxing).
The default Docker sandbox backend uses only the `docker` CLI. Set the backend to `"podman"` to select native Podman directly. Sandboxing is off by default and does not require the gateway itself to run in a container. SSH and OpenShell sandbox backends are also available; see [Sandboxing](/gateway/sandboxing).
Hosting multiple users? See [Multi-tenant hosting](/gateway/multi-tenant-hosting) for the one-cell-per-tenant model.
+2 -2
View File
@@ -92,9 +92,9 @@ The manual launcher reads only a small allowlist of Podman-related keys from `~/
## Agent sandbox backend
This page covers running the Gateway itself in a Podman container. Agent sandboxing is separate: set `agents.defaults.sandbox.backend: "podman"` when you want OpenClaw to run sandboxed agent tools in local Podman containers.
This page covers running the Gateway itself in a Podman container. Agent sandboxing is separate. Set `agents.defaults.sandbox.backend: "podman"` to select the native Podman CLI directly. The default `"docker"` backend remains Docker-only.
The Podman sandbox backend ships as a bundled plugin. It reuses the same `agents.defaults.sandbox.docker.*` container settings as the Docker backend, but executes them through the `podman` CLI and optional Podman remote `connection` or `url` plugin config. Browser sandboxes and `sandbox.docker.gpus` remain Docker-only for now.
Podman reuses the same `agents.defaults.sandbox.docker.*` container settings as Docker but executes them through the native `podman` CLI. Browser sandboxes remain Docker-only for now.
See [Sandboxing](/gateway/sandboxing#podman-backend) for the config example and image-build command.
-19
View File
@@ -1,19 +0,0 @@
---
summary: "OpenClaw sandbox backend for rootless Podman containers."
read_when:
- You are installing, configuring, or auditing the podman plugin
title: "Podman plugin"
---
# Podman plugin
OpenClaw sandbox backend for rootless Podman containers.
## Distribution
- Package: `@openclaw/podman-sandbox`
- Install route: included in OpenClaw
## Surface
plugin
+1 -1
View File
@@ -265,7 +265,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/tool-payload` | Private-local after July 2026; Extract normalized payloads from tool result objects |
| `plugin-sdk/tool-results` | Typed text and JSON agent tool result builders |
| `plugin-sdk/tool-send` | Extract canonical send target fields from tool args |
| `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types, container backend helper builders, and SSH/OpenShell command helpers, including fail-fast exec command preflight |
| `plugin-sdk/sandbox` | Private-local after July 2026; Sandbox backend types and SSH/OpenShell command helpers, including fail-fast exec command preflight |
| `plugin-sdk/temp-path` | Shared temp-download path helpers and private secure temp workspaces |
| `plugin-sdk/logging-core` | Subsystem logger and redaction helpers |
| `plugin-sdk/markdown-table-runtime` | Private-local after July 2026; Markdown table mode and conversion helpers |
+1 -1
View File
@@ -189,7 +189,7 @@ agents.entries.*.sandbox.prune.* > agents.defaults.sandbox.prune.*
```
<Note>
`agents.entries.*.sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). The `docker` block configures Docker-compatible container settings used by Docker and Podman backends.
`agents.entries.*.sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). The `docker` block configures both built-in container backends.
</Note>
### Tool restrictions
+1
View File
@@ -128,6 +128,7 @@ function createSandboxBackendTestConfig(
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/workspace-root",
dockerTmpfsSource: "configured",
docker: {
binds: [],
capDrop: [],
@@ -520,6 +520,7 @@ describe("openshell sandbox backend e2e", () => {
scope: "session" as const,
workspaceAccess: "rw" as const,
workspaceRoot: path.join(rootDir, "sandboxes"),
dockerTmpfsSource: "configured" as const,
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
@@ -49,6 +49,7 @@ function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
dockerTmpfsSource: "configured",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
@@ -739,6 +739,7 @@ function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
dockerTmpfsSource: "configured",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
-25
View File
@@ -1,25 +0,0 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import {
createPodmanSandboxBackendFactory,
createPodmanSandboxBackendManager,
} from "./src/backend.js";
import { createPodmanPluginConfigSchema, resolvePodmanPluginConfig } from "./src/config.js";
export default definePluginEntry({
id: "podman",
name: "Podman Sandbox",
description: "OpenClaw sandbox backend for rootless Podman containers.",
configSchema: createPodmanPluginConfigSchema(),
register(api) {
if (api.registrationMode !== "full") {
return;
}
const pluginConfig = resolvePodmanPluginConfig(api.pluginConfig);
registerSandboxBackend("podman", {
factory: createPodmanSandboxBackendFactory({ pluginConfig }),
manager: createPodmanSandboxBackendManager({ pluginConfig }),
resolveWorkdir: ({ cfg }) => cfg.docker.workdir,
});
},
});
-42
View File
@@ -1,42 +0,0 @@
{
"id": "podman",
"activation": {
"onStartup": true
},
"name": "Podman Sandbox",
"description": "OpenClaw sandbox backend for rootless Podman containers.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "string",
"minLength": 1
},
"connection": {
"type": "string",
"minLength": 1
},
"url": {
"type": "string",
"minLength": 1
}
}
},
"uiHints": {
"command": {
"label": "Podman Command",
"help": "Path or command name for the Podman CLI."
},
"connection": {
"label": "Podman Connection",
"help": "Optional Podman connection name passed with --connection.",
"advanced": true
},
"url": {
"label": "Podman URL",
"help": "Optional Podman service URL passed with --url.",
"advanced": true
}
}
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@openclaw/podman-sandbox",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw sandbox backend for rootless Podman containers.",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
-306
View File
@@ -1,306 +0,0 @@
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
import { beforeEach, describe, expect, it, vi } from "vitest";
type SandboxConfig = CreateSandboxBackendParams["cfg"];
const podmanMocks = vi.hoisted(() => ({
execPodman: vi.fn(),
execPodmanRaw: vi.fn(),
podmanContainerState: vi.fn(),
readPodmanContainerLabel: vi.fn(),
}));
const sdkMocks = vi.hoisted(() => ({
readSandboxRegistryEntry: vi.fn(),
}));
vi.mock("./podman.js", async () => {
const actual = await vi.importActual<typeof import("./podman.js")>("./podman.js");
return {
...actual,
execPodman: podmanMocks.execPodman,
execPodmanRaw: podmanMocks.execPodmanRaw,
podmanContainerState: podmanMocks.podmanContainerState,
readPodmanContainerLabel: podmanMocks.readPodmanContainerLabel,
};
});
vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sandbox")>();
return {
...actual,
readSandboxRegistryEntry: sdkMocks.readSandboxRegistryEntry,
};
});
const { createPodmanSandboxBackendFactory } = await import("./backend.js");
function createSandboxConfig(overrides: Partial<SandboxConfig> = {}): SandboxConfig {
return {
mode: "all",
backend: "podman",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
ssh: {
command: "ssh",
workspaceRoot: "/tmp/openclaw-sandboxes",
strictHostKeyChecking: true,
updateHostKeys: true,
},
browser: {
enabled: false,
image: "openclaw-sandbox-browser:bookworm-slim",
containerPrefix: "openclaw-sbx-browser-",
network: "openclaw-sandbox-browser",
cdpPort: 9222,
vncPort: 5900,
noVncPort: 6080,
headless: true,
enableNoVnc: true,
allowHostControl: false,
autoStart: true,
autoStartTimeoutMs: 12_000,
},
tools: {
allow: [],
deny: [],
},
prune: {
idleHours: 24,
maxAgeDays: 7,
},
...overrides,
};
}
function createParams(
overrides: Partial<CreateSandboxBackendParams> = {},
): CreateSandboxBackendParams {
const cfg = overrides.cfg ?? createSandboxConfig();
return {
sessionKey: "agent:main:podman-test",
scopeKey: "agent:main:podman-test",
workspaceDir: "/tmp/openclaw-sandbox-workspace",
agentWorkspaceDir: "/tmp/openclaw-agent-workspace",
cfg,
...overrides,
};
}
function commandCalls() {
return podmanMocks.execPodman.mock.calls.map((call) => call[1] as string[]);
}
function expectedKeepIdUserArgs() {
return typeof process.getuid === "function" && typeof process.getgid === "function"
? ["--userns", "keep-id", "--user", `${process.getuid()}:${process.getgid()}`]
: [];
}
describe("Podman sandbox backend", () => {
beforeEach(() => {
podmanMocks.execPodman.mockReset();
podmanMocks.execPodmanRaw.mockReset();
podmanMocks.podmanContainerState.mockReset();
podmanMocks.readPodmanContainerLabel.mockReset();
sdkMocks.readSandboxRegistryEntry.mockReset();
podmanMocks.execPodman.mockResolvedValue({ stdout: "", stderr: "", code: 0 });
podmanMocks.execPodmanRaw.mockResolvedValue({
stdout: Buffer.from(""),
stderr: Buffer.from(""),
code: 0,
});
podmanMocks.podmanContainerState.mockResolvedValue({ exists: false, running: false });
podmanMocks.readPodmanContainerLabel.mockResolvedValue(null);
sdkMocks.readSandboxRegistryEntry.mockResolvedValue(null);
});
it("creates and starts a missing Podman sandbox container", async () => {
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
const backend = await factory(createParams());
const calls = commandCalls();
const createArgs = calls.find((args) => args[0] === "create");
expect(backend.id).toBe("podman");
expect(backend.workdir).toBe("/workspace");
expect(createArgs).toEqual(
expect.arrayContaining([
"create",
"--name",
expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/),
"--read-only",
"--network",
"none",
"--cap-drop",
"ALL",
...expectedKeepIdUserArgs(),
"-v",
"/tmp/openclaw-sandbox-workspace:/workspace:z",
"-v",
"/tmp/openclaw-agent-workspace:/agent:z",
"openclaw-sandbox:bookworm-slim",
"sleep",
"infinity",
]),
);
expect(calls.some((args) => args[0] === "start")).toBe(true);
});
it("does not override an explicitly configured sandbox user", async () => {
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await factory(
createParams({
cfg: createSandboxConfig({
docker: { ...createSandboxConfig().docker, user: "1001:1001" },
}),
}),
);
const createArgs = commandCalls().find((args) => args[0] === "create");
expect(createArgs).toEqual(expect.arrayContaining(["--user", "1001:1001"]));
expect(createArgs).not.toContain("keep-id");
});
it("recreates a stopped existing container when the config hash label does not match", async () => {
podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: false });
podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash");
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await factory(createParams());
const calls = commandCalls();
expect(calls).toContainEqual([
"rm",
"-f",
expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/),
]);
expect(calls.some((args) => args[0] === "create")).toBe(true);
});
it("preserves a running existing container when the config hash label does not match", async () => {
podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true });
podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash");
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await factory(createParams());
const calls = commandCalls();
expect(calls.some((args) => args[0] === "rm")).toBe(false);
expect(calls.some((args) => args[0] === "create")).toBe(false);
});
it("recreates an old running container when the config hash label does not match", async () => {
podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true });
podmanMocks.readPodmanContainerLabel.mockResolvedValue("old-hash");
sdkMocks.readSandboxRegistryEntry.mockResolvedValue({ lastUsedAtMs: 1 });
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await factory(createParams());
const calls = commandCalls();
expect(calls).toContainEqual([
"rm",
"-f",
expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/),
]);
expect(calls.some((args) => args[0] === "create")).toBe(true);
});
it("reuses a matching running container", async () => {
let capturedHash = "";
podmanMocks.execPodman.mockImplementation(async (_config, args: string[]) => {
if (args[0] === "create") {
capturedHash =
args
.find((arg) => arg.startsWith("openclaw.configHash="))
?.slice("openclaw.configHash=".length) ?? "";
}
return { stdout: "", stderr: "", code: 0 };
});
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await factory(createParams());
podmanMocks.execPodman.mockClear();
podmanMocks.podmanContainerState.mockResolvedValue({ exists: true, running: true });
podmanMocks.readPodmanContainerLabel.mockResolvedValue(capturedHash);
await factory(createParams());
const calls = commandCalls();
expect(calls.some((args) => args[0] === "create")).toBe(false);
expect(calls.some((args) => args[0] === "rm")).toBe(false);
});
it("builds exec specs with Podman remote options", async () => {
const factory = createPodmanSandboxBackendFactory({
pluginConfig: { command: "podman", connection: "dev" },
});
const backend = await factory(createParams());
const spec = await backend.buildExecSpec({
command: "pwd",
env: { PATH: "/usr/local/bin" },
usePty: true,
});
expect(spec.argv.slice(0, 4)).toEqual(["podman", "--connection", "dev", "exec"]);
expect(spec.argv).toContain("-t");
expect(spec.argv).toContain("OPENCLAW_PREPEND_PATH=/usr/local/bin");
});
it("routes filesystem bridge shell commands through podman exec", async () => {
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
const backend = await factory(createParams());
await backend.runShellCommand({ script: 'stat -c %s "$1"', args: ["/workspace/file.txt"] });
expect(podmanMocks.execPodmanRaw).toHaveBeenCalledWith(
{ command: "podman" },
[
"exec",
"-i",
expect.stringMatching(/^openclaw-sbx-agent-main-podman-test-/),
"sh",
"-c",
'stat -c %s "$1"',
"openclaw-sandbox-fs",
"/workspace/file.txt",
],
expect.objectContaining({ allowFailure: undefined }),
);
});
it("fails closed for unsupported browser and GPU options", async () => {
const factory = createPodmanSandboxBackendFactory({ pluginConfig: { command: "podman" } });
await expect(
factory(
createParams({
cfg: createSandboxConfig({
browser: { ...createSandboxConfig().browser, enabled: true },
}),
}),
),
).rejects.toThrow(/browser sandboxes/i);
await expect(
factory(
createParams({
cfg: createSandboxConfig({
docker: { ...createSandboxConfig().docker, gpus: "all" },
}),
}),
),
).rejects.toThrow(/does not support sandbox\.docker\.gpus/i);
});
});
-350
View File
@@ -1,350 +0,0 @@
import {
appendReadOnlyWorkspaceSkillMountArgs,
appendWorkspaceMountArgs,
buildDockerExecArgs,
buildSandboxCreateArgs,
computeSandboxConfigHash,
formatReadOnlyWorkspaceSkillMountHashState,
readSandboxRegistryEntry,
resolveDockerEnvPolicyEpoch,
resolveReadOnlyWorkspaceSkillMounts,
resolveSandboxConfigForAgent,
SANDBOX_MOUNT_FORMAT_VERSION,
slugifySessionKey,
type CreateSandboxBackendParams,
type SandboxBackendCommandParams,
type SandboxBackendFactory,
type SandboxBackendHandle,
type SandboxBackendManager,
} from "openclaw/plugin-sdk/sandbox";
import type { PodmanPluginConfig } from "./config.js";
import {
execPodman,
execPodmanRaw,
podmanContainerState,
readPodmanContainerLabel,
} from "./podman.js";
type PodmanSandboxBackendParams = {
pluginConfig: PodmanPluginConfig;
};
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
const PODMAN_USERNS_CONFIG_EPOCH = "podman-keep-id-v1";
function buildContainerName(params: CreateSandboxBackendParams): string {
const scopeKey =
params.cfg.scope === "shared"
? "shared"
: params.cfg.scope === "session"
? params.sessionKey
: params.scopeKey;
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
return `${params.cfg.docker.containerPrefix}${slug}`.slice(0, 63);
}
function assertSupportedPodmanConfig(params: CreateSandboxBackendParams): void {
if (params.cfg.docker.gpus) {
throw new Error(
'Podman sandbox backend does not support sandbox.docker.gpus. Use backend "docker" for Docker GPU runtime support, or configure GPU devices through a custom Podman image/runtime outside this backend.',
);
}
if (params.cfg.browser.enabled) {
throw new Error("Podman sandbox backend does not support browser sandboxes yet.");
}
}
async function ensurePodmanImage(config: PodmanPluginConfig, image: string): Promise<void> {
const result = await execPodman(config, ["image", "inspect", image], { allowFailure: true });
if (result.code === 0) {
return;
}
const detail = result.stderr.trim() || result.stdout.trim();
throw new Error(
`Podman sandbox image not found: ${image}. Build or pull it into the selected Podman store first.${detail ? ` Podman said: ${detail}` : ""}`,
);
}
function appendCustomBinds(args: string[], binds: readonly string[] | undefined): void {
for (const bind of binds ?? []) {
args.push("-v", bind);
}
}
function currentUserForKeepId(): string | null {
const getuid = process.getuid;
const getgid = process.getgid;
if (typeof getuid !== "function" || typeof getgid !== "function") {
return null;
}
return `${getuid()}:${getgid()}`;
}
function appendPodmanUserNamespaceArgs(args: string[], cfg: CreateSandboxBackendParams["cfg"]) {
if (cfg.docker.user) {
return;
}
const user = currentUserForKeepId();
if (!user) {
return;
}
// Rootless Podman needs keep-id so the sandbox can write workspace bind mounts.
// When callers set docker.user explicitly, that ownership contract is theirs.
args.push("--userns", "keep-id", "--user", user);
}
function computePodmanConfigHash(params: CreateSandboxBackendParams): string {
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
skillsWorkspaceDir: params.skillsWorkspaceDir,
workdir: params.cfg.docker.workdir,
workspaceAccess: params.cfg.workspaceAccess,
});
const genericHash = computeSandboxConfigHash({
docker: params.cfg.docker,
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env),
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
readOnlyWorkspaceSkillMounts: formatReadOnlyWorkspaceSkillMountHashState(
readOnlyWorkspaceSkillMounts,
),
});
return `${genericHash}:${PODMAN_USERNS_CONFIG_EPOCH}`;
}
async function createPodmanContainer(params: {
pluginConfig: PodmanPluginConfig;
createParams: CreateSandboxBackendParams;
containerName: string;
configHash: string;
}) {
const { createParams } = params;
await ensurePodmanImage(params.pluginConfig, createParams.cfg.docker.image);
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
workspaceDir: createParams.workspaceDir,
agentWorkspaceDir: createParams.agentWorkspaceDir,
skillsWorkspaceDir: createParams.skillsWorkspaceDir,
workdir: createParams.cfg.docker.workdir,
workspaceAccess: createParams.cfg.workspaceAccess,
});
const args = buildSandboxCreateArgs({
name: params.containerName,
cfg: createParams.cfg.docker,
scopeKey: createParams.scopeKey,
configHash: params.configHash,
includeBinds: false,
bindSourceRoots: [createParams.workspaceDir, createParams.agentWorkspaceDir],
});
appendPodmanUserNamespaceArgs(args, createParams.cfg);
args.push("--workdir", createParams.cfg.docker.workdir);
appendWorkspaceMountArgs({
args,
workspaceDir: createParams.workspaceDir,
agentWorkspaceDir: createParams.agentWorkspaceDir,
skillsWorkspaceDir: createParams.skillsWorkspaceDir,
workdir: createParams.cfg.docker.workdir,
workspaceAccess: createParams.cfg.workspaceAccess,
readOnlyWorkspaceSkillMounts,
includeReadOnlyWorkspaceSkillMounts: false,
});
appendCustomBinds(args, createParams.cfg.docker.binds);
appendReadOnlyWorkspaceSkillMountArgs({
args,
readOnlyWorkspaceSkillMounts,
});
args.push(createParams.cfg.docker.image, "sleep", "infinity");
await execPodman(params.pluginConfig, args);
await execPodman(params.pluginConfig, ["start", params.containerName]);
const setupCommand = createParams.cfg.docker.setupCommand?.trim();
if (setupCommand) {
await execPodman(params.pluginConfig, [
"exec",
"-i",
params.containerName,
"/bin/sh",
"-lc",
setupCommand,
]);
}
}
async function ensurePodmanContainer(params: {
pluginConfig: PodmanPluginConfig;
createParams: CreateSandboxBackendParams;
}): Promise<string> {
assertSupportedPodmanConfig(params.createParams);
const containerName = buildContainerName(params.createParams);
const expectedHash = computePodmanConfigHash(params.createParams);
const state = await podmanContainerState(params.pluginConfig, containerName);
let hasContainer = state.exists;
const running = state.running;
if (hasContainer) {
const currentHash = await readPodmanContainerLabel(
params.pluginConfig,
containerName,
"openclaw.configHash",
);
if (currentHash !== expectedHash) {
const registryEntry = (await readSandboxRegistryEntry(containerName)) ?? undefined;
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
const isHot =
running &&
(typeof lastUsedAtMs !== "number" || Date.now() - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
if (isHot) {
// Match Docker's hot-container contract: keep recently used runtimes
// alive so config drift does not kill active agent work mid-turn.
return containerName;
}
await execPodman(params.pluginConfig, ["rm", "-f", containerName], {
allowFailure: true,
});
hasContainer = false;
}
}
if (!hasContainer) {
await createPodmanContainer({
pluginConfig: params.pluginConfig,
createParams: params.createParams,
containerName,
configHash: expectedHash,
});
} else if (!running) {
await execPodman(params.pluginConfig, ["start", containerName]);
}
return containerName;
}
function createPodmanSandboxBackendHandle(params: {
pluginConfig: PodmanPluginConfig;
containerName: string;
workdir: string;
env?: Record<string, string>;
image: string;
}): SandboxBackendHandle {
return {
id: "podman",
runtimeId: params.containerName,
runtimeLabel: params.containerName,
workdir: params.workdir,
env: params.env,
configLabel: params.image,
configLabelKind: "Image",
async buildExecSpec({ command, workdir, env, usePty }) {
const invocation = {
command: params.pluginConfig.command,
args: buildDockerExecArgs({
containerName: params.containerName,
command,
workdir: workdir ?? params.workdir,
env,
tty: usePty,
}),
};
if (params.pluginConfig.connection) {
invocation.args.unshift("--connection", params.pluginConfig.connection);
}
if (params.pluginConfig.url) {
invocation.args.unshift("--url", params.pluginConfig.url);
}
return {
argv: [invocation.command, ...invocation.args],
env: process.env,
stdinMode: usePty ? "pipe-open" : "pipe-closed",
};
},
runShellCommand(command) {
return runPodmanSandboxShellCommand({
pluginConfig: params.pluginConfig,
containerName: params.containerName,
...command,
});
},
};
}
export function runPodmanSandboxShellCommand(
params: {
pluginConfig: PodmanPluginConfig;
containerName: string;
} & SandboxBackendCommandParams,
) {
const podmanArgs = [
"exec",
"-i",
params.containerName,
"sh",
"-c",
params.script,
"openclaw-sandbox-fs",
];
if (params.args?.length) {
podmanArgs.push(...params.args);
}
return execPodmanRaw(params.pluginConfig, podmanArgs, {
input: params.stdin,
allowFailure: params.allowFailure,
signal: params.signal,
});
}
export function createPodmanSandboxBackendFactory(
params: PodmanSandboxBackendParams,
): SandboxBackendFactory {
return async (createParams) => {
const containerName = await ensurePodmanContainer({
pluginConfig: params.pluginConfig,
createParams,
});
return createPodmanSandboxBackendHandle({
pluginConfig: params.pluginConfig,
containerName,
workdir: createParams.cfg.docker.workdir,
env: createParams.cfg.docker.env,
image: createParams.cfg.docker.image,
});
};
}
export function createPodmanSandboxBackendManager(params: {
pluginConfig: PodmanPluginConfig;
}): SandboxBackendManager {
return {
async describeRuntime({ entry, config, agentId }) {
const state = await podmanContainerState(params.pluginConfig, entry.containerName);
let actualConfigLabel = entry.image;
if (state.exists) {
const result = await execPodman(
params.pluginConfig,
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualConfigLabel = result.stdout.trim() || actualConfigLabel;
}
}
const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image;
return {
running: state.running,
actualConfigLabel,
configLabelMatch: actualConfigLabel === configuredImage,
};
},
async removeRuntime({ entry }) {
const result = await execPodman(params.pluginConfig, ["rm", "-f", entry.containerName], {
allowFailure: true,
});
if (result.code !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
if (/no such (container|object)|does not exist/iu.test(detail)) {
return;
}
throw new Error(
`Failed to remove Podman sandbox runtime ${entry.containerName}: ${detail}`,
);
}
},
};
}
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolvePodmanPluginConfig } from "./config.js";
describe("resolvePodmanPluginConfig", () => {
it("defaults to the podman command", () => {
expect(resolvePodmanPluginConfig(undefined)).toEqual({ command: "podman" });
});
it("trims command and optional remote selectors", () => {
expect(
resolvePodmanPluginConfig({
command: " /usr/bin/podman ",
connection: " dev ",
}),
).toEqual({
command: "/usr/bin/podman",
connection: "dev",
});
});
it("rejects connection and url together", () => {
expect(() =>
resolvePodmanPluginConfig({
connection: "dev",
url: "unix:///run/user/1000/podman/podman.sock",
}),
).toThrow(/cannot set both connection and url/i);
});
});
-44
View File
@@ -1,44 +0,0 @@
import { buildJsonPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
export type PodmanPluginConfig = {
command: string;
connection?: string;
url?: string;
};
export type PodmanPluginConfigInput = {
command?: unknown;
connection?: unknown;
url?: unknown;
};
export function createPodmanPluginConfigSchema() {
return buildJsonPluginConfigSchema({
type: "object",
additionalProperties: false,
properties: {
command: { type: "string", minLength: 1 },
connection: { type: "string", minLength: 1 },
url: { type: "string", minLength: 1 },
},
});
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function resolvePodmanPluginConfig(input: unknown): PodmanPluginConfig {
const config = (input && typeof input === "object" ? input : {}) as PodmanPluginConfigInput;
const command = readOptionalString(config.command) ?? "podman";
const connection = readOptionalString(config.connection);
const url = readOptionalString(config.url);
if (connection && url) {
throw new Error("Podman sandbox config cannot set both connection and url.");
}
return {
command,
...(connection ? { connection } : {}),
...(url ? { url } : {}),
};
}
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildPodmanInvocation } from "./podman.js";
describe("buildPodmanInvocation", () => {
it("uses the configured command and appends args", () => {
expect(
buildPodmanInvocation({
config: { command: "/opt/bin/podman" },
args: ["inspect", "openclaw-sbx-main"],
}),
).toEqual({
command: "/opt/bin/podman",
args: ["inspect", "openclaw-sbx-main"],
});
});
it("places remote options before the subcommand", () => {
expect(
buildPodmanInvocation({
config: { command: "podman", url: "unix:///run/user/1000/podman/podman.sock" },
args: ["exec", "sandbox", "true"],
}).args,
).toEqual(["--url", "unix:///run/user/1000/podman/podman.sock", "exec", "sandbox", "true"]);
});
});
-195
View File
@@ -1,195 +0,0 @@
import { spawn } from "node:child_process";
import type { PodmanPluginConfig } from "./config.js";
export type ExecPodmanRawOptions = {
allowFailure?: boolean;
input?: Buffer | string;
signal?: AbortSignal;
};
export type ExecPodmanRawResult = {
stdout: Buffer;
stderr: Buffer;
code: number;
};
type ExecPodmanRawError = Error & {
code: number;
stdout: Buffer;
stderr: Buffer;
};
function createAbortError(message: string): Error {
const error = new Error(message);
error.name = "AbortError";
return error;
}
export function buildPodmanInvocation(params: {
config: PodmanPluginConfig;
args: readonly string[];
}): { command: string; args: string[] } {
const args: string[] = [];
if (params.config.connection) {
args.push("--connection", params.config.connection);
}
if (params.config.url) {
args.push("--url", params.config.url);
}
args.push(...params.args);
return { command: params.config.command, args };
}
export function execPodmanRaw(
config: PodmanPluginConfig,
args: string[],
opts?: ExecPodmanRawOptions,
): Promise<ExecPodmanRawResult> {
return new Promise<ExecPodmanRawResult>((resolve, reject) => {
const invocation = buildPodmanInvocation({ config, args });
const child = spawn(invocation.command, invocation.args, {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let aborted = false;
let outputStreamError: Error | undefined;
const signal = opts?.signal;
const handleAbort = () => {
if (aborted) {
return;
}
aborted = true;
child.kill("SIGTERM");
};
if (signal) {
if (signal.aborted) {
handleAbort();
} else {
signal.addEventListener("abort", handleAbort, { once: true });
}
}
const handleStreamError = (error: Error) => {
if (outputStreamError) {
return;
}
outputStreamError = error;
child.kill("SIGTERM");
};
child.stdout?.on("error", handleStreamError);
child.stdout?.on("data", (chunk) => {
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.stderr?.on("error", handleStreamError);
child.stderr?.on("data", (chunk) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
child.on("error", (error) => {
if (signal) {
signal.removeEventListener("abort", handleAbort);
}
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
reject(
Object.assign(
new Error(
`Sandbox backend "podman" requires Podman, but the "${config.command}" command was not found in PATH. Install Podman or set agents.defaults.sandbox.backend to another backend.`,
),
{ code: "INVALID_CONFIG", cause: error },
),
);
return;
}
reject(error);
});
child.on("close", (code) => {
if (signal) {
signal.removeEventListener("abort", handleAbort);
}
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
if (aborted || signal?.aborted) {
reject(createAbortError("Aborted"));
return;
}
if (outputStreamError) {
reject(outputStreamError);
return;
}
const exitCode = code ?? 0;
if (exitCode !== 0 && !opts?.allowFailure) {
reject(
Object.assign(
new Error(stderr.toString("utf8").trim() || `podman ${args.join(" ")} failed`),
{
code: exitCode,
stdout,
stderr,
},
) satisfies ExecPodmanRawError,
);
return;
}
resolve({ stdout, stderr, code: exitCode });
});
const stdin = child.stdin;
if (stdin) {
stdin.on("error", handleStreamError);
if (opts?.input !== undefined) {
stdin.end(opts.input);
} else {
stdin.end();
}
}
});
}
export async function execPodman(
config: PodmanPluginConfig,
args: string[],
opts?: ExecPodmanRawOptions,
) {
const result = await execPodmanRaw(config, args, opts);
return {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
code: result.code,
};
}
export async function podmanContainerState(config: PodmanPluginConfig, name: string) {
const result = await execPodman(config, ["inspect", "-f", "{{.State.Running}}", name], {
allowFailure: true,
});
if (result.code !== 0) {
return { exists: false, running: false };
}
return { exists: true, running: result.stdout.trim() === "true" };
}
export async function readPodmanContainerLabel(
config: PodmanPluginConfig,
containerName: string,
label: string,
): Promise<string | null> {
const result = await execPodman(
config,
["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName],
{ allowFailure: true },
);
if (result.code !== 0) {
return null;
}
const raw = result.stdout.trim();
return raw && raw !== "<no value>" ? raw : null;
}
-16
View File
@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}
@@ -222,71 +222,6 @@ describe("registerPolicyDoctorChecks", () => {
);
});
it("collects container posture for the Podman sandbox backend", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
...cfgWithPolicy(),
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "Podman",
docker: {
network: "host",
binds: ["/var/run/podman/podman.sock:/podman.sock:rw"],
seccompProfile: "unconfined",
},
},
},
},
} as OpenClawConfig;
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
sandbox: {
allowBackends: ["podman"],
containers: {
denyHostNetwork: true,
denyContainerRuntimeSocketMounts: true,
denyUnconfinedProfiles: true,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(ctx(configPath, cfg));
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
expect(evidence.sandboxPosture).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "backend", value: "podman" }),
expect.objectContaining({ kind: "containerNetwork", value: "host" }),
expect.objectContaining({ kind: "containerMount" }),
expect.objectContaining({
kind: "containerSecurityProfile",
profile: "seccomp",
value: "unconfined",
}),
]),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/sandbox-container-host-network-denied" }),
expect.objectContaining({ checkId: "policy/sandbox-container-runtime-socket-mount" }),
expect.objectContaining({ checkId: "policy/sandbox-container-unconfined-profile" }),
]),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/sandbox-container-posture-unobservable",
}),
]),
);
});
it("evaluates inherited container mounts for browser containers on non-Docker backends", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const cfg = {
@@ -165,7 +165,7 @@ function scopedSandboxDefaultDisabledForAgent(
candidate.kind === "backend" &&
scopedAgentIdMatches(candidate.agentId, policyAgentId),
);
if (typeof backend?.value === "string" && !isObservableContainerSandboxBackend(backend.value)) {
if (typeof backend?.value === "string" && backend.value.toLowerCase() !== "docker") {
return true;
}
}
@@ -277,10 +277,7 @@ function sandboxContainerPostureUnobservableFindings(
}
return sandboxPostureEntries(evidence, "backend")
.filter(evidenceFilter)
.filter(
(entry) =>
typeof entry.value === "string" && !isObservableContainerSandboxBackend(entry.value),
)
.filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() !== "docker")
.flatMap((entry) =>
enabledRules.map((rule) =>
sandboxPostureFinding(entry, {
@@ -294,11 +291,6 @@ function sandboxContainerPostureUnobservableFindings(
);
}
function isObservableContainerSandboxBackend(value: string): boolean {
const backend = value.toLowerCase();
return backend === "docker" || backend === "podman";
}
function sandboxContainerHostNetworkFindings(
sandboxPolicy: Record<string, unknown>,
policyDocName: string,
@@ -87,16 +87,12 @@ function pushSandboxPostureEvidence(
inherited: localBackend === undefined && inheritedBackend !== undefined,
});
if (isObservableContainerSandboxBackend(effectiveBackend)) {
if (effectiveBackend === "docker" || effectiveBackend === "podman") {
pushSandboxDockerPosture(entries, effectiveParams);
}
pushSandboxBrowserPosture(entries, effectiveParams);
}
function isObservableContainerSandboxBackend(backend: string): boolean {
return backend === "docker" || backend === "podman";
}
function pushSandboxDockerPosture(
entries: PolicySandboxPostureEvidence[],
params: SandboxPostureParams,
@@ -245,7 +241,7 @@ function pushSandboxBrowserPosture(
sourceSuffix: "browser/binds",
surface: "browser",
});
} else if (params.effectiveBackend !== "docker") {
} else if (params.effectiveBackend !== "docker" && params.effectiveBackend !== "podman") {
const localDocker =
!params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {};
const inheritedDocker = isRecord(params.inheritedSandbox.docker)
@@ -1,5 +1,6 @@
// Policy tests cover policy state plugin behavior.
import { describe, expect, it } from "vitest";
import { scanPolicySandboxPosture } from "./policy-state-sandbox.js";
import { collectPolicyEvidence } from "./policy-state.js";
const scanPolicyChannels = (cfg: Record<string, unknown>) => collectPolicyEvidence(cfg).channels;
@@ -12,6 +13,47 @@ async function scanPolicyTools(raw: string) {
const scanPolicyExecApprovals = (raw: string) =>
collectPolicyEvidence({}, { execApprovalsRaw: raw }).execApprovals ?? [];
describe("scanPolicySandboxPosture", () => {
it("keeps explicit Podman identity while exposing shared container settings", () => {
const evidence = scanPolicySandboxPosture({
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "podman",
docker: {
network: "bridge",
seccompProfile: "custom-seccomp.json",
binds: ["/host/data:/data:ro"],
},
},
},
},
});
expect(evidence).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "backend", value: "podman" }),
expect.objectContaining({
kind: "containerNetwork",
networkSurface: "docker",
value: "bridge",
}),
expect.objectContaining({
kind: "containerSecurityProfile",
profile: "seccomp",
value: "custom-seccomp.json",
}),
expect.objectContaining({
kind: "containerMount",
bindSurface: "docker",
bind: "/host/data:/data:ro",
}),
]),
);
});
});
describe("scanPolicyChannels", () => {
it("ignores reserved channel config namespaces", () => {
expect(
-9
View File
@@ -1555,15 +1555,6 @@ importers:
specifier: workspace:*
version: link:../..
extensions/podman:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/policy:
dependencies:
json5:
@@ -27,6 +27,9 @@ const browserProfilesMock = vi.hoisted(() => ({
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
})),
}));
const containerEngineMocks = vi.hoisted(() => ({
resolvePodmanSandboxRuntimeInfo: vi.fn(),
}));
vi.mock("./sandbox/registry.js", () => ({
readRegisteredSandboxRuntimeIds: readRegisteredSandboxRuntimeIdsMock,
@@ -41,6 +44,14 @@ vi.mock("../plugin-sdk/browser-control-auth.js", () => browserControlAuthMock);
vi.mock("../plugin-sdk/browser-profiles.js", () => browserProfilesMock);
vi.mock("./sandbox/docker.js", async () => {
const actual = await vi.importActual<typeof import("./sandbox/docker.js")>("./sandbox/docker.js");
return {
...actual,
resolvePodmanSandboxRuntimeInfo: containerEngineMocks.resolvePodmanSandboxRuntimeInfo,
};
});
vi.mock("./exec-defaults.js", () => ({
resolveNodeExecEligibility: resolveNodeExecEligibilityMock,
}));
@@ -540,6 +551,114 @@ describe("resolveSandboxContext", () => {
}
}, 15_000);
it("keeps Docker isolated from Podman when the Docker backend is configured", async () => {
containerEngineMocks.resolvePodmanSandboxRuntimeInfo.mockClear();
const backendFactory = vi.fn(async () => ({
id: "docker",
runtimeId: "docker-runtime",
runtimeLabel: "Docker Runtime",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["docker", "exec"],
env: process.env,
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
const restore = registerSandboxBackend("docker", backendFactory);
try {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
scope: "session",
workspaceAccess: "rw",
prune: { idleHours: 0, maxAgeDays: 0 },
},
},
},
};
const result = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:worker:docker",
workspaceDir: "/tmp/openclaw-test",
});
expect(result?.backendId).toBe("docker");
expect(containerEngineMocks.resolvePodmanSandboxRuntimeInfo).not.toHaveBeenCalled();
expect(backendFactory).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({ backend: "docker" }),
}),
);
} finally {
restore();
}
}, 15_000);
it("uses Podman directly when the Podman backend is configured", async () => {
containerEngineMocks.resolvePodmanSandboxRuntimeInfo.mockResolvedValueOnce({
rootless: true,
remote: false,
machine: false,
});
const backendFactory = vi.fn(async () => ({
id: "podman",
runtimeId: "podman-runtime",
runtimeLabel: "Podman Runtime",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["podman", "exec"],
env: process.env,
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
const restore = registerSandboxBackend("podman", backendFactory);
try {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "podman",
scope: "session",
workspaceAccess: "rw",
prune: { idleHours: 0, maxAgeDays: 0 },
},
},
},
};
const result = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:worker:podman",
workspaceDir: "/tmp/openclaw-test",
});
expect(result?.backendId).toBe("podman");
expect(containerEngineMocks.resolvePodmanSandboxRuntimeInfo).toHaveBeenCalledOnce();
expect(backendFactory).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({ backend: "podman" }),
}),
);
} finally {
restore();
}
}, 15_000);
it("passes the resolved browser SSRF policy to sandbox browser setup", async () => {
ensureSandboxBrowserMock.mockClear();
const restore = registerSandboxBackend("test-browser-backend", async () => ({
+1 -17
View File
@@ -19,21 +19,7 @@ export {
requireSandboxBackendFactory,
} from "./sandbox/backend.js";
export {
buildSandboxCreateArgs,
isDockerDaemonUnavailable,
resolveDockerEnvPolicyEpoch,
} from "./sandbox/docker.js";
export { computeSandboxConfigHash } from "./sandbox/config-hash.js";
export {
appendReadOnlyWorkspaceSkillMountArgs,
appendWorkspaceMountArgs,
formatReadOnlyWorkspaceSkillMountHashState,
resolveReadOnlyWorkspaceSkillMounts,
SANDBOX_MOUNT_FORMAT_VERSION,
type ReadOnlyWorkspaceSkillMount,
} from "./sandbox/workspace-mounts.js";
export { resolveSandboxScopeKey, slugifySessionKey } from "./sandbox/shared.js";
export { isDockerDaemonUnavailable } from "./sandbox/docker.js";
export {
listSandboxBrowsers,
listSandboxContainers,
@@ -60,8 +46,6 @@ export {
uploadDirectoryToSshTarget,
} from "./sandbox/ssh.js";
export { sanitizeEnvVars } from "./sandbox/sanitize-env-vars.js";
export { buildDockerExecArgs } from "./bash-tools.shared.js";
export { readRegistryEntry as readSandboxRegistryEntry } from "./sandbox/registry.js";
export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js";
export { createWritableRenameTargetResolver } from "./sandbox/fs-bridge-rename-targets.js";
export { resolveWritableRenameTargets } from "./sandbox/fs-bridge-rename-targets.js";
+6
View File
@@ -9,6 +9,12 @@ import {
} from "./backend.js";
describe("sandbox backend registry", () => {
it("registers Podman as a built-in backend", () => {
expect(getSandboxBackendFactory("podman")).not.toBeNull();
expect(getSandboxBackendManager("podman")).not.toBeNull();
expect(getSandboxBackendWorkdirResolver("podman")).not.toBeNull();
});
it("registers and restores backend factories", () => {
// Tests and optional backends install process-local factories; restore must
// remove them so later suites see the default registry.
+14 -3
View File
@@ -1,7 +1,7 @@
/**
* Sandbox backend registry.
*
* Stores process-wide backend factories so core and plugins can register Docker, SSH, or custom sandbox providers.
* Stores process-wide backend factories so core and plugins can register local container, SSH, or custom sandbox providers.
*/
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import type {
@@ -35,7 +35,7 @@ export type {
const SANDBOX_BACKEND_FACTORIES_STATE_KEY = Symbol.for("openclaw.sandboxBackendFactories");
// Process-wide sandbox backend registry. Tests and plugins can install temporary
// factories while core still auto-registers the bundled Docker and SSH backends.
// factories while core still auto-registers the bundled container and SSH backends.
function getSandboxBackendFactories(): Map<SandboxBackendId, RegisteredSandboxBackend> {
const globalStore = globalThis as typeof globalThis & {
[SANDBOX_BACKEND_FACTORIES_STATE_KEY]?: Map<SandboxBackendId, RegisteredSandboxBackend>;
@@ -101,7 +101,12 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory
);
}
import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js";
import {
createDockerSandboxBackend,
createPodmanSandboxBackend,
dockerSandboxBackendManager,
podmanSandboxBackendManager,
} from "./docker-backend.js";
import {
createSshSandboxBackend,
resolveSshRuntimePaths,
@@ -114,6 +119,12 @@ registerSandboxBackend("docker", {
resolveWorkdir: ({ cfg }) => cfg.docker.workdir,
});
registerSandboxBackend("podman", {
factory: createPodmanSandboxBackend,
manager: podmanSandboxBackendManager,
resolveWorkdir: ({ cfg }) => cfg.docker.workdir,
});
registerSandboxBackend("ssh", {
factory: createSshSandboxBackend,
manager: sshSandboxBackendManager,
@@ -122,6 +122,7 @@ function buildConfig(noVncEnabled: boolean): SandboxConfig {
scope: "session",
workspaceAccess: "none",
workspaceRoot: "/tmp/openclaw-sandboxes",
dockerTmpfsSource: "default",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
+15
View File
@@ -6,6 +6,21 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveSandboxConfigForAgent } from "./config.js";
describe("sandbox config", () => {
it("tracks whether tmpfs came from defaults or explicit config", () => {
expect(resolveSandboxConfigForAgent().dockerTmpfsSource).toBe("default");
expect(
resolveSandboxConfigForAgent({
agents: {
defaults: {
sandbox: {
docker: { tmpfs: ["/run"] },
},
},
},
}).dockerTmpfsSource,
).toBe("configured");
});
it("caps browser autostart timeout to a timer-safe delay", () => {
// Browser startup timeouts flow into Node timers; huge config values must
// not overflow or become immediate delays.
+6 -1
View File
@@ -250,6 +250,7 @@ export function resolveSandboxConfigForAgent(
});
const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, agentId);
const scopedAgentDocker = scope === "shared" ? undefined : agentSandbox?.docker;
return {
mode: agentSandbox?.mode ?? agent?.mode ?? "off",
@@ -258,10 +259,14 @@ export function resolveSandboxConfigForAgent(
workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none",
workspaceRoot:
agentSandbox?.workspaceRoot ?? agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
dockerTmpfsSource:
scopedAgentDocker?.tmpfs === undefined && agent?.docker?.tmpfs === undefined
? "default"
: "configured",
docker: resolveSandboxDockerConfig({
scope,
globalDocker: agent?.docker,
agentDocker: agentSandbox?.docker,
agentDocker: scopedAgentDocker,
}),
ssh: resolveSandboxSshConfig({
scope,
+122
View File
@@ -0,0 +1,122 @@
/**
* Shared local container-engine process execution and backend selection.
*/
import { createAbortError } from "../../infra/abort-signal.js";
import { toErrorObject } from "../../infra/errors.js";
import { isPlainCommandExitFailure, spawnCommand } from "../../process/exec.js";
import { SANDBOX_COMMAND_MAX_BUFFER_BYTES } from "./constants.js";
export type ExecContainerRawOptions = {
allowFailure?: boolean;
input?: Buffer | string;
signal?: AbortSignal;
};
export type SandboxContainerEngine = {
id: "docker" | "podman";
command: "docker" | "podman";
displayName: "Docker" | "Podman";
globalArgs?: readonly string[];
};
export type SandboxContainerEngineTarget = {
key: string;
globalArgs: string[];
};
export const DOCKER_SANDBOX_ENGINE: SandboxContainerEngine = {
id: "docker",
command: "docker",
displayName: "Docker",
};
export const PODMAN_SANDBOX_ENGINE: SandboxContainerEngine = {
id: "podman",
command: "podman",
displayName: "Podman",
};
export type ExecDockerRawResult = {
stdout: Buffer;
stderr: Buffer;
code: number;
};
type ExecDockerRawError = Error & {
code: number;
stdout: Buffer;
stderr: Buffer;
};
function missingContainerEngineMessage(engine: SandboxContainerEngine): string {
if (engine.id === "docker") {
return 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.';
}
return 'Sandbox mode requires Podman, but the "podman" command was not found in PATH. Install Podman (and ensure "podman" is available), choose another sandbox backend, or set `agents.defaults.sandbox.mode=off` to disable sandboxing.';
}
export async function execContainerRaw(
engine: SandboxContainerEngine,
args: string[],
opts?: ExecContainerRawOptions,
): Promise<ExecDockerRawResult> {
let result;
try {
result = await spawnCommand([engine.command, ...(engine.globalArgs ?? []), ...args], {
cancelSignal: opts?.signal,
encoding: "buffer",
input: opts?.input ?? Buffer.alloc(0),
maxBuffer: SANDBOX_COMMAND_MAX_BUFFER_BYTES,
reject: false,
stripFinalNewline: false,
});
} catch (error) {
if (opts?.signal?.aborted) {
throw createAbortError("Aborted");
}
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw Object.assign(new Error(missingContainerEngineMessage(engine)), {
code: "INVALID_CONFIG",
cause: error,
});
}
throw error;
}
if (opts?.signal?.aborted || result.isCanceled) {
throw createAbortError("Aborted");
}
if (result.failed && !isPlainCommandExitFailure(result)) {
if (result.code === "ENOENT") {
throw Object.assign(new Error(missingContainerEngineMessage(engine)), {
code: "INVALID_CONFIG",
cause: result,
});
}
throw toErrorObject(result, `${engine.displayName} command execution failed`);
}
const stdout = Buffer.from(result.stdout);
const stderr = Buffer.from(result.stderr);
const exitCode = result.exitCode ?? (result.failed ? 1 : 0);
if (exitCode !== 0 && !opts?.allowFailure) {
const message = stderr.length > 0 ? stderr.toString("utf8").trim() : "";
const error: ExecDockerRawError = Object.assign(
new Error(message || `${engine.command} ${args.join(" ")} failed`),
{ code: exitCode, stdout, stderr },
);
throw error;
}
return { stdout, stderr, code: exitCode };
}
export async function execContainer(
engine: SandboxContainerEngine,
args: string[],
opts?: ExecContainerRawOptions,
) {
const result = await execContainerRaw(engine, args, opts);
return {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
code: result.code,
};
}
+16 -3
View File
@@ -20,6 +20,11 @@ import { getSandboxBackendWorkdirResolver, requireSandboxBackendFactory } from "
import { ensureSandboxBrowser } from "./browser.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import { resolveSandboxDockerUser } from "./docker-user.js";
import {
DOCKER_SANDBOX_ENGINE,
PODMAN_SANDBOX_ENGINE,
resolvePodmanSandboxRuntimeInfo,
} from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { toSandboxProvisioningError } from "./provisioning-error.js";
import { readRegisteredSandboxRuntimeIds, updateRegistry } from "./registry.js";
@@ -224,8 +229,18 @@ async function resolveProvisionedSandboxContext(
workspaceDir: params.workspaceDir,
});
const configuredBackend = cfg.backend.trim().toLowerCase();
const containerEngine =
configuredBackend === "docker"
? DOCKER_SANDBOX_ENGINE
: configuredBackend === "podman"
? PODMAN_SANDBOX_ENGINE
: null;
const podmanRuntimeInfo =
containerEngine?.id === "podman" ? await resolvePodmanSandboxRuntimeInfo() : undefined;
const docker = await resolveSandboxDockerUser({
backend: cfg.backend,
...(podmanRuntimeInfo ? { podmanRootless: podmanRuntimeInfo.rootless } : {}),
docker: cfg.docker,
workspaceDir,
});
@@ -283,9 +298,7 @@ async function resolveProvisionedSandboxContext(
})()
: undefined;
if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) {
throw new Error(
`Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`,
);
throw new Error(`Sandbox backend "${backend.id}" does not support browser sandboxes yet.`);
}
const browser =
resolvedCfg.browser.enabled && backend.capabilities?.browser === true
@@ -55,6 +55,17 @@ describe("resolveSandboxDockerUser", () => {
expect(resolved.user).toBeUndefined();
});
it("applies workspace ownership fallback for rootful Podman", async () => {
const resolved = await resolveSandboxDockerUser({
backend: "podman",
podmanRootless: false,
docker: baseDocker,
workspaceDir: "/tmp/workspace",
stat: async () => ({ uid: 1001, gid: 1002 }),
});
expect(resolved.user).toBe("1001:1002");
});
it("leaves docker.user unset when workspace stat fails", async () => {
const resolved = await resolveSandboxDockerUser({
backend: "docker",
+164 -15
View File
@@ -5,25 +5,33 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveSandboxConfigForAgent } from "./config.js";
const dockerMocks = vi.hoisted(() => ({
dockerContainerState: vi.fn(),
containerState: vi.fn(),
ensureSandboxContainer: vi.fn(),
execDocker: vi.fn(),
execDockerRaw: vi.fn(),
execContainer: vi.fn(),
execContainerRaw: vi.fn(),
resolvePodmanSandboxRuntimeInfo: vi.fn(),
validateSandboxContainerEngineTarget: vi.fn(),
}));
vi.mock("./docker.js", async () => {
const actual = await vi.importActual<typeof import("./docker.js")>("./docker.js");
return {
...actual,
dockerContainerState: dockerMocks.dockerContainerState,
containerState: dockerMocks.containerState,
ensureSandboxContainer: dockerMocks.ensureSandboxContainer,
execDocker: dockerMocks.execDocker,
execDockerRaw: dockerMocks.execDockerRaw,
execContainer: dockerMocks.execContainer,
execContainerRaw: dockerMocks.execContainerRaw,
resolvePodmanSandboxRuntimeInfo: dockerMocks.resolvePodmanSandboxRuntimeInfo,
validateSandboxContainerEngineTarget: dockerMocks.validateSandboxContainerEngineTarget,
};
});
const { createDockerSandboxBackend, dockerSandboxBackendManager } =
await import("./docker-backend.js");
const {
createDockerSandboxBackend,
createPodmanSandboxBackend,
dockerSandboxBackendManager,
podmanSandboxBackendManager,
} = await import("./docker-backend.js");
function createConfig(): OpenClawConfig {
return {
@@ -50,15 +58,20 @@ function createConfig(): OpenClawConfig {
describe("docker sandbox backend manager", () => {
beforeEach(() => {
vi.clearAllMocks();
dockerMocks.dockerContainerState.mockResolvedValue({
dockerMocks.containerState.mockResolvedValue({
exists: true,
running: true,
});
dockerMocks.execDocker.mockResolvedValue({
dockerMocks.execContainer.mockResolvedValue({
code: 0,
stdout: "unused-image",
stderr: "",
});
dockerMocks.resolvePodmanSandboxRuntimeInfo.mockResolvedValue({
machine: false,
rootless: true,
target: { key: "local", globalArgs: [] },
});
});
it("forwards the canonical scope key to container provisioning", async () => {
@@ -78,8 +91,51 @@ describe("docker sandbox backend manager", () => {
);
});
it("binds Podman provisioning and later execs to the resolved target", async () => {
dockerMocks.ensureSandboxContainer.mockResolvedValueOnce("sandbox-podman");
const podmanTarget = {
key: `machine:${"a".repeat(32)}`,
globalArgs: [
"--url",
"ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock",
"--identity",
"/tmp/podman-machine-key",
],
};
dockerMocks.resolvePodmanSandboxRuntimeInfo.mockResolvedValueOnce({
machine: true,
rootless: true,
target: podmanTarget,
});
const config = createConfig();
config.agents!.defaults!.sandbox!.backend = "podman";
config.agents!.defaults!.sandbox!.browser!.enabled = false;
const backend = await createPodmanSandboxBackend({
sessionKey: "agent:coder:main",
scopeKey: "agent:coder:main",
workspaceDir: "/workspace",
agentWorkspaceDir: "/workspace",
cfg: resolveSandboxConfigForAgent(config),
});
const execSpec = await backend.buildExecSpec({
command: "true",
env: {},
usePty: false,
});
expect(dockerMocks.ensureSandboxContainer).toHaveBeenCalledWith(
expect.objectContaining({ podmanTarget }),
);
expect(dockerMocks.validateSandboxContainerEngineTarget).toHaveBeenCalledWith(
expect.objectContaining({ id: "podman" }),
podmanTarget,
);
expect(execSpec.argv.slice(0, 6)).toEqual(["podman", ...podmanTarget.globalArgs, "exec"]);
});
it("matches ordinary sandbox runtimes against sandbox.docker.image", async () => {
dockerMocks.execDocker.mockResolvedValueOnce({
dockerMocks.execContainer.mockResolvedValueOnce({
code: 0,
stdout: "openclaw-sandbox:bookworm-slim\n",
stderr: "",
@@ -108,7 +164,7 @@ describe("docker sandbox backend manager", () => {
});
it("matches browser runtimes against sandbox.browser.image", async () => {
dockerMocks.execDocker.mockResolvedValueOnce({
dockerMocks.execContainer.mockResolvedValueOnce({
code: 0,
stdout: "openclaw-sandbox-browser:bookworm-slim\n",
stderr: "",
@@ -139,7 +195,7 @@ describe("docker sandbox backend manager", () => {
it("defaults docker-backed runtime matching to sandbox.docker.image when label kind is missing", async () => {
// Older registry entries did not record configLabelKind; keep ordinary
// sandbox matching stable for those existing containers.
dockerMocks.execDocker.mockResolvedValueOnce({
dockerMocks.execContainer.mockResolvedValueOnce({
code: 0,
stdout: "openclaw-sandbox:bookworm-slim\n",
stderr: "",
@@ -167,7 +223,7 @@ describe("docker sandbox backend manager", () => {
});
it("reports Docker runtime removal failures", async () => {
dockerMocks.execDocker.mockResolvedValueOnce({
dockerMocks.execContainer.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "permission denied",
@@ -192,7 +248,7 @@ describe("docker sandbox backend manager", () => {
it("treats already-missing Docker runtimes as removed", async () => {
// Prune/remove flows are idempotent; Docker may have already removed the
// container by the time the manager runs.
dockerMocks.execDocker.mockResolvedValueOnce({
dockerMocks.execContainer.mockResolvedValueOnce({
code: 1,
stdout: "",
stderr: "Error response from daemon: No such container: sandbox-1",
@@ -213,4 +269,97 @@ describe("docker sandbox backend manager", () => {
}),
).resolves.toBeUndefined();
});
it("uses Podman for Podman registry entries", async () => {
dockerMocks.execContainer.mockResolvedValueOnce({
code: 0,
stdout: "",
stderr: "",
});
await podmanSandboxBackendManager.removeRuntime({
entry: {
containerName: "sandbox-podman",
backendId: "podman",
backendTarget: { key: "local", globalArgs: [] },
runtimeLabel: "sandbox-podman",
sessionKey: "agent:coder:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "openclaw-sandbox:bookworm-slim",
},
config: createConfig(),
});
expect(dockerMocks.execContainer).toHaveBeenCalledWith(
expect.objectContaining({ id: "podman", command: "podman" }),
["rm", "-f", "sandbox-podman"],
{ allowFailure: true },
);
expect(dockerMocks.validateSandboxContainerEngineTarget).toHaveBeenCalledWith(
expect.objectContaining({ id: "podman", command: "podman" }),
{ key: "local", globalArgs: [] },
);
});
it("rejects browser sandboxing on the explicit Podman backend", async () => {
const config = createConfig();
config.agents!.defaults!.sandbox!.backend = "podman";
await expect(
createPodmanSandboxBackend({
sessionKey: "agent:coder:main",
scopeKey: "agent:coder:main",
workspaceDir: "/workspace",
agentWorkspaceDir: "/workspace",
skillsWorkspaceDir: "/workspace/.openclaw/sandbox-skills",
cfg: resolveSandboxConfigForAgent(config),
}),
).rejects.toThrow(
"Podman sandboxing does not support browser sandboxes. Install Docker and select the docker backend, or disable sandbox.browser.enabled.",
);
expect(dockerMocks.ensureSandboxContainer).not.toHaveBeenCalled();
});
it("matches canonical Podman image identity when Podman expands a short name", async () => {
dockerMocks.execContainer
.mockResolvedValueOnce({
code: 0,
stdout: "localhost/openclaw-sandbox:bookworm-slim\tsha256:abc123\n",
stderr: "",
})
.mockResolvedValueOnce({
code: 0,
stdout: "abc123\n",
stderr: "",
});
const result = await podmanSandboxBackendManager.describeRuntime({
entry: {
containerName: "sandbox-podman",
backendId: "podman",
backendTarget: { key: "local", globalArgs: [] },
runtimeLabel: "sandbox-podman",
sessionKey: "agent:coder:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "openclaw-sandbox:bookworm-slim",
configLabelKind: "Image",
},
config: createConfig(),
agentId: "coder",
});
expect(result).toEqual({
running: true,
actualConfigLabel: "localhost/openclaw-sandbox:bookworm-slim",
configLabelMatch: true,
});
expect(dockerMocks.execContainer).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ id: "podman", command: "podman" }),
["image", "inspect", "-f", "{{.Id}}", "openclaw-sandbox:bookworm-slim"],
{ allowFailure: true },
);
});
});
+164 -48
View File
@@ -12,11 +12,19 @@ import type {
} from "./backend.types.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import {
dockerContainerState,
containerState,
bindPodmanSandboxEngine,
DOCKER_SANDBOX_ENGINE,
ensureSandboxContainer,
execDocker,
execDockerRaw,
execContainer,
execContainerRaw,
PODMAN_SANDBOX_ENGINE,
resolvePodmanSandboxRuntimeInfo,
type SandboxContainerEngine,
type SandboxContainerEngineTarget,
validateSandboxContainerEngineTarget,
} from "./docker.js";
import type { SandboxRegistryEntry } from "./registry.js";
function resolveConfiguredDockerRuntimeImage(params: {
config: CreateSandboxBackendParams["cfg"] | import("../../config/config.js").OpenClawConfig;
@@ -32,10 +40,21 @@ function resolveConfiguredDockerRuntimeImage(params: {
}
}
export async function createDockerSandboxBackend(
async function createContainerSandboxBackend(
engine: SandboxContainerEngine,
params: CreateSandboxBackendParams,
): Promise<SandboxBackendHandle> {
if (engine.id === "podman" && params.cfg.browser.enabled) {
throw new Error(
"Podman sandboxing does not support browser sandboxes. Install Docker and select the docker backend, or disable sandbox.browser.enabled.",
);
}
const podmanTarget =
engine.id === "podman" ? (await resolvePodmanSandboxRuntimeInfo()).target : undefined;
const boundEngine = podmanTarget ? bindPodmanSandboxEngine(podmanTarget) : engine;
const containerName = await ensureSandboxContainer({
engine: boundEngine,
...(podmanTarget ? { podmanTarget } : {}),
scopeKey: params.scopeKey,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
@@ -45,22 +64,38 @@ export async function createDockerSandboxBackend(
? { requireCurrentConfig: params.requireCurrentConfig }
: {}),
});
return createDockerSandboxBackendHandle({
return createContainerSandboxBackendHandle({
engine: boundEngine,
containerName,
workdir: params.cfg.docker.workdir,
env: params.cfg.docker.env,
image: params.cfg.docker.image,
podmanTarget,
});
}
function createDockerSandboxBackendHandle(params: {
export async function createDockerSandboxBackend(
params: CreateSandboxBackendParams,
): Promise<SandboxBackendHandle> {
return await createContainerSandboxBackend(DOCKER_SANDBOX_ENGINE, params);
}
export async function createPodmanSandboxBackend(
params: CreateSandboxBackendParams,
): Promise<SandboxBackendHandle> {
return await createContainerSandboxBackend(PODMAN_SANDBOX_ENGINE, params);
}
function createContainerSandboxBackendHandle(params: {
engine: SandboxContainerEngine;
containerName: string;
workdir: string;
env?: Record<string, string>;
image: string;
podmanTarget?: SandboxContainerEngineTarget;
}): SandboxBackendHandle {
return {
id: "docker",
id: params.engine.id,
runtimeId: params.containerName,
runtimeLabel: params.containerName,
workdir: params.workdir,
@@ -68,12 +103,14 @@ function createDockerSandboxBackendHandle(params: {
configLabel: params.image,
configLabelKind: "Image",
capabilities: {
browser: true,
browser: params.engine.id === "docker",
},
async buildExecSpec({ command, workdir, env, usePty }) {
await validateSandboxContainerEngineTarget(params.engine, params.podmanTarget);
return {
argv: [
"docker",
params.engine.command,
...(params.engine.globalArgs ?? []),
...buildDockerExecArgs({
containerName: params.containerName,
command,
@@ -87,19 +124,24 @@ function createDockerSandboxBackendHandle(params: {
};
},
runShellCommand(command) {
return runDockerSandboxShellCommand({
return runContainerSandboxShellCommand({
engine: params.engine,
containerName: params.containerName,
podmanTarget: params.podmanTarget,
...command,
});
},
};
}
export function runDockerSandboxShellCommand(
async function runContainerSandboxShellCommand(
params: {
engine: SandboxContainerEngine;
containerName: string;
podmanTarget?: SandboxContainerEngineTarget;
} & SandboxBackendCommandParams,
) {
await validateSandboxContainerEngineTarget(params.engine, params.podmanTarget);
const dockerArgs = [
"exec",
"-i",
@@ -112,49 +154,123 @@ export function runDockerSandboxShellCommand(
if (params.args?.length) {
dockerArgs.push(...params.args);
}
return execDockerRaw(dockerArgs, {
return execContainerRaw(params.engine, dockerArgs, {
input: params.stdin,
allowFailure: params.allowFailure,
signal: params.signal,
});
}
export const dockerSandboxBackendManager: SandboxBackendManager = {
async describeRuntime({ entry, config, agentId }) {
const state = await dockerContainerState(entry.containerName);
let actualConfigLabel = entry.image;
if (state.exists) {
try {
const result = await execDocker(
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualConfigLabel = result.stdout.trim() || actualConfigLabel;
export function runDockerSandboxShellCommand(
params: {
containerName: string;
} & SandboxBackendCommandParams,
) {
return runContainerSandboxShellCommand({
engine: DOCKER_SANDBOX_ENGINE,
...params,
});
}
function createContainerSandboxBackendManager(
engine: SandboxContainerEngine,
): SandboxBackendManager {
const resolvePodmanTarget = (entry: SandboxRegistryEntry) => {
if (engine.id !== "podman") {
return undefined;
}
if (entry.backendTarget) {
return entry.backendTarget;
}
throw Object.assign(
new Error(
`Podman sandbox runtime ${entry.containerName} has no recorded engine target. Remove that unshipped runtime manually before managing it.`,
),
{ code: "INVALID_CONFIG" },
);
};
return {
async describeRuntime({ entry, config, agentId }) {
const podmanTarget = resolvePodmanTarget(entry);
await validateSandboxContainerEngineTarget(engine, podmanTarget);
const runtimeEngine = podmanTarget ? bindPodmanSandboxEngine(podmanTarget) : engine;
const state = await containerState(runtimeEngine, entry.containerName);
let actualConfigLabel = entry.image;
let actualImageId: string | undefined;
if (state.exists) {
try {
const result = await execContainer(
runtimeEngine,
[
"inspect",
"-f",
runtimeEngine.id === "podman" ? "{{.ImageName}}\t{{.Image}}" : "{{.Config.Image}}",
entry.containerName,
],
{ allowFailure: true },
);
if (result.code === 0) {
const inspected = result.stdout.trim();
if (runtimeEngine.id === "podman") {
const [imageName, imageId] = inspected.split("\t", 2);
actualConfigLabel = imageName || actualConfigLabel;
actualImageId = imageId;
} else {
actualConfigLabel = inspected || actualConfigLabel;
}
}
} catch {
// ignore inspect failures
}
} catch {
// ignore inspect failures
}
}
const configuredImage = resolveConfiguredDockerRuntimeImage({
config,
agentId,
configLabelKind: entry.configLabelKind,
});
return {
running: state.running,
actualConfigLabel,
configLabelMatch: actualConfigLabel === configuredImage,
};
},
async removeRuntime({ entry }) {
const result = await execDocker(["rm", "-f", entry.containerName], { allowFailure: true });
if (result.code !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
if (/No such (container|object)/iu.test(detail)) {
return;
const configuredImage = resolveConfiguredDockerRuntimeImage({
config,
agentId,
configLabelKind: entry.configLabelKind,
});
let configLabelMatch = actualConfigLabel === configuredImage;
if (runtimeEngine.id === "podman" && !configLabelMatch && actualImageId) {
try {
const result = await execContainer(
runtimeEngine,
["image", "inspect", "-f", "{{.Id}}", configuredImage],
{ allowFailure: true },
);
if (result.code === 0) {
const normalizeImageId = (value: string) => value.trim().replace(/^sha256:/u, "");
configLabelMatch = normalizeImageId(actualImageId) === normalizeImageId(result.stdout);
}
} catch {
// Keep the name comparison result when image inspection fails.
}
}
throw new Error(`Failed to remove Docker sandbox runtime ${entry.containerName}: ${detail}`);
}
},
};
return {
running: state.running,
actualConfigLabel,
configLabelMatch,
};
},
async removeRuntime({ entry }) {
const podmanTarget = resolvePodmanTarget(entry);
await validateSandboxContainerEngineTarget(engine, podmanTarget);
const runtimeEngine = podmanTarget ? bindPodmanSandboxEngine(podmanTarget) : engine;
const result = await execContainer(runtimeEngine, ["rm", "-f", entry.containerName], {
allowFailure: true,
});
if (result.code !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
if (/No such (container|object)|does not exist/iu.test(detail)) {
return;
}
throw new Error(
`Failed to remove ${engine.displayName} sandbox runtime ${entry.containerName}: ${detail}`,
);
}
},
};
}
export const dockerSandboxBackendManager =
createContainerSandboxBackendManager(DOCKER_SANDBOX_ENGINE);
export const podmanSandboxBackendManager =
createContainerSandboxBackendManager(PODMAN_SANDBOX_ENGINE);
+3 -1
View File
@@ -3,6 +3,7 @@ import type { SandboxDockerConfig } from "./types.js";
export async function resolveSandboxDockerUser(params: {
backend: string;
podmanRootless?: boolean;
docker: SandboxDockerConfig;
workspaceDir: string;
stat?: (workspaceDir: string) => Promise<{ uid: number; gid: number }>;
@@ -11,7 +12,8 @@ export async function resolveSandboxDockerUser(params: {
if (configuredUser) {
return params.docker;
}
if (params.backend.trim().toLowerCase() !== "docker") {
const backend = params.backend.trim().toLowerCase();
if (backend !== "docker" && !(backend === "podman" && params.podmanRootless === false)) {
return params.docker;
}
const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir));
@@ -16,6 +16,7 @@ import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js";
type SpawnCall = {
command: string;
args: string[];
globalArgs: string[];
};
const spawnState = vi.hoisted(() => ({
@@ -23,6 +24,8 @@ const spawnState = vi.hoisted(() => ({
containerExists: true,
inspectRunning: true,
labelHash: "",
podmanInfo: "true\tfalse\n",
podmanConnections: "[]\n",
}));
const registryMocks = vi.hoisted(() => ({
@@ -52,15 +55,23 @@ vi.mock("../../runtime.js", () => ({
}));
async function spawnDockerProcess(commandAndArgs: string[]) {
const [command = "", ...args] = commandAndArgs;
const [command = "", ...rawArgs] = commandAndArgs;
const globalArgs: string[] = [];
let args = rawArgs;
if (command === "podman") {
while (args[0] === "--url" || args[0] === "--identity") {
globalArgs.push(...args.slice(0, 2));
args = args.slice(2);
}
}
// The tests assert docker CLI arguments without requiring Docker; this mock
// implements only the inspect/create/start/rm calls used by ensureSandboxContainer.
spawnState.calls.push({ command, args });
spawnState.calls.push({ command, args, globalArgs });
let code = 0;
let stdout = "";
let stderr = "";
if (command !== "docker") {
if (command !== "docker" && command !== "podman") {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") {
@@ -81,6 +92,10 @@ async function spawnDockerProcess(commandAndArgs: string[]) {
} else {
stdout = `${spawnState.labelHash}\n`;
}
} else if (command === "podman" && args[0] === "info") {
stdout = spawnState.podmanInfo;
} else if (command === "podman" && args[0] === "system") {
stdout = spawnState.podmanConnections;
} else if (args[0] === "rm" && args[1] === "-f") {
spawnState.containerExists = false;
spawnState.inspectRunning = false;
@@ -122,6 +137,7 @@ vi.mock("../../process/exec.js", async (importOriginal) => ({
let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer;
let resolveDockerEnvPolicyEpoch: typeof import("./docker.js").resolveDockerEnvPolicyEpoch;
let PODMAN_SANDBOX_ENGINE: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE;
async function loadFreshDockerModuleForTest() {
vi.resetModules();
@@ -133,7 +149,8 @@ async function loadFreshDockerModuleForTest() {
...(await importOriginal<typeof import("../../process/exec.js")>()),
spawnCommand: spawnDockerProcess,
}));
({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch } = await import("./docker.js"));
({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch, PODMAN_SANDBOX_ENGINE } =
await import("./docker.js"));
}
function createSandboxConfig(
@@ -148,6 +165,7 @@ function createSandboxConfig(
scope: "shared",
workspaceAccess,
workspaceRoot: "~/.openclaw/sandboxes",
dockerTmpfsSource: "default",
docker: {
image: "openclaw-sandbox:test",
containerPrefix: "oc-test-",
@@ -191,6 +209,7 @@ async function ensureSandboxCreateCallForTest(params: {
cfg: SandboxConfig;
workspaceDir?: string;
scopeKey?: string;
engine?: import("./docker.js").SandboxContainerEngine;
}): Promise<SpawnCall> {
const workspaceDir = params.workspaceDir ?? "/tmp/workspace";
await ensureSandboxContainer({
@@ -198,13 +217,14 @@ async function ensureSandboxCreateCallForTest(params: {
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: params.cfg,
...(params.engine ? { engine: params.engine } : {}),
});
const createCall = spawnState.calls.find(
(call) => call.command === "docker" && call.args[0] === "create",
(call) => call.command === (params.engine?.command ?? "docker") && call.args[0] === "create",
);
if (!createCall) {
throw new Error("expected docker create call");
throw new Error(`expected ${params.engine?.command ?? "docker"} create call`);
}
return createCall;
}
@@ -221,6 +241,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
spawnState.containerExists = true;
spawnState.inspectRunning = true;
spawnState.labelHash = "";
spawnState.podmanInfo = "true\tfalse\n";
spawnState.podmanConnections = "[]\n";
registryMocks.readRegistryEntry.mockClear();
registryMocks.updateRegistry.mockClear();
registryMocks.updateRegistry.mockResolvedValue(undefined);
@@ -600,4 +622,275 @@ describe("ensureSandboxContainer config-hash recreation", () => {
`openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`,
);
});
it("uses the shared lifecycle with Podman keep-id ownership", async () => {
const workspaceDir = "/tmp/workspace";
const cfg = createSandboxConfig([]);
cfg.docker.user = undefined;
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const createCall = await ensureSandboxCreateCallForTest({
cfg,
workspaceDir,
engine: PODMAN_SANDBOX_ENGINE,
});
expect(createCall.command).toBe("podman");
expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual(["keep-id"]);
expect(collectDockerFlagValues(createCall.args, "--user")).toEqual([]);
expect(createCall.args).toContain("--http-proxy=false");
expect(createCall.args).toContain("--init");
expect(createCall.args).toContain("--read-only-tmpfs=true");
expect(collectDockerFlagValues(createCall.args, "--tmpfs")).toEqual(["/tmp", "/var/tmp"]);
expect(collectDockerFlagValues(createCall.args, "-v")).toContain(
`${workspaceDir}:/workspace:z`,
);
expect(registryMocks.updateRegistry.mock.calls.at(-1)?.[0]?.backendId).toBe("podman");
expect(registryMocks.updateRegistry.mock.calls.at(-1)?.[0]?.backendTarget).toEqual({
key: "local",
globalArgs: [],
});
});
it("rejects a Podman runtime recorded for a different engine target", async () => {
const cfg = createSandboxConfig([]);
registryMocks.readRegistryEntry.mockResolvedValue({
containerName: "oc-test-podman-shared",
backendId: "podman",
backendTarget: {
key: `machine:${"a".repeat(32)}`,
globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock"],
},
sessionKey: "shared",
createdAtMs: 1,
lastUsedAtMs: 1,
image: cfg.docker.image,
});
await expect(
ensureSandboxContainer({
engine: PODMAN_SANDBOX_ENGINE,
scopeKey: "shared",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg,
}),
).rejects.toThrow(/active Podman connection changed/u);
expect(spawnState.calls.some((call) => call.args[0] === "inspect")).toBe(false);
});
it("uses collision-safe Docker name truncation for a long container prefix", async () => {
const cfg = createSandboxConfig([]);
cfg.scope = "session";
cfg.docker.containerPrefix = "x".repeat(56);
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const createCall = await ensureSandboxCreateCallForTest({
cfg,
scopeKey: "agent:first:session",
});
const containerName = collectDockerFlagValues(createCall.args, "--name")[0];
expect(containerName).toHaveLength(63);
expect(containerName).toMatch(/^x{50}-[a-f0-9]{12}$/);
});
it("preserves distinct session suffixes with a long Podman container prefix", async () => {
const cfg = createSandboxConfig([]);
cfg.scope = "session";
cfg.docker.containerPrefix = "x".repeat(56);
cfg.docker.user = undefined;
spawnState.containerExists = false;
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const firstCreate = await ensureSandboxCreateCallForTest({
cfg,
scopeKey: "agent:first:session",
engine: PODMAN_SANDBOX_ENGINE,
});
const firstName = collectDockerFlagValues(firstCreate.args, "--name")[0];
spawnState.calls.length = 0;
spawnState.containerExists = false;
const secondCreate = await ensureSandboxCreateCallForTest({
cfg,
scopeKey: "agent:second:session",
engine: PODMAN_SANDBOX_ENGINE,
});
const secondName = collectDockerFlagValues(secondCreate.args, "--name")[0];
expect(firstName).not.toBe(secondName);
expect(firstName?.length).toBeLessThanOrEqual(63);
expect(secondName?.length).toBeLessThanOrEqual(63);
});
it("uses Podman init when mounts leave podman-init visible", async () => {
const cfg = createSandboxConfig([]);
cfg.docker.tmpfs = ["/tmp", "/var/tmp"];
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const createCall = await ensureSandboxCreateCallForTest({
cfg,
engine: PODMAN_SANDBOX_ENGINE,
});
expect(createCall.args).toContain("--init");
});
it("rejects a workdir whose managed workspace bind would cover Podman init", async () => {
const cfg = createSandboxConfig([]);
cfg.docker.workdir = "/run";
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
await expect(
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
).rejects.toThrow("would cover Podman's init path");
});
it("omits the default /run tmpfs for writable-root Podman sandboxes", async () => {
const cfg = createSandboxConfig([]);
cfg.docker.readOnlyRoot = false;
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const createCall = await ensureSandboxCreateCallForTest({
cfg,
engine: PODMAN_SANDBOX_ENGINE,
});
expect(createCall.args).toContain("--init");
expect(createCall.args).not.toContain("--read-only-tmpfs=true");
expect(collectDockerFlagValues(createCall.args, "--tmpfs")).toEqual(["/tmp", "/var/tmp"]);
});
it("rejects an explicitly configured bare /run tmpfs", async () => {
const cfg = createSandboxConfig([]);
cfg.dockerTmpfsSource = "configured";
cfg.docker.readOnlyRoot = false;
cfg.docker.tmpfs = ["/run"];
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
await expect(
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
).rejects.toThrow("would cover Podman's init path");
});
it("invalidates a Podman container when the same tmpfs list becomes explicit", async () => {
const workspaceDir = makeTempDir();
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
const genericHash = computeSandboxConfigHash({
docker: cfg.docker,
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(cfg.docker.env),
workspaceAccess: cfg.workspaceAccess,
workspaceDir,
agentWorkspaceDir: workspaceDir,
mountFormatVersion: SANDBOX_MOUNT_FORMAT_VERSION,
createArgsEpoch: SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
readOnlyWorkspaceSkillMounts: [],
});
const oldHash = `${genericHash}:podman-runtime-v5:keep-id:default`;
cfg.dockerTmpfsSource = "configured";
spawnState.inspectRunning = false;
spawnState.labelHash = oldHash;
registryMocks.readRegistryEntry.mockResolvedValue({
containerName: "oc-test-podman-shared",
backendId: "podman",
backendTarget: { key: "local", globalArgs: [] },
sessionKey: "shared",
createdAtMs: 1,
lastUsedAtMs: 0,
image: cfg.docker.image,
configHash: oldHash,
});
await expect(
ensureSandboxContainer({
engine: PODMAN_SANDBOX_ENGINE,
scopeKey: "agent:main:session-1",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
}),
).rejects.toThrow("would cover Podman's init path");
expect(
spawnState.calls.some(
(call) => call.command === "podman" && call.args[0] === "rm" && call.args[1] === "-f",
),
).toBe(true);
});
it("rejects customized /run tmpfs options instead of discarding them", async () => {
const cfg = createSandboxConfig([]);
cfg.dockerTmpfsSource = "configured";
cfg.docker.tmpfs = ["/run:size=64m,mode=0700"];
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
await expect(
ensureSandboxCreateCallForTest({ cfg, engine: PODMAN_SANDBOX_ENGINE }),
).rejects.toThrow("would cover Podman's init path");
});
it("allows Podman Machine workspaces under the default home share", async () => {
const cfg = createSandboxConfig([]);
const workspaceDir = path.join(os.homedir(), "openclaw-podman-workspace");
cfg.docker.binds = [`${workspaceDir}:/workspace:rw`];
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-default",
URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
IsMachine: true,
Default: true,
},
]);
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const createCall = await ensureSandboxCreateCallForTest({
cfg,
workspaceDir,
engine: PODMAN_SANDBOX_ENGINE,
});
expect(createCall.command).toBe("podman");
expect(createCall.globalArgs).toEqual([
"--url",
"ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
]);
});
it("rejects Podman Machine bind sources outside the default home share", async () => {
const cfg = createSandboxConfig([]);
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-default",
URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
IsMachine: true,
Default: true,
},
]);
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
await expect(
ensureSandboxContainer({
engine: PODMAN_SANDBOX_ENGINE,
scopeKey: "agent:test:session",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg,
}),
).rejects.toThrow(/outside the default host home share/u);
expect(spawnState.calls.some((call) => call.args[0] === "create")).toBe(false);
});
});
+242 -5
View File
@@ -1,6 +1,7 @@
// Docker image tests cover sandbox image inspection and actionable setup errors
// without invoking a real Docker daemon.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../../test-utils/env.js";
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_COMMAND_MAX_BUFFER_BYTES } from "./constants.js";
type SpawnCall = {
@@ -16,6 +17,9 @@ const spawnState = vi.hoisted(() => ({
calls: [] as SpawnCall[],
imageExists: true,
inspectError: "",
infoAvailable: { docker: false, podman: false },
podmanConnections: "[]\n",
podmanInfo: "true\tfalse\n",
lastOptions: undefined as SpawnCallOptions | undefined,
executionError: undefined as Error | undefined,
transportFailure: false,
@@ -41,10 +45,19 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO
}
let code = 0;
let stdout = "";
let stderr = "";
if (command !== "docker") {
if (command !== "docker" && command !== "podman") {
code = 1;
stderr = `unexpected command: ${command}`;
} else if (command === "podman" && args[0] === "system") {
stdout = spawnState.podmanConnections;
} else if (args[0] === "info") {
code = spawnState.infoAvailable[command as "docker" | "podman"] ? 0 : 1;
if (code === 0 && command === "podman" && args.includes("--format")) {
stdout = spawnState.podmanInfo;
}
stderr = code === 0 ? "" : `${command} unavailable`;
} else if (args[0] === "image" && args[1] === "inspect") {
code = spawnState.imageExists ? 0 : 1;
stderr = spawnState.imageExists
@@ -58,7 +71,7 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO
failed: code !== 0,
isCanceled: false,
exitCode: code,
stdout: Buffer.alloc(0),
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
};
}
@@ -69,7 +82,11 @@ vi.mock("../../process/exec.js", async (importOriginal) => ({
}));
let ensureDockerImage: typeof import("./docker.js").ensureDockerImage;
let ensureContainerImage: typeof import("./docker.js").ensureContainerImage;
let execDockerRaw: typeof import("./docker.js").execDockerRaw;
let podmanSandboxEngine: typeof import("./docker.js").PODMAN_SANDBOX_ENGINE;
let resolvePodmanSandboxRuntimeInfo: typeof import("./docker.js").resolvePodmanSandboxRuntimeInfo;
let validateSandboxContainerEngineTarget: typeof import("./docker.js").validateSandboxContainerEngineTarget;
async function loadFreshDockerModuleForTest() {
vi.resetModules();
@@ -77,9 +94,196 @@ async function loadFreshDockerModuleForTest() {
...(await importOriginal<typeof import("../../process/exec.js")>()),
spawnCommand: spawnDockerProcess,
}));
({ ensureDockerImage, execDockerRaw } = await import("./docker.js"));
const dockerModule = await import("./docker.js");
({ ensureContainerImage, ensureDockerImage, execDockerRaw } = dockerModule);
resolvePodmanSandboxRuntimeInfo = dockerModule.resolvePodmanSandboxRuntimeInfo;
validateSandboxContainerEngineTarget = dockerModule.validateSandboxContainerEngineTarget;
podmanSandboxEngine = dockerModule.PODMAN_SANDBOX_ENGINE;
}
describe("resolvePodmanSandboxRuntimeInfo", () => {
beforeEach(async () => {
spawnState.calls.length = 0;
spawnState.infoAvailable.podman = true;
spawnState.podmanConnections = "[]\n";
spawnState.podmanInfo = "true\tfalse\n";
await loadFreshDockerModuleForTest();
});
it("rejects an arbitrary remote Podman connection", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "remote",
URI: "ssh://example.test/run/user/1000/podman/podman.sock",
Default: true,
},
]);
await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(
/active Podman connection is remote/u,
);
});
it("allows Podman Machine connections", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-default",
URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
IsMachine: true,
Default: true,
},
]);
await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({
machine: true,
rootless: true,
target: {
key: expect.stringMatching(/^machine:[a-f0-9]{32}$/u),
globalArgs: ["--url", "ssh://core@127.0.0.1/run/user/501/podman/podman.sock"],
},
});
});
it("rejects an unknown configured remote connection", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-default",
URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
IsMachine: true,
Default: true,
},
]);
await withEnvAsync({ CONTAINER_CONNECTION: "missing", CONTAINER_HOST: undefined }, async () => {
await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(/could not be identified/u);
});
});
it("prefers a configured host URI over a configured connection name", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-default",
URI: "ssh://core@127.0.0.1/run/user/501/podman/podman.sock",
IsMachine: true,
},
]);
await withEnvAsync(
{
CONTAINER_CONNECTION: "podman-machine-default",
CONTAINER_HOST: "ssh://example.test/run/user/1000/podman/podman.sock",
},
async () => {
await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(
/active Podman connection is remote/u,
);
},
);
});
it("validates a named remote connection when the configured host URI is empty", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "remote",
URI: "ssh://example.test/run/user/1000/podman/podman.sock",
},
]);
await withEnvAsync({ CONTAINER_CONNECTION: "remote", CONTAINER_HOST: " " }, async () => {
await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(
/active Podman connection is remote/u,
);
});
});
it("uses Podman's local Unix fallback when no connection is configured", async () => {
spawnState.podmanInfo = "true\ttrue\t/run/user/1000/podman/podman.sock\n";
await withEnvAsync({ CONTAINER_CONNECTION: undefined, CONTAINER_HOST: undefined }, async () => {
await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({
machine: false,
rootless: true,
target: {
key: expect.stringMatching(/^socket:[a-f0-9]{32}$/u),
globalArgs: ["--url", "unix:///run/user/1000/podman/podman.sock"],
},
});
});
});
it("revalidates the active Podman connection on every resolution", async () => {
spawnState.podmanInfo = "true\tfalse\n";
await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({
machine: false,
rootless: true,
target: { key: "local", globalArgs: [] },
});
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "remote",
URI: "ssh://example.test/run/user/1000/podman/podman.sock",
Default: true,
},
]);
await expect(resolvePodmanSandboxRuntimeInfo()).rejects.toThrow(
/active Podman connection is remote/u,
);
});
it("ignores a saved remote default while the CLI uses its local engine", async () => {
spawnState.podmanConnections = JSON.stringify([
{
Name: "saved-remote",
URI: "ssh://example.test/run/user/1000/podman/podman.sock",
Default: true,
},
]);
await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({
machine: false,
rootless: true,
target: { key: "local", globalArgs: [] },
});
expect(spawnState.calls.some((call) => call.args[0] === "system")).toBe(false);
});
it("rejects a different allowed Podman Machine after a runtime target is recorded", async () => {
spawnState.podmanInfo = "true\ttrue\n";
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-first",
URI: "ssh://core@127.0.0.1:60001/run/user/501/podman/podman.sock",
Identity: "/tmp/first-machine-key",
IsMachine: true,
Default: true,
},
]);
const first = await resolvePodmanSandboxRuntimeInfo();
spawnState.podmanConnections = JSON.stringify([
{
Name: "podman-machine-second",
URI: "ssh://core@127.0.0.1:60002/run/user/501/podman/podman.sock",
Identity: "/tmp/second-machine-key",
IsMachine: true,
Default: true,
},
]);
await expect(
validateSandboxContainerEngineTarget(podmanSandboxEngine, first.target),
).rejects.toThrow(/active Podman connection changed/u);
});
});
describe("ensureDockerImage", () => {
beforeEach(async () => {
spawnState.calls.length = 0;
@@ -116,8 +320,9 @@ describe("ensureDockerImage", () => {
}
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain("scripts/sandbox-setup.sh");
expect((err as Error).message).toContain("python3");
expect((err as Error).message).toBe(
`Sandbox image not found: ${DEFAULT_SANDBOX_IMAGE}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
);
expect(spawnState.calls).toEqual([
{
command: "docker",
@@ -126,6 +331,21 @@ describe("ensureDockerImage", () => {
]);
});
it("gives Podman users a Podman build command for the missing default image", async () => {
spawnState.imageExists = false;
await expect(ensureContainerImage(podmanSandboxEngine, DEFAULT_SANDBOX_IMAGE)).rejects.toThrow(
`podman build -t ${DEFAULT_SANDBOX_IMAGE} -f scripts/docker/sandbox/Dockerfile .`,
);
expect(spawnState.calls).toEqual([
{
command: "podman",
args: ["image", "inspect", DEFAULT_SANDBOX_IMAGE],
},
]);
});
it("throws when the Docker daemon is unavailable during image inspection", async () => {
spawnState.imageExists = false;
spawnState.inspectError =
@@ -142,6 +362,23 @@ describe("ensureDockerImage", () => {
},
]);
});
it("preserves the Docker error for other image inspection failures", async () => {
spawnState.imageExists = false;
spawnState.inspectError = "permission denied";
await expect(ensureDockerImage(DEFAULT_SANDBOX_IMAGE)).rejects.toThrow(
"Failed to inspect sandbox image: permission denied",
);
});
it("preserves the Docker error for a missing custom image", async () => {
spawnState.imageExists = false;
await expect(ensureDockerImage("example/custom:latest")).rejects.toThrow(
"Sandbox image not found: example/custom:latest. Build or pull it first.",
);
});
});
describe("execDockerRaw", () => {
+172 -139
View File
@@ -3,97 +3,62 @@
*
* Wraps Docker spawn, environment sanitization, container inspection, creation, and exec behavior.
*/
import { createAbortError } from "../../infra/abort-signal.js";
import { toErrorObject } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { isPlainCommandExitFailure, spawnCommand } from "../../process/exec.js";
import { sanitizeEnvVars, sanitizeExplicitSandboxEnvVars } from "./sanitize-env-vars.js";
import {
DOCKER_SANDBOX_ENGINE,
execContainer,
execContainerRaw,
type ExecContainerRawOptions,
type ExecDockerRawResult,
type SandboxContainerEngine,
type SandboxContainerEngineTarget,
} from "./container-engine.js";
import {
assertPodmanSandboxTarget,
bindPodmanSandboxEngine,
resolvePodmanSandboxConfigHash,
resolvePodmanSandboxContainerPrefix,
resolvePodmanSandboxCreatePolicy,
resolvePodmanSandboxRuntimeInfo,
type PodmanSandboxRuntimeInfo,
} from "./podman-runtime.js";
import {
resolveDockerEnvPolicyEpoch,
sanitizeExplicitSandboxEnvVars,
} from "./sanitize-env-vars.js";
type ExecDockerRawOptions = {
allowFailure?: boolean;
input?: Buffer | string;
signal?: AbortSignal;
};
export {
DOCKER_SANDBOX_ENGINE,
execContainer,
execContainerRaw,
PODMAN_SANDBOX_ENGINE,
} from "./container-engine.js";
export type {
ExecDockerRawResult,
SandboxContainerEngine,
SandboxContainerEngineTarget,
} from "./container-engine.js";
export {
bindPodmanSandboxEngine,
resolvePodmanSandboxRuntimeInfo,
validateSandboxContainerEngineTarget,
} from "./podman-runtime.js";
export type { PodmanSandboxRuntimeInfo } from "./podman-runtime.js";
export { resolveDockerEnvPolicyEpoch } from "./sanitize-env-vars.js";
export type ExecDockerRawResult = {
stdout: Buffer;
stderr: Buffer;
code: number;
};
type ExecDockerRawError = Error & {
code: number;
stdout: Buffer;
stderr: Buffer;
};
type ExecDockerRawOptions = ExecContainerRawOptions;
export async function execDockerRaw(
args: string[],
opts?: ExecDockerRawOptions,
): Promise<ExecDockerRawResult> {
let result;
try {
result = await spawnCommand(["docker", ...args], {
cancelSignal: opts?.signal,
encoding: "buffer",
input: opts?.input ?? Buffer.alloc(0),
maxBuffer: SANDBOX_COMMAND_MAX_BUFFER_BYTES,
reject: false,
stripFinalNewline: false,
});
} catch (error) {
if (opts?.signal?.aborted) {
throw createAbortError("Aborted");
}
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw Object.assign(
new Error(
'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.',
),
{ code: "INVALID_CONFIG", cause: error },
);
}
throw error;
}
if (opts?.signal?.aborted || result.isCanceled) {
throw createAbortError("Aborted");
}
if (result.failed && !isPlainCommandExitFailure(result)) {
if (result.code === "ENOENT") {
throw Object.assign(
new Error(
'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.',
),
{ code: "INVALID_CONFIG", cause: result },
);
}
throw toErrorObject(result, "Docker command execution failed");
}
const stdout = Buffer.from(result.stdout);
const stderr = Buffer.from(result.stderr);
const exitCode = result.exitCode ?? (result.failed ? 1 : 0);
if (exitCode !== 0 && !opts?.allowFailure) {
const message = stderr.length > 0 ? stderr.toString("utf8").trim() : "";
const error: ExecDockerRawError = Object.assign(
new Error(message || `docker ${args.join(" ")} failed`),
{ code: exitCode, stdout, stderr },
);
throw error;
}
return { stdout, stderr, code: exitCode };
return await execContainerRaw(DOCKER_SANDBOX_ENGINE, args, opts);
}
import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import {
computeSandboxConfigHash,
SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH,
} from "./config-hash.js";
import {
DEFAULT_SANDBOX_IMAGE,
SANDBOX_COMMAND_MAX_BUFFER_BYTES,
SANDBOX_DOCKER_CREATE_ARGS_EPOCH,
} from "./constants.js";
import { computeSandboxConfigHash } from "./config-hash.js";
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_DOCKER_CREATE_ARGS_EPOCH } from "./constants.js";
import { handleHotSandboxConfigMismatch } from "./current-config.js";
import { readRegistryEntry, updateRegistry } from "./registry.js";
import { buildSandboxContainerName, slugifySessionKey } from "./shared.js";
@@ -115,31 +80,6 @@ const sandboxContainerLifecycleQueue = new KeyedAsyncQueue();
type ExecDockerOptions = ExecDockerRawOptions;
function envRecordsEqual(left: Record<string, string>, right: Record<string, string>): boolean {
const leftEntries = Object.entries(left).toSorted(([leftKey], [rightKey]) =>
leftKey.localeCompare(rightKey),
);
const rightEntries = Object.entries(right).toSorted(([leftKey], [rightKey]) =>
leftKey.localeCompare(rightKey),
);
if (leftEntries.length !== rightEntries.length) {
return false;
}
return leftEntries.every(([key, value], index) => {
const rightEntry = rightEntries[index];
return rightEntry?.[0] === key && rightEntry[1] === value;
});
}
export function resolveDockerEnvPolicyEpoch(env: Record<string, string | undefined> | undefined) {
const explicitEnv = env ?? {};
const previousAllowed = sanitizeEnvVars(explicitEnv).allowed;
const currentAllowed = sanitizeExplicitSandboxEnvVars(explicitEnv).allowed;
return envRecordsEqual(previousAllowed, currentAllowed)
? undefined
: SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH;
}
export async function execDocker(args: string[], opts?: ExecDockerOptions) {
const result = await execDockerRaw(args, opts);
return {
@@ -153,7 +93,16 @@ export async function readDockerContainerLabel(
containerName: string,
label: string,
): Promise<string | null> {
const result = await execDocker(
return await readContainerLabel(DOCKER_SANDBOX_ENGINE, containerName, label);
}
export async function readContainerLabel(
engine: SandboxContainerEngine,
containerName: string,
label: string,
): Promise<string | null> {
const result = await execContainer(
engine,
["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName],
{ allowFailure: true },
);
@@ -224,38 +173,66 @@ export function formatDockerDaemonUnavailableError(stderr: string): string {
.join(" ");
}
async function inspectDockerImage(image: string): Promise<"exists" | "missing"> {
const result = await execDocker(["image", "inspect", image], {
async function inspectContainerImage(
engine: SandboxContainerEngine,
image: string,
): Promise<"exists" | "missing"> {
const result = await execContainer(engine, ["image", "inspect", image], {
allowFailure: true,
});
if (result.code === 0) {
return "exists";
}
const stderr = result.stderr.trim();
if (stderr.toLowerCase().includes("no such image")) {
const imageMissing =
engine.id === "docker"
? stderr.toLowerCase().includes("no such image")
: /no such image|image not known|image .* not found/iu.test(stderr);
if (imageMissing) {
return "missing";
}
if (isDockerDaemonUnavailable(stderr)) {
if (engine.id === "docker" && isDockerDaemonUnavailable(stderr)) {
throw new Error(formatDockerDaemonUnavailableError(stderr));
}
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
if (engine.id === "docker") {
throw new Error(`Failed to inspect sandbox image: ${stderr}`);
}
throw new Error(`Failed to inspect sandbox image with ${engine.displayName}: ${stderr}`);
}
export async function ensureDockerImage(image: string) {
const imageState = await inspectDockerImage(image);
await ensureContainerImage(DOCKER_SANDBOX_ENGINE, image);
}
export async function ensureContainerImage(engine: SandboxContainerEngine, image: string) {
const imageState = await inspectContainerImage(engine, image);
if (imageState === "exists") {
return;
}
if (image === DEFAULT_SANDBOX_IMAGE) {
if (engine.id === "docker") {
throw new Error(
`Sandbox image not found: ${image}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
);
}
throw new Error(
`Sandbox image not found: ${image}. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
`Sandbox image not found in ${engine.displayName}: ${image}. Build it with podman build -t ${image} -f scripts/docker/sandbox/Dockerfile . before enabling container sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.`,
);
}
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
if (engine.id === "docker") {
throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`);
}
throw new Error(
`Sandbox image not found in ${engine.displayName}: ${image}. Build or pull it first.`,
);
}
export async function dockerContainerState(name: string) {
const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], {
return await containerState(DOCKER_SANDBOX_ENGINE, name);
}
export async function containerState(engine: SandboxContainerEngine, name: string) {
const result = await execContainer(engine, ["inspect", "-f", "{{.State.Running}}", name], {
allowFailure: true,
});
if (result.code !== 0) {
@@ -338,8 +315,8 @@ export function buildSandboxCreateArgs(params: {
const createdAtMs = params.createdAtMs ?? Date.now();
const args = ["create", "--name", params.name];
// Docker's init owns PID 1 so orphaned children from long-running tool and
// browser workloads are reaped instead of accumulating against pidsLimit.
// The container engine's init owns PID 1 so orphaned children from long-running
// tool and browser workloads are reaped instead of accumulating against pidsLimit.
args.push("--init");
args.push("--label", "openclaw.sandbox=1");
args.push("--label", `openclaw.sessionKey=${params.scopeKey}`);
@@ -444,8 +421,10 @@ function appendCustomBinds(args: string[], cfg: SandboxDockerConfig): void {
}
async function createSandboxContainer(params: {
engine: SandboxContainerEngine;
name: string;
cfg: SandboxDockerConfig;
dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"];
workspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
@@ -453,18 +432,35 @@ async function createSandboxContainer(params: {
scopeKey: string;
configHash?: string;
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
podmanRuntimeInfo?: PodmanSandboxRuntimeInfo;
}) {
const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image);
const { engine, name, cfg, workspaceDir, scopeKey } = params;
const podmanPolicy =
engine.id === "podman" && params.podmanRuntimeInfo
? resolvePodmanSandboxCreatePolicy({
cfg,
dockerTmpfsSource: params.dockerTmpfsSource,
workspaceDir,
workspaceAccess: params.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
readOnlyWorkspaceSkillMounts: params.readOnlyWorkspaceSkillMounts,
runtimeInfo: params.podmanRuntimeInfo,
})
: undefined;
const createCfg = podmanPolicy?.cfg ?? cfg;
await ensureContainerImage(engine, cfg.image);
const args = buildSandboxCreateArgs({
name,
cfg,
cfg: createCfg,
scopeKey,
configHash: params.configHash,
includeBinds: false,
bindSourceRoots: [workspaceDir, params.agentWorkspaceDir],
});
if (podmanPolicy) {
args.push(...podmanPolicy.extraCreateArgs);
}
args.push("--workdir", cfg.workdir);
appendWorkspaceMountArgs({
args,
@@ -483,19 +479,24 @@ async function createSandboxContainer(params: {
});
args.push(cfg.image, "sleep", "infinity");
await execDocker(args);
await execDocker(["start", name]);
await execContainer(engine, args);
await execContainer(engine, ["start", name]);
if (cfg.setupCommand?.trim()) {
await execDocker(["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]);
await execContainer(engine, ["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]);
}
}
async function readContainerConfigHash(containerName: string): Promise<string | null> {
return await readDockerContainerLabel(containerName, "openclaw.configHash");
async function readContainerConfigHash(
engine: SandboxContainerEngine,
containerName: string,
): Promise<string | null> {
return await readContainerLabel(engine, containerName, "openclaw.configHash");
}
type EnsureSandboxContainerParams = {
engine?: SandboxContainerEngine;
podmanTarget?: SandboxContainerEngineTarget;
scopeKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
@@ -505,10 +506,15 @@ type EnsureSandboxContainerParams = {
};
export async function ensureSandboxContainer(params: EnsureSandboxContainerParams) {
const engine = params.engine ?? DOCKER_SANDBOX_ENGINE;
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
const containerName = buildSandboxContainerName(params.cfg.docker.containerPrefix, slug);
const prefix =
engine.id === "podman"
? resolvePodmanSandboxContainerPrefix(params.cfg.docker.containerPrefix)
: params.cfg.docker.containerPrefix;
const containerName = buildSandboxContainerName(prefix, slug);
// Independent agent runs can converge on one Docker resource. Serialize the
// Independent agent runs can converge on one container resource. Serialize the
// full lifecycle so followers re-read state after create, start, or replace.
return await sandboxContainerLifecycleQueue.enqueue(containerName, async () => {
return await ensureSandboxContainerLifecycle(params, containerName);
@@ -519,6 +525,27 @@ async function ensureSandboxContainerLifecycle(
params: EnsureSandboxContainerParams,
containerName: string,
) {
const configuredEngine = params.engine ?? DOCKER_SANDBOX_ENGINE;
const podmanRuntimeInfo =
configuredEngine.id === "podman" ? await resolvePodmanSandboxRuntimeInfo() : undefined;
if (podmanRuntimeInfo) {
assertPodmanSandboxTarget(params.podmanTarget, podmanRuntimeInfo.target);
}
const engine = podmanRuntimeInfo
? bindPodmanSandboxEngine(podmanRuntimeInfo.target)
: configuredEngine;
const existingRegistryEntry = await readRegistryEntry(containerName);
if (engine.id === "podman" && existingRegistryEntry) {
if (!existingRegistryEntry.backendTarget) {
throw Object.assign(
new Error(
`Podman sandbox runtime ${containerName} has no recorded engine target. Remove that unshipped runtime manually before recreating it.`,
),
{ code: "INVALID_CONFIG" },
);
}
assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target);
}
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
@@ -526,7 +553,7 @@ async function ensureSandboxContainerLifecycle(
workdir: params.cfg.docker.workdir,
workspaceAccess: params.cfg.workspaceAccess,
});
const expectedHash = computeSandboxConfigHash({
const genericConfigHash = computeSandboxConfigHash({
docker: params.cfg.docker,
dockerEnvPolicyEpoch: resolveDockerEnvPolicyEpoch(params.cfg.docker.env),
workspaceAccess: params.cfg.workspaceAccess,
@@ -538,21 +565,23 @@ async function ensureSandboxContainerLifecycle(
readOnlyWorkspaceSkillMounts,
),
});
const expectedHash =
engine.id === "podman"
? resolvePodmanSandboxConfigHash({
genericConfigHash,
configuredUser: Boolean(params.cfg.docker.user),
dockerTmpfsSource: params.cfg.dockerTmpfsSource,
})
: genericConfigHash;
const now = Date.now();
const state = await dockerContainerState(containerName);
const state = await containerState(engine, containerName);
let hasContainer = state.exists;
let running = state.running;
let currentHash: string | null = null;
let hashMismatch = false;
let registryEntry:
| {
lastUsedAtMs: number;
configHash?: string;
}
| undefined;
const registryEntry = existingRegistryEntry ?? undefined;
if (hasContainer) {
registryEntry = (await readRegistryEntry(containerName)) ?? undefined;
currentHash = await readContainerConfigHash(containerName);
currentHash = await readContainerConfigHash(engine, containerName);
if (!currentHash) {
currentHash = registryEntry?.configHash ?? null;
}
@@ -572,7 +601,7 @@ async function ensureSandboxContainerLifecycle(
: {}),
});
} else {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
await execContainer(engine, ["rm", "-f", containerName], { allowFailure: true });
hasContainer = false;
running = false;
}
@@ -580,8 +609,10 @@ async function ensureSandboxContainerLifecycle(
}
if (!hasContainer) {
await createSandboxContainer({
engine,
name: containerName,
cfg: params.cfg.docker,
dockerTmpfsSource: params.cfg.dockerTmpfsSource,
workspaceDir: params.workspaceDir,
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
@@ -589,13 +620,15 @@ async function ensureSandboxContainerLifecycle(
scopeKey: params.scopeKey,
configHash: expectedHash,
readOnlyWorkspaceSkillMounts,
podmanRuntimeInfo,
});
} else if (!running) {
await execDocker(["start", containerName]);
await execContainer(engine, ["start", containerName]);
}
await updateRegistry({
containerName,
backendId: "docker",
backendId: engine.id,
...(podmanRuntimeInfo ? { backendTarget: podmanRuntimeInfo.target } : {}),
runtimeLabel: containerName,
sessionKey: params.scopeKey,
createdAtMs: now,
@@ -25,8 +25,16 @@ const hoisted = vi.hoisted(
);
vi.mock("./docker.js", () => ({
DOCKER_SANDBOX_ENGINE: { id: "docker", command: "docker", displayName: "Docker" },
PODMAN_SANDBOX_ENGINE: { id: "podman", command: "podman", displayName: "Podman" },
execContainerRaw: (
_engine: unknown,
args: ExecDockerArgs,
opts?: Parameters<ExecDockerRawFn>[1],
) => hoisted.execDockerRaw(args, opts),
execDockerRaw: (args: ExecDockerArgs, opts?: Parameters<ExecDockerRawFn>[1]) =>
hoisted.execDockerRaw(args, opts),
validateSandboxContainerEngineTarget: vi.fn(),
}));
async function createPathSafetyRuntimeMock() {
@@ -50,8 +58,16 @@ let createSandboxFsBridgeImpl: typeof import("./fs-bridge.js").createSandboxFsBr
async function loadFreshFsBridgeModuleForTest() {
vi.resetModules();
vi.doMock("./docker.js", () => ({
DOCKER_SANDBOX_ENGINE: { id: "docker", command: "docker", displayName: "Docker" },
PODMAN_SANDBOX_ENGINE: { id: "podman", command: "podman", displayName: "Podman" },
execContainerRaw: (
_engine: unknown,
args: ExecDockerArgs,
opts?: Parameters<ExecDockerRawFn>[1],
) => hoisted.execDockerRaw(args, opts),
execDockerRaw: (args: ExecDockerArgs, opts?: Parameters<ExecDockerRawFn>[1]) =>
hoisted.execDockerRaw(args, opts),
validateSandboxContainerEngineTarget: vi.fn(),
}));
vi.doMock("./fs-bridge-path-safety.runtime.js", createPathSafetyRuntimeMock);
({ createSandboxFsBridge: createSandboxFsBridgeImpl } = await import("./fs-bridge.js"));
+5
View File
@@ -43,10 +43,15 @@ vi.mock("./registry.js", () => ({
vi.mock("./docker-backend.js", () => ({
createDockerSandboxBackend: vi.fn(),
createPodmanSandboxBackend: vi.fn(),
dockerSandboxBackendManager: {
describeRuntime: backendMocks.describeRuntime,
removeRuntime: backendMocks.removeRuntime,
},
podmanSandboxBackendManager: {
describeRuntime: vi.fn(),
removeRuntime: vi.fn(),
},
}));
beforeAll(async () => {
+280
View File
@@ -0,0 +1,280 @@
import os from "node:os";
import path from "node:path";
import { isPathInside } from "../../infra/path-guards.js";
import { splitSandboxBindSpec } from "./bind-spec.js";
import {
execContainer,
PODMAN_SANDBOX_ENGINE,
type SandboxContainerEngine,
type SandboxContainerEngineTarget,
} from "./container-engine.js";
import { hashTextSha256 } from "./hash.js";
import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
import type { ReadOnlyWorkspaceSkillMount } from "./workspace-mounts.js";
const SANDBOX_ENGINE_PROBE_TIMEOUT_MS = 5_000;
const PODMAN_INIT_PATH = "/run/podman-init";
export type PodmanSandboxRuntimeInfo = {
machine: boolean;
rootless: boolean;
target: SandboxContainerEngineTarget;
};
function hashPodmanTarget(kind: "machine" | "socket", ...parts: string[]): string {
return `${kind}:${hashTextSha256(parts.join("\0")).slice(0, 32)}`;
}
function invalidPodmanConfig(message: string): Error {
return Object.assign(new Error(message), { code: "INVALID_CONFIG" });
}
async function assertSupportedPodmanConnection(remoteSocketPath: string): Promise<{
machine: boolean;
target: SandboxContainerEngineTarget;
}> {
const result = await execContainer(
PODMAN_SANDBOX_ENGINE,
["system", "connection", "list", "--format", "json"],
{
allowFailure: true,
signal: AbortSignal.timeout(SANDBOX_ENGINE_PROBE_TIMEOUT_MS),
},
);
if (result.code !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
throw new Error(`Failed to inspect the active Podman connection: ${detail}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch (error) {
throw new Error("Podman returned invalid connection metadata", { cause: error });
}
const connections = Array.isArray(parsed)
? parsed.filter(
(entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null,
)
: [];
const configuredUri = process.env.CONTAINER_HOST?.trim();
const configuredName = process.env.CONTAINER_CONNECTION?.trim();
let selected: Record<string, unknown> | undefined;
// Podman resolves the explicit URL/CONTAINER_HOST before named or saved destinations.
if (configuredUri) {
selected = connections.find((entry) => entry.URI === configuredUri);
} else if (configuredName) {
selected = connections.find((entry) => entry.Name === configuredName);
} else {
// OpenClaw supplies no Podman global connection flag, so Podman's documented
// selection order reaches the saved default after the env overrides above.
selected = connections.find((entry) => entry.Default === true);
}
const selectedUri =
configuredUri ||
(typeof selected?.URI === "string" ? selected.URI : "") ||
(remoteSocketPath ? `unix://${remoteSocketPath}` : "");
const unsupportedRemoteError = () =>
invalidPodmanConfig(
"Podman sandboxing supports a local Podman engine or Podman Machine, but the active Podman connection is remote or could not be identified. Use the SSH sandbox backend for a remote host.",
);
if (!configuredUri && configuredName && !selected) {
throw unsupportedRemoteError();
}
if (!selectedUri) {
throw unsupportedRemoteError();
}
if (selectedUri && !selectedUri.startsWith("unix://")) {
if (selected?.IsMachine === true) {
const identity =
process.env.CONTAINER_SSHKEY?.trim() ||
(typeof selected.Identity === "string" ? selected.Identity : "");
return {
machine: true,
target: {
key: hashPodmanTarget("machine", selectedUri, identity),
globalArgs: ["--url", selectedUri, ...(identity ? ["--identity", identity] : [])],
},
};
}
throw unsupportedRemoteError();
}
return {
machine: false,
target: {
key: hashPodmanTarget("socket", selectedUri),
globalArgs: ["--url", selectedUri],
},
};
}
export async function resolvePodmanSandboxRuntimeInfo(): Promise<PodmanSandboxRuntimeInfo> {
const result = await execContainer(
PODMAN_SANDBOX_ENGINE,
[
"info",
"--format",
"{{.Host.Security.Rootless}}\t{{.Host.ServiceIsRemote}}\t{{.Host.RemoteSocket.Path}}",
],
{
allowFailure: true,
signal: AbortSignal.timeout(SANDBOX_ENGINE_PROBE_TIMEOUT_MS),
},
);
if (result.code !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
throw new Error(`Failed to inspect Podman user namespace mode: ${detail}`);
}
const [rootless = "", serviceIsRemote = "", remoteSocketPath = ""] = result.stdout
.trim()
.split("\t", 3);
let machine = false;
let target: SandboxContainerEngineTarget = { key: "local", globalArgs: [] };
if (serviceIsRemote === "true") {
({ machine, target } = await assertSupportedPodmanConnection(remoteSocketPath));
}
return { machine, rootless: rootless === "true", target };
}
export async function validateSandboxContainerEngineTarget(
engine: SandboxContainerEngine,
expectedTarget?: SandboxContainerEngineTarget,
): Promise<void> {
if (engine.id === "podman") {
// Podman resolves its active connection for every invocation. Validate once
// at the start of each lifecycle sequence so context changes cannot reuse stale approval.
const runtimeInfo = await resolvePodmanSandboxRuntimeInfo();
assertPodmanSandboxTarget(expectedTarget, runtimeInfo.target);
}
}
export function assertPodmanSandboxTarget(
expectedTarget: SandboxContainerEngineTarget | undefined,
actualTarget: SandboxContainerEngineTarget,
): void {
if (
expectedTarget &&
(actualTarget.key !== expectedTarget.key ||
actualTarget.globalArgs.length !== expectedTarget.globalArgs.length ||
actualTarget.globalArgs.some((arg, index) => arg !== expectedTarget.globalArgs[index]))
) {
throw invalidPodmanConfig(
"The active Podman connection changed after this sandbox runtime was created. Restore the original Podman target before inspecting, executing, or removing the runtime.",
);
}
}
export function bindPodmanSandboxEngine(
target: SandboxContainerEngineTarget,
): SandboxContainerEngine {
return {
...PODMAN_SANDBOX_ENGINE,
globalArgs: target.globalArgs,
};
}
function mountTargetCoversPodmanInit(target: string): boolean {
const normalizedTarget = path.posix.normalize(target.trim());
return (
normalizedTarget === "/" ||
normalizedTarget === PODMAN_INIT_PATH ||
PODMAN_INIT_PATH.startsWith(`${normalizedTarget}/`) ||
normalizedTarget.startsWith(`${PODMAN_INIT_PATH}/`)
);
}
function assertPodmanMachineBindSourcesSupported(params: {
cfg: SandboxDockerConfig;
workspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
}): void {
const hostHome = resolveSandboxHostPathViaExistingAncestor(path.resolve(os.homedir()));
const sources = new Set<string>([params.workspaceDir]);
if (params.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) {
sources.add(params.agentWorkspaceDir);
}
for (const mount of params.readOnlyWorkspaceSkillMounts) {
sources.add(mount.hostPath);
}
for (const bind of params.cfg.binds ?? []) {
const source = splitSandboxBindSpec(bind)?.host.trim();
if (source) {
sources.add(source);
}
}
for (const source of sources) {
const canonicalSource = resolveSandboxHostPathViaExistingAncestor(path.resolve(source));
if (isPathInside(hostHome, canonicalSource)) {
continue;
}
throw invalidPodmanConfig(
`Podman Machine sandbox bind source "${source}" is outside the default host home share "${os.homedir()}". Move the workspace or bind under the host home directory, or use Docker or the SSH sandbox backend.`,
);
}
}
export function resolvePodmanSandboxCreatePolicy(params: {
cfg: SandboxDockerConfig;
dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"];
workspaceDir: string;
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
readOnlyWorkspaceSkillMounts: readonly ReadOnlyWorkspaceSkillMount[];
runtimeInfo: PodmanSandboxRuntimeInfo;
}): {
cfg: SandboxDockerConfig;
extraCreateArgs: string[];
} {
const cfg =
params.dockerTmpfsSource === "default"
? {
...params.cfg,
// The shared default includes bare /run, but Podman mounts its init there.
// Read-only roots get Podman's native /run tmpfs below; writable roots use /run directly.
tmpfs: params.cfg.tmpfs.filter((entry) => entry.trim() !== "/run"),
}
: params.cfg;
const hasInitMountConflict =
// workdir is also the managed workspace bind target, not only the process cwd.
mountTargetCoversPodmanInit(params.cfg.workdir) ||
cfg.tmpfs.some((entry) => mountTargetCoversPodmanInit(entry.split(":", 1)[0]?.trim() || "")) ||
params.cfg.binds?.some((bind) => {
const target = splitSandboxBindSpec(bind)?.container.trim();
return target ? mountTargetCoversPodmanInit(target) : false;
}) === true;
if (hasInitMountConflict) {
throw invalidPodmanConfig(
"Podman sandbox configuration would cover Podman's init path at /run/podman-init. Remove the conflicting tmpfs or bind mount so orphaned sandbox processes can be reaped.",
);
}
if (params.runtimeInfo.machine) {
assertPodmanMachineBindSourcesSupported(params);
}
const extraCreateArgs = ["--http-proxy=false"];
if (params.cfg.readOnlyRoot) {
extraCreateArgs.push("--read-only-tmpfs=true");
}
if (!params.cfg.user) {
// Resolve against the engine host so native remote contexts and Podman machines use
// their own identity without reserving every subordinate ID.
extraCreateArgs.push("--userns", "keep-id");
}
return { cfg, extraCreateArgs };
}
export function resolvePodmanSandboxConfigHash(params: {
genericConfigHash: string;
configuredUser: boolean;
dockerTmpfsSource: SandboxConfig["dockerTmpfsSource"];
}): string {
const userMode = params.configuredUser ? "configured-user" : "keep-id";
return `${params.genericConfigHash}:podman-runtime-v5:${userMode}:${params.dockerTmpfsSource}`;
}
export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string {
return `${containerPrefix}podman-`;
}
+1
View File
@@ -63,6 +63,7 @@ function buildPruneConfig(): SandboxConfig {
scope: "session",
workspaceAccess: "none",
workspaceRoot: "/tmp/openclaw-sandboxes",
dockerTmpfsSource: "configured",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
+27
View File
@@ -341,6 +341,33 @@ describe("registry race safety", () => {
await expect(readRegistryEntry("missing-container")).resolves.toBeNull();
});
it("preserves a Podman target across registry usage updates", async () => {
await updateRegistry(
containerEntry({
backendId: "podman",
backendTarget: {
key: "machine:target-a",
globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/podman/podman.sock"],
},
}),
);
await updateRegistry(
containerEntry({
backendId: "podman",
lastUsedAtMs: 2,
}),
);
await expect(readRegistryEntry("container-a")).resolves.toMatchObject({
backendId: "podman",
backendTarget: {
key: "machine:target-a",
globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/podman/podman.sock"],
},
lastUsedAtMs: 2,
});
});
it("reads registered runtime IDs for one backend and scope newest first", async () => {
await updateRegistry(
containerEntry({
+3
View File
@@ -22,10 +22,12 @@ import {
SANDBOX_CONTAINERS_DIR,
SANDBOX_REGISTRY_PATH,
} from "./constants.js";
import type { SandboxContainerEngineTarget } from "./container-engine.js";
export type SandboxRegistryEntry = {
containerName: string;
backendId?: string;
backendTarget?: SandboxContainerEngineTarget;
runtimeLabel?: string;
sessionKey: string;
createdAtMs: number;
@@ -174,6 +176,7 @@ function containerEntryToRow(entry: SandboxRegistryEntry, existing?: SandboxRegi
const next: SandboxRegistryEntry = {
...entry,
backendId: entry.backendId ?? existing?.backendId,
backendTarget: entry.backendTarget ?? existing?.backendTarget,
runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
+28
View File
@@ -7,6 +7,7 @@
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { isInstalledPluginEnabled } from "../../plugins/installed-plugin-index.js";
import { listKnownSecretEnvVarNames } from "../../secrets/provider-env-vars.js";
import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js";
const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^ANTHROPIC_API_KEY$/i,
@@ -55,6 +56,33 @@ type EnvSanitizationOptions = {
const MAX_ENV_VAR_VALUE_BYTES = 32768;
function envRecordsEqual(left: Record<string, string>, right: Record<string, string>): boolean {
const leftEntries = Object.entries(left).toSorted(([leftKey], [rightKey]) =>
leftKey.localeCompare(rightKey),
);
const rightEntries = Object.entries(right).toSorted(([leftKey], [rightKey]) =>
leftKey.localeCompare(rightKey),
);
if (leftEntries.length !== rightEntries.length) {
return false;
}
return leftEntries.every(([key, value], index) => {
const rightEntry = rightEntries[index];
return rightEntry?.[0] === key && rightEntry[1] === value;
});
}
export function resolveDockerEnvPolicyEpoch(
env: Record<string, string | undefined> | undefined,
): string | undefined {
const explicitEnv = env ?? {};
const previousAllowed = sanitizeEnvVars(explicitEnv).allowed;
const currentAllowed = sanitizeExplicitSandboxEnvVars(explicitEnv).allowed;
return envRecordsEqual(previousAllowed, currentAllowed)
? undefined
: SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH;
}
/** Returns a warning or block reason for environment values that look unsafe to forward. */
export function validateEnvVarValue(value: string): string | undefined {
if (value.includes("\0")) {
+2
View File
@@ -102,6 +102,7 @@ function createBackendSandboxConfig(params?: { binds?: string[]; target?: string
scope: "session",
workspaceAccess: "rw" as const,
workspaceRoot: "~/.openclaw/sandboxes",
dockerTmpfsSource: "configured",
docker: {
image: "img",
containerPrefix: "prefix-",
@@ -414,6 +415,7 @@ describe("ssh sandbox backend", () => {
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "~/.openclaw/sandboxes",
dockerTmpfsSource: "configured",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
+2
View File
@@ -79,6 +79,8 @@ export type SandboxConfig = {
scope: SandboxScope;
workspaceAccess: SandboxWorkspaceAccess;
workspaceRoot: string;
// Podman must omit only the inherited bare /run tmpfs default; explicit /run is rejected.
dockerTmpfsSource: "default" | "configured";
docker: SandboxDockerConfig;
ssh: SandboxSshConfig;
browser: SandboxBrowserConfig;
+1 -1
View File
@@ -158,7 +158,7 @@ export function resolveSandboxWorkspaceAuthority(params: {
return { sandboxed: false, workspaceAccess: sandbox.workspaceAccess };
}
let confinementError: string | undefined;
if (sandbox.backend !== "docker") {
if (sandbox.backend !== "docker" && sandbox.backend !== "podman") {
confinementError = "target sandbox backend does not provide local workspace confinement.";
} else if (sandbox.scope !== "session") {
confinementError = "target sandbox is not exclusive to this worker session.";
+73 -34
View File
@@ -9,6 +9,11 @@ import {
isDockerDaemonUnavailable,
resolveSandboxScope,
} from "../agents/sandbox.js";
import {
DOCKER_SANDBOX_ENGINE,
PODMAN_SANDBOX_ENGINE,
validateSandboxContainerEngineTarget,
} from "../agents/sandbox/docker.js";
import {
inspectLegacySandboxRegistryFiles,
migrateLegacySandboxRegistryFiles,
@@ -84,11 +89,15 @@ async function runSandboxScript(scriptRel: string, runtime: RuntimeEnv): Promise
return true;
}
async function isDockerAvailable(): Promise<boolean> {
async function isContainerEngineAvailable(command: "docker" | "podman"): Promise<boolean> {
try {
await runExec("docker", ["version", "--format", "{{.Server.Version}}"], {
timeoutMs: 5_000,
});
await runExec(
command,
command === "docker" ? ["version", "--format", "{{.Server.Version}}"] : ["info"],
{
timeoutMs: 5_000,
},
);
return true;
} catch {
return false;
@@ -146,7 +155,10 @@ async function probeCodexBwrapNamespaces(cfg: OpenClawConfig): Promise<CodexBwra
]);
}
async function noteCodexBwrapNamespaceWarning(cfg: OpenClawConfig): Promise<void> {
async function noteCodexBwrapNamespaceWarning(
cfg: OpenClawConfig,
engineName: "Docker" | "Podman",
): Promise<void> {
const probe = await probeCodexBwrapNamespaces(cfg);
if (probe.ok) {
return;
@@ -156,10 +168,10 @@ async function noteCodexBwrapNamespaceWarning(cfg: OpenClawConfig): Promise<void
? " bwrap: setting up uid map: Permission denied"
: " bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted";
const networkSentence = codexBwrapNeedsNetworkNamespaceProbe(cfg)
? "With Docker sandbox network egress disabled, it also needs an unprivileged network namespace."
: "Docker sandbox network egress is enabled, so doctor only checked the user namespace.";
? `With ${engineName} sandbox network egress disabled, it also needs an unprivileged network namespace.`
: `${engineName} sandbox network egress is enabled, so doctor only checked the user namespace.`;
const lines = [
`Codex bwrap ${probe.kind} namespace probe failed while Docker sandbox mode is enabled.`,
`Codex bwrap ${probe.kind} namespace probe failed while ${engineName} sandbox mode is enabled.`,
`Codex app-server \`workspace-write\` shell execution needs unprivileged user namespaces. ${networkSentence}`,
"On Ubuntu/AppArmor hosts this usually appears as:",
symptom,
@@ -174,19 +186,23 @@ async function noteCodexBwrapNamespaceWarning(cfg: OpenClawConfig): Promise<void
note(lines.join("\n"), "Sandbox");
}
async function dockerImageExists(image: string): Promise<boolean> {
async function containerImageExists(command: "docker" | "podman", image: string): Promise<boolean> {
try {
await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 });
await runExec(command, ["image", "inspect", image], { timeoutMs: 5_000 });
return true;
} catch (error) {
const stderr =
(error as { stderr: string } | undefined)?.stderr ||
(error as { message: string } | undefined)?.message ||
"";
if (stderr.includes("No such image")) {
const imageMissing =
command === "docker"
? stderr.includes("No such image")
: /No such image|image not known|image .* not found/iu.test(stderr);
if (imageMissing) {
return false;
}
if (isDockerDaemonUnavailable(stderr)) {
if (command === "docker" && isDockerDaemonUnavailable(stderr)) {
return false;
}
throw error;
@@ -200,7 +216,7 @@ function resolveSandboxDockerImage(cfg: OpenClawConfig): string {
function resolveSandboxBackend(cfg: OpenClawConfig): string {
const backend = cfg.agents?.defaults?.sandbox?.backend?.trim();
return backend || "docker";
return (backend || "docker").toLowerCase();
}
function resolveSandboxBrowserImage(cfg: OpenClawConfig): string {
@@ -247,6 +263,7 @@ function updateSandboxBrowserImage(cfg: OpenClawConfig, image: string): OpenClaw
}
type SandboxImageCheck = {
engineCommand: "docker" | "podman";
kind: string;
image: string;
buildScript?: string;
@@ -258,7 +275,7 @@ async function handleMissingSandboxImage(
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
const exists = await dockerImageExists(params.image);
const exists = await containerImageExists(params.engineCommand, params.image);
if (exists) {
return;
}
@@ -282,7 +299,7 @@ async function handleMissingSandboxImage(
/**
* Checks configured sandbox images and optionally runs repo build scripts for missing defaults.
*
* Non-Docker backends skip Docker image checks; Docker mode also probes Codex bwrap namespace
* Non-container backends skip image checks; local container mode also probes Codex bwrap namespace
* support because nested app-server shells rely on host user/network namespace policy.
*/
export async function maybeRepairSandboxImages(
@@ -296,7 +313,7 @@ export async function maybeRepairSandboxImages(
return cfg;
}
const backend = resolveSandboxBackend(cfg);
if (backend !== "docker") {
if (backend !== "docker" && backend !== "podman") {
if (sandbox.browser?.enabled) {
note(
`Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`,
@@ -305,22 +322,35 @@ export async function maybeRepairSandboxImages(
}
return cfg;
}
const containerEngine = backend === "podman" ? PODMAN_SANDBOX_ENGINE : DOCKER_SANDBOX_ENGINE;
const dockerAvailable = await isDockerAvailable();
if (!dockerAvailable) {
const lines = [
`Sandbox mode is enabled (mode: "${mode}") but Docker is not available.`,
"Docker is required for sandbox mode to function.",
"Isolated sessions (automations, sub-agents) will fail without Docker.",
"",
"Options:",
"- Install Docker and restart the gateway",
"- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off",
];
const engineAvailable = await isContainerEngineAvailable(containerEngine.command);
if (!engineAvailable) {
const lines =
containerEngine.id === "docker"
? [
`Sandbox mode is enabled (mode: "${mode}") but Docker is not available.`,
"Docker is required for sandbox mode to function.",
"Isolated sessions (automations, sub-agents) will fail without Docker.",
"",
"Options:",
"- Install Docker and restart the gateway",
"- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off",
]
: [
`Sandbox mode is enabled (mode: "${mode}") but Podman is not available.`,
"Podman is required by the selected sandbox backend.",
"Isolated sessions (automations, sub-agents) will fail without Podman.",
"",
"Options:",
"- Install Podman and restart the gateway",
"- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off",
];
note(lines.join("\n"), "Sandbox");
return cfg;
}
await noteCodexBwrapNamespaceWarning(cfg);
await validateSandboxContainerEngineTarget(containerEngine);
await noteCodexBwrapNamespaceWarning(cfg, containerEngine.displayName);
let next = cfg;
const changes: string[] = [];
@@ -328,14 +358,17 @@ export async function maybeRepairSandboxImages(
const dockerImage = resolveSandboxDockerImage(cfg);
await handleMissingSandboxImage(
{
engineCommand: containerEngine.command,
kind: "base",
image: dockerImage,
buildScript:
dockerImage === DEFAULT_SANDBOX_COMMON_IMAGE
? "scripts/sandbox-common-setup.sh"
: dockerImage === DEFAULT_SANDBOX_IMAGE
? "scripts/sandbox-setup.sh"
: undefined,
containerEngine.id !== "docker"
? undefined
: dockerImage === DEFAULT_SANDBOX_COMMON_IMAGE
? "scripts/sandbox-common-setup.sh"
: dockerImage === DEFAULT_SANDBOX_IMAGE
? "scripts/sandbox-setup.sh"
: undefined,
updateConfig: (image) => {
next = updateSandboxDockerImage(next, image);
changes.push(`Updated agents.defaults.sandbox.docker.image → ${image}`);
@@ -345,9 +378,10 @@ export async function maybeRepairSandboxImages(
prompter,
);
if (sandbox.browser?.enabled) {
if (sandbox.browser?.enabled && containerEngine.id === "docker") {
await handleMissingSandboxImage(
{
engineCommand: containerEngine.command,
kind: "browser",
image: resolveSandboxBrowserImage(cfg),
buildScript: "scripts/sandbox-browser-setup.sh",
@@ -359,6 +393,11 @@ export async function maybeRepairSandboxImages(
runtime,
prompter,
);
} else if (sandbox.browser?.enabled) {
note(
"Podman sandbox selected. Browser sandbox health checks are skipped because browser sandboxing requires the Docker engine.",
"Sandbox",
);
}
if (changes.length > 0) {
@@ -9,6 +9,7 @@ const runExec = vi.fn();
const note = vi.fn();
const inspectLegacySandboxRegistryFiles = vi.fn();
const migrateLegacySandboxRegistryFiles = vi.fn();
const validateSandboxContainerEngineTarget = vi.fn();
vi.mock("../process/exec.js", () => ({
runExec,
@@ -22,6 +23,20 @@ vi.mock("../agents/sandbox.js", () => ({
resolveSandboxScope: vi.fn(() => "shared"),
}));
vi.mock("../agents/sandbox/docker.js", () => ({
DOCKER_SANDBOX_ENGINE: {
id: "docker",
command: "docker",
displayName: "Docker",
},
PODMAN_SANDBOX_ENGINE: {
id: "podman",
command: "podman",
displayName: "Podman",
},
validateSandboxContainerEngineTarget,
}));
vi.mock("../agents/sandbox/registry.js", () => ({
inspectLegacySandboxRegistryFiles,
migrateLegacySandboxRegistryFiles,
@@ -58,6 +73,7 @@ describe("maybeRepairSandboxImages", () => {
beforeEach(() => {
vi.clearAllMocks();
validateSandboxContainerEngineTarget.mockResolvedValue(undefined);
inspectLegacySandboxRegistryFiles.mockResolvedValue([]);
migrateLegacySandboxRegistryFiles.mockResolvedValue([]);
});
@@ -112,15 +128,19 @@ describe("maybeRepairSandboxImages", () => {
it("warns when sandbox mode is enabled but Docker is not available", async () => {
await runSandboxRepair({ mode: "non-main", dockerAvailable: false });
// The warning should clearly indicate sandbox is enabled but won't work
expect(note).toHaveBeenCalled();
const noteCall = firstNoteCall();
const message = noteCall[0] as string;
// The message should warn that sandbox mode won't function, not just "skipping checks"
expect(message).toMatch(/sandbox.*mode.*enabled|sandbox.*won.*work|docker.*required/i);
// Should NOT just say "skipping sandbox image checks" - that's too mild
expect(message).not.toBe("Docker not available; skipping sandbox image checks.");
expect(noteCall).toEqual([
[
'Sandbox mode is enabled (mode: "non-main") but Docker is not available.',
"Docker is required for sandbox mode to function.",
"Isolated sessions (automations, sub-agents) will fail without Docker.",
"",
"Options:",
"- Install Docker and restart the gateway",
"- Disable sandbox mode: openclaw config set agents.defaults.sandbox.mode off",
].join("\n"),
"Sandbox",
]);
});
it("warns when sandbox mode is 'all' but Docker is not available", async () => {
@@ -152,6 +172,28 @@ describe("maybeRepairSandboxImages", () => {
expect(dockerUnavailableWarning).toBeUndefined();
});
it("validates the explicit Podman target before checking images", async () => {
const cfg = createSandboxConfig("all");
cfg.agents!.defaults!.sandbox!.backend = "podman";
runExec.mockResolvedValue({ stdout: "", stderr: "" });
validateSandboxContainerEngineTarget.mockRejectedValue(
Object.assign(new Error("unsupported remote Podman connection"), {
code: "INVALID_CONFIG",
}),
);
await expect(maybeRepairSandboxImages(cfg, mockRuntime, mockPrompter)).rejects.toThrow(
"unsupported remote Podman connection",
);
expect(runExec).toHaveBeenCalledWith("podman", ["info"], { timeoutMs: 5_000 });
expect(validateSandboxContainerEngineTarget).toHaveBeenCalledWith({
id: "podman",
command: "podman",
displayName: "Podman",
});
});
it("warns when Codex bwrap namespaces are blocked on a sandboxed Linux host", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
runExec.mockImplementation(async (command: string, args: string[]) => {
-12
View File
@@ -30,13 +30,10 @@ export type { OpenClawConfig } from "../config/config.js";
export {
buildExecRemoteCommand,
buildDockerExecArgs,
buildRemoteWorkdirValidationCommand,
buildRemoteCommand,
buildSshSandboxArgv,
buildValidatedExecRemoteCommand,
buildSandboxCreateArgs,
computeSandboxConfigHash,
createRemoteShellSandboxFsBridge,
createWritableRenameTargetResolver,
createSshSandboxSessionFromConfigText,
@@ -48,21 +45,12 @@ export {
isToolAllowed,
registerSandboxBackend,
requireSandboxBackendFactory,
resolveDockerEnvPolicyEpoch,
resolveReadOnlyWorkspaceSkillMounts,
readSandboxRegistryEntry,
resolveSandboxConfigForAgent,
resolveSandboxRuntimeStatus,
appendReadOnlyWorkspaceSkillMountArgs,
appendWorkspaceMountArgs,
formatReadOnlyWorkspaceSkillMountHashState,
resolveWritableRenameTargets,
resolveWritableRenameTargetsForBridge,
runSshSandboxCommand,
SANDBOX_MOUNT_FORMAT_VERSION,
sanitizeEnvVars,
shellEscape,
slugifySessionKey,
uploadDirectoryToSshTarget,
} from "../agents/sandbox.js";
@@ -55,8 +55,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"ollama",
"opencode",
"openshell",
"phone-control",
"podman",
"policy",
"reef",
"talk-voice",