diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 20774579258d..a02d6290ff02 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -300,11 +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. 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`. @@ -396,16 +401,16 @@ node command should update `policy.jsonc` after review instead of relying on #### Sandbox posture -| Policy field | Observed state | Use when | -| ----------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- | -| `sandbox.requireMode` | `agents.defaults.sandbox.mode` and per-agent mode | Allow only reviewed sandbox modes such as `all` or `non-main`. | -| `sandbox.allowBackends` | `agents.defaults.sandbox.backend` and per-agent backend | Allow only reviewed sandbox backends such as `docker`. | -| `sandbox.containers.denyHostNetwork` | Container-backed sandbox/browser network mode | Deny host network mode. | -| `sandbox.containers.denyContainerNamespaceJoin` | Container-backed sandbox/browser network mode | Deny joining another container network namespace. | -| `sandbox.containers.requireReadOnlyMounts` | Container-backed sandbox/browser mount mode | Require mounts to be read-only. | -| `sandbox.containers.denyContainerRuntimeSocketMounts` | Container-backed sandbox/browser mount targets | Deny container runtime socket mounts. | -| `sandbox.containers.denyUnconfinedProfiles` | Container security profile posture | Deny unconfined container security profiles. | -| `sandbox.browser.requireCdpSourceRange` | Sandbox browser CDP source range | Require browser CDP exposure to declare a source range. | +| Policy field | Observed state | Use when | +| ----------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------ | +| `sandbox.requireMode` | `agents.defaults.sandbox.mode` and per-agent mode | Allow only reviewed sandbox modes such as `all` or `non-main`. | +| `sandbox.allowBackends` | `agents.defaults.sandbox.backend` and per-agent backend | Allow only reviewed sandbox backends such as `docker` or `podman`. | +| `sandbox.containers.denyHostNetwork` | Container-backed sandbox/browser network mode | Deny host network mode. | +| `sandbox.containers.denyContainerNamespaceJoin` | Container-backed sandbox/browser network mode | Deny joining another container network namespace. | +| `sandbox.containers.requireReadOnlyMounts` | Container-backed sandbox/browser mount mode | Require mounts to be read-only. | +| `sandbox.containers.denyContainerRuntimeSocketMounts` | Container-backed sandbox/browser mount targets | Deny container runtime socket mounts. | +| `sandbox.containers.denyUnconfinedProfiles` | Container security profile posture | Deny unconfined container security profiles. | +| `sandbox.browser.requireCdpSourceRange` | Sandbox browser CDP source range | Require browser CDP exposure to declare a source range. | Policy treats missing `sandbox.mode` as its implicit default `off`, so `sandbox.requireMode` reports a fresh or unconfigured sandbox as outside an diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 302f80c1aa0e..551db6599e41 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -5,7 +5,7 @@ read_when: "You are managing sandbox runtimes or debugging sandbox/tool-policy b status: active --- -Manage sandbox runtimes for isolated agent execution: Docker containers, SSH targets, or OpenShell backends. +Manage sandbox runtimes for isolated agent execution: Docker/Podman containers, SSH targets, or OpenShell backends. [`openclaw agent exec`](/cli/agent#agent-exec) does not use these configured runtimes. Its isolated implicit policy config turns the agent sandbox off, allows full Gateway-host execution, and restricts filesystem tools to `--cwd`. @@ -72,7 +72,7 @@ Prefer `openclaw sandbox recreate` over manual backend-specific cleanup. It uses | Change | Command | | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| Docker image update (`agents.defaults.sandbox.docker.image`) | `openclaw sandbox recreate --all` | +| Container sandbox image update (`agents.defaults.sandbox.docker.image`) | `openclaw sandbox recreate --all` | | Sandbox config (`agents.defaults.sandbox.*`) | `openclaw sandbox recreate --all` | | SSH target/auth (`agents.defaults.sandbox.ssh.{target,workspaceRoot,identityFile,certificateFile,knownHostsFile,identityData,certificateData,knownHostsData}`) | `openclaw sandbox recreate --all` | | OpenShell source/policy/mode (`plugins.entries.openshell.config.{from,mode,policy}`) | `openclaw sandbox recreate --all` | diff --git a/docs/docs_map.md b/docs/docs_map.md index ad68306096cd..bc65f7d97555 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -4044,6 +4044,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Supported capability matrix - H2: Docker backend - H3: Sandboxed browser + - H2: Podman backend - H2: SSH backend - H2: OpenShell backend - H2: Workspace access @@ -4859,6 +4860,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Prerequisites - H2: Quick start + - H2: Agent sandbox backend - H2: Podman and Tailscale - H2: Systemd (Quadlet, optional) - H2: Config, env, and storage diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 34a465b16d6e..c56858c4f77f 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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) | ssh | openshell + 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 -Browser sandboxing and `sandbox.docker.binds` are Docker-only. +Browser sandboxing requires the Docker engine. `sandbox.docker.binds` applies to both the Docker and Podman backends. Build images (from a source checkout): diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 7fc25c81bab5..9ed9fa133f68 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -26,11 +26,11 @@ Not sandboxed: Three independent settings control sandbox behavior: -| Setting | Key | Values | Default | -| ------- | --------------------------------- | ---------------------------- | -------- | -| Mode | `agents.defaults.sandbox.mode` | `off`, `non-main`, `all` | `off` | -| Scope | `agents.defaults.sandbox.scope` | `agent`, `session`, `shared` | `agent` | -| Backend | `agents.defaults.sandbox.backend` | `docker`, `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 | SSH | OpenShell | -| ------------------- | -------------------------------- | ------------------------------ | --------------------------------------------------- | -| **Where it runs** | Local container | Any SSH-accessible host | OpenShell managed sandbox | -| **Setup** | `scripts/sandbox-setup.sh` | 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** | Supported | Not supported | Not supported yet | -| **Bind mounts** | `docker.binds` | N/A | N/A | -| **Best for** | Local dev, full isolation | 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. Podman requires version 5.0 or newer for this option. **Docker-out-of-Docker (DooD) constraints** @@ -143,6 +143,58 @@ On Ubuntu/AppArmor hosts with Docker sandbox mode enabled, Codex app-server `wor - `agents.defaults.sandbox.browser.allowHostControl` (default `false`) lets sandboxed sessions target the host browser explicitly. - Optional allowlists gate `target: "custom"`: `allowedControlUrls`, `allowedControlHosts`, `allowedControlPorts`. +## Podman backend + +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. + +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 a nonzero numeric UID or UID:GID to control the container user. Rootless Podman rejects UID or GID 0 because Podman 4.x cannot remap namespace root while preserving workspace bind ownership; bake root-required setup into the image or use rootful Podman. Rootful Podman otherwise uses the workspace owner when available. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "podman", + scope: "session", + workspaceAccess: "rw", + docker: { + image: "openclaw-sandbox:bookworm-slim", + network: "none", + readOnlyRoot: true, + capDrop: ["ALL"], + }, + }, + }, + }, +} +``` + +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 notes: + +- 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. + + +**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. + + + ## SSH backend Use `backend: "ssh"` to sandbox `exec`, file tools, and media reads on an arbitrary SSH-accessible machine. @@ -407,7 +459,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker -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`. Package installation and certificate-store changes are image provisioning, not @@ -471,9 +523,12 @@ Paths: - Default `docker.network` is `"none"` (no egress), so package installs will fail. - `docker.network: "container:"` 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"`; rootful Podman must set `user: "0:0"` because its default + preserves workspace ownership. Rootless Podman rejects zero-valued users; + bake packages into the image or use rootful Podman. - 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. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 4427d4fd9351..e94ac8f63bdc 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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) 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. diff --git a/docs/help/faq.md b/docs/help/faq.md index 83375ba02671..a0561dec8a6e 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -394,7 +394,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - - 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). diff --git a/docs/install/docker.md b/docs/install/docker.md index a36092510f4e..5aa643dae39f 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -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. 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. diff --git a/docs/install/podman.md b/docs/install/podman.md index 65b0a422ea52..387f43842d99 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -90,6 +90,14 @@ The model: The manual launcher reads only a small allowlist of Podman-related keys from `~/.openclaw/.env` and passes explicit runtime env vars to the container; it does not hand the full env file to Podman. +## Agent sandbox backend + +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. + +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. + ## Podman and Tailscale @@ -203,5 +211,6 @@ mounted state. ## Related - [Docker](/install/docker) +- [Sandboxing](/gateway/sandboxing#podman-backend) - [Gateway background process](/gateway/background-process) - [Gateway troubleshooting](/gateway/troubleshooting) diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index 39ca3d85f052..3eff2c9a1a29 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -79,7 +79,7 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store in `~/.ope **Result:** - `main` agent: runs on host, full tool access. - - `family` agent: runs in Docker (one container per agent), only `read` and current-conversation message sends. + - `family` agent: runs in the configured container sandbox backend (one container per agent), only `read` and current-conversation message sends. @@ -189,7 +189,7 @@ agents.entries.*.sandbox.prune.* > agents.defaults.sandbox.prune.* ``` -`agents.entries.*.sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). +`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. ### Tool restrictions diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index f59a02678564..86e757075fd0 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -15,6 +15,7 @@ import { buildDynamicTools, disableCodexPluginThreadConfig, resolveCodexAppServerExecutionCwd, + resolveCodexExternalSandboxPolicyForOpenClawSandbox, resolveCodexMessageToolProvider, shouldEnableCodexAppServerNativeToolSurface, } from "./dynamic-tool-build.js"; @@ -757,6 +758,24 @@ describe("Codex app-server dynamic tool build", () => { expect(persistentWebSearchAllowed).toBe(false); }); + it("maps Podman sandbox network config into Codex external sandbox policy", () => { + expect( + resolveCodexExternalSandboxPolicyForOpenClawSandbox({ + enabled: true, + backendId: "podman", + docker: { network: "none" }, + } as never), + ).toEqual({ type: "externalSandbox", networkAccess: "restricted" }); + + expect( + resolveCodexExternalSandboxPolicyForOpenClawSandbox({ + enabled: true, + backendId: "Podman", + docker: { network: "bridge" }, + } as never), + ).toEqual({ type: "externalSandbox", networkAccess: "enabled" }); + }); + it("exposes OpenClaw sandbox shell tools under distinct names for non-Docker sandbox backends", async () => { setOpenClawCodingToolsFactoryForTests(() => [ createRuntimeDynamicTool("read"), diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index c47a5d4e10d1..6bab8d9860d6 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -643,10 +643,16 @@ export function resolveCodexExternalSandboxPolicyForOpenClawSandbox( networkAccess: codexNetworkAccessForOpenClawSandbox(sandbox) ? "enabled" : "restricted", }; } + +function usesDockerNetworkConfig(sandbox: OpenClawSandboxContext | undefined): boolean { + const backendId = sandbox?.backendId.trim().toLowerCase(); + return backendId === "docker" || backendId === "podman"; +} + function codexNetworkAccessForOpenClawSandbox( sandbox: OpenClawSandboxContext | undefined, ): boolean { - if (sandbox?.backendId !== "docker") { + if (!usesDockerNetworkConfig(sandbox)) { return true; } const network = sandbox?.docker?.network?.trim().toLowerCase(); diff --git a/extensions/mxc/test/mxc-backend.test.ts b/extensions/mxc/test/mxc-backend.test.ts index 3c7f63898b88..232ce20b21da 100644 --- a/extensions/mxc/test/mxc-backend.test.ts +++ b/extensions/mxc/test/mxc-backend.test.ts @@ -128,6 +128,7 @@ function createSandboxBackendTestConfig( scope: "session", workspaceAccess: "rw", workspaceRoot: "/workspace-root", + dockerTmpfsSource: "configured", docker: { binds: [], capDrop: [], diff --git a/extensions/openshell/src/backend.e2e.test.ts b/extensions/openshell/src/backend.e2e.test.ts index 16c3f22d0e9d..88e74570fd78 100644 --- a/extensions/openshell/src/backend.e2e.test.ts +++ b/extensions/openshell/src/backend.e2e.test.ts @@ -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-", diff --git a/extensions/openshell/src/backend.exec-workdir.test.ts b/extensions/openshell/src/backend.exec-workdir.test.ts index 6c2065a49c61..51a0d80cd96e 100644 --- a/extensions/openshell/src/backend.exec-workdir.test.ts +++ b/extensions/openshell/src/backend.exec-workdir.test.ts @@ -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-", diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index 970e2a07c81e..c8b93caa8af2 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -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-", diff --git a/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts b/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts index f346ddd430c5..af85f00707ab 100644 --- a/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts +++ b/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts @@ -342,6 +342,58 @@ describe("registerPolicyDoctorChecks", () => { ); }); + it("evaluates Podman container posture without reporting it as unobservable", async () => { + const configPath = join(workspaceDir, "openclaw.jsonc"); + const cfg = { + ...cfgWithPolicy(), + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "podman", + docker: { + network: "host", + binds: ["/run/podman/podman.sock:/run/podman/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)); + + 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("uses explicit agent sandbox scope before inherited legacy perSession", async () => { const cfg = { agents: { diff --git a/extensions/policy/src/doctor/sandbox-findings.ts b/extensions/policy/src/doctor/sandbox-findings.ts index 37db804f563d..259991e9ffd5 100644 --- a/extensions/policy/src/doctor/sandbox-findings.ts +++ b/extensions/policy/src/doctor/sandbox-findings.ts @@ -165,7 +165,7 @@ function scopedSandboxDefaultDisabledForAgent( candidate.kind === "backend" && scopedAgentIdMatches(candidate.agentId, policyAgentId), ); - if (typeof backend?.value === "string" && backend.value.toLowerCase() !== "docker") { + if (typeof backend?.value === "string" && !isObservableContainerSandboxBackend(backend.value)) { return true; } } @@ -262,6 +262,10 @@ function sandboxBackendFindings( ); } +function isObservableContainerSandboxBackend(value: string): boolean { + return value.toLowerCase() === "docker" || value.toLowerCase() === "podman"; +} + function sandboxContainerPostureUnobservableFindings( sandboxPolicy: Record, policyDocName: string, @@ -277,7 +281,10 @@ function sandboxContainerPostureUnobservableFindings( } return sandboxPostureEntries(evidence, "backend") .filter(evidenceFilter) - .filter((entry) => typeof entry.value === "string" && entry.value.toLowerCase() !== "docker") + .filter( + (entry) => + typeof entry.value === "string" && !isObservableContainerSandboxBackend(entry.value), + ) .flatMap((entry) => enabledRules.map((rule) => sandboxPostureFinding(entry, { diff --git a/extensions/policy/src/policy-state-sandbox.ts b/extensions/policy/src/policy-state-sandbox.ts index 66e86fa3b8ad..7717d8734784 100644 --- a/extensions/policy/src/policy-state-sandbox.ts +++ b/extensions/policy/src/policy-state-sandbox.ts @@ -87,7 +87,7 @@ function pushSandboxPostureEvidence( inherited: localBackend === undefined && inheritedBackend !== undefined, }); - if (effectiveBackend === "docker") { + if (effectiveBackend === "docker" || effectiveBackend === "podman") { pushSandboxDockerPosture(entries, effectiveParams); } pushSandboxBrowserPosture(entries, effectiveParams); @@ -241,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) diff --git a/extensions/policy/src/policy-state.test.ts b/extensions/policy/src/policy-state.test.ts index 2f157c0f5028..80556aaf09d4 100644 --- a/extensions/policy/src/policy-state.test.ts +++ b/extensions/policy/src/policy-state.test.ts @@ -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) => 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( diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index d2fa3c98a539..1a595f770617 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -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("./sandbox/docker.js"); + return { + ...actual, + resolvePodmanSandboxRuntimeInfo: containerEngineMocks.resolvePodmanSandboxRuntimeInfo, + }; +}); + vi.mock("./exec-defaults.js", () => ({ resolveNodeExecEligibility: resolveNodeExecEligibilityMock, })); @@ -540,6 +551,123 @@ 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"); + const workspaceStat = await fs.stat("/tmp/openclaw-test"); + const expectedUser = + workspaceStat.uid === 0 || workspaceStat.gid === 0 + ? undefined + : `${workspaceStat.uid}:${workspaceStat.gid}`; + expect(backendFactory).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + backend: "podman", + docker: expect.objectContaining({ + user: expectedUser, + }), + }), + }), + ); + } 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 () => ({ diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts index ab513be8c2aa..feb9810e0495 100644 --- a/src/agents/sandbox/backend.test.ts +++ b/src/agents/sandbox/backend.test.ts @@ -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. diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index 9636167d4aeb..e22a9d1a4b73 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -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 { const globalStore = globalThis as typeof globalThis & { [SANDBOX_BACKEND_FACTORIES_STATE_KEY]?: Map; @@ -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, diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 578e8c81a6a2..8c10658d8c29 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -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-", diff --git a/src/agents/sandbox/config.test.ts b/src/agents/sandbox/config.test.ts index 7d552315997d..ceb4288aa7b6 100644 --- a/src/agents/sandbox/config.test.ts +++ b/src/agents/sandbox/config.test.ts @@ -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. diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index 3387f0dedfd0..d2426be925b2 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -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, diff --git a/src/agents/sandbox/container-engine.ts b/src/agents/sandbox/container-engine.ts new file mode 100644 index 000000000000..6e7f4cc9d0e3 --- /dev/null +++ b/src/agents/sandbox/container-engine.ts @@ -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 { + 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.displayName} command failed (exit ${exitCode})`), + { 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, + }; +} diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 9c87be08f7df..d772cec33aad 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -225,6 +225,7 @@ async function resolveProvisionedSandboxContext( }); const docker = await resolveSandboxDockerUser({ + backend: cfg.backend, docker: cfg.docker, workspaceDir, }); @@ -282,9 +283,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 diff --git a/src/agents/sandbox/context.user-fallback.test.ts b/src/agents/sandbox/context.user-fallback.test.ts index 324d3266ade4..4fc2566506f0 100644 --- a/src/agents/sandbox/context.user-fallback.test.ts +++ b/src/agents/sandbox/context.user-fallback.test.ts @@ -17,6 +17,7 @@ const baseDocker: SandboxDockerConfig = { describe("resolveSandboxDockerUser", () => { it("keeps configured docker.user", async () => { const resolved = await resolveSandboxDockerUser({ + backend: "docker", docker: { ...baseDocker, user: "2000:2000" }, workspaceDir: "/tmp/unused", stat: async () => ({ uid: 1000, gid: 1000 }), @@ -26,6 +27,7 @@ describe("resolveSandboxDockerUser", () => { it("falls back to workspace ownership when docker.user is unset", async () => { const resolved = await resolveSandboxDockerUser({ + backend: "docker", docker: baseDocker, workspaceDir: "/tmp/workspace", stat: async () => ({ uid: 1001, gid: 1002 }), @@ -33,8 +35,51 @@ describe("resolveSandboxDockerUser", () => { expect(resolved.user).toBe("1001:1002"); }); + it("falls back to workspace ownership for mixed-case Docker backend ids", async () => { + const resolved = await resolveSandboxDockerUser({ + backend: "Docker", + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 1001, gid: 1002 }), + }); + expect(resolved.user).toBe("1001:1002"); + }); + + it("falls back to workspace ownership for rootless Podman", async () => { + const resolved = await resolveSandboxDockerUser({ + backend: "podman", + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 1001, gid: 1002 }), + }); + expect(resolved.user).toBe("1001:1002"); + }); + + it("applies workspace ownership fallback for rootful Podman", async () => { + const resolved = await resolveSandboxDockerUser({ + backend: "podman", + docker: baseDocker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 1001, gid: 1002 }), + }); + expect(resolved.user).toBe("1001:1002"); + }); + + it("leaves Podman user unset when host ownership IDs are zero", async () => { + const docker = { ...baseDocker }; + const resolved = await resolveSandboxDockerUser({ + backend: "podman", + docker, + workspaceDir: "/tmp/workspace", + stat: async () => ({ uid: 0, gid: 0 }), + }); + + expect(resolved).toBe(docker); + }); + it("leaves docker.user unset when workspace stat fails", async () => { const resolved = await resolveSandboxDockerUser({ + backend: "docker", docker: baseDocker, workspaceDir: "/tmp/workspace", stat: async () => { diff --git a/src/agents/sandbox/docker-backend.test.ts b/src/agents/sandbox/docker-backend.test.ts index 35f7e3c067f0..481fc8cb0ef8 100644 --- a/src/agents/sandbox/docker-backend.test.ts +++ b/src/agents/sandbox/docker-backend.test.ts @@ -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("./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,150 @@ 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 a stale Podman registry target before inspecting the runtime", async () => { + const targetError = new Error("active Podman connection changed"); + dockerMocks.validateSandboxContainerEngineTarget.mockRejectedValueOnce(targetError); + + await expect( + podmanSandboxBackendManager.describeRuntime({ + entry: { + containerName: "sandbox-podman", + backendId: "podman", + backendTarget: { + key: `machine:${"a".repeat(32)}`, + globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/podman/podman.sock"], + }, + runtimeLabel: "sandbox-podman", + sessionKey: "agent:coder:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:bookworm-slim", + }, + config: createConfig(), + }), + ).rejects.toBe(targetError); + + expect(dockerMocks.containerState).not.toHaveBeenCalled(); + expect(dockerMocks.execContainer).not.toHaveBeenCalled(); + }); + + it("rejects a stale Podman registry target before removing the runtime", async () => { + const targetError = new Error("active Podman connection changed"); + dockerMocks.validateSandboxContainerEngineTarget.mockRejectedValueOnce(targetError); + + await expect( + podmanSandboxBackendManager.removeRuntime({ + entry: { + containerName: "sandbox-podman", + backendId: "podman", + backendTarget: { + key: `machine:${"a".repeat(32)}`, + globalArgs: ["--url", "ssh://core@127.0.0.1:60001/run/podman/podman.sock"], + }, + runtimeLabel: "sandbox-podman", + sessionKey: "agent:coder:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:bookworm-slim", + }, + config: createConfig(), + }), + ).rejects.toBe(targetError); + + expect(dockerMocks.execContainer).not.toHaveBeenCalled(); + }); + + 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 }, + ); + }); }); diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts index 797b7c95df90..37d816069085 100644 --- a/src/agents/sandbox/docker-backend.ts +++ b/src/agents/sandbox/docker-backend.ts @@ -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 { + 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 { + return await createContainerSandboxBackend(DOCKER_SANDBOX_ENGINE, params); +} + +export async function createPodmanSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + return await createContainerSandboxBackend(PODMAN_SANDBOX_ENGINE, params); +} + +function createContainerSandboxBackendHandle(params: { + engine: SandboxContainerEngine; containerName: string; workdir: string; env?: Record; 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); diff --git a/src/agents/sandbox/docker-user.ts b/src/agents/sandbox/docker-user.ts index 62250e036b8d..63611d29fa2a 100644 --- a/src/agents/sandbox/docker-user.ts +++ b/src/agents/sandbox/docker-user.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import type { SandboxDockerConfig } from "./types.js"; export async function resolveSandboxDockerUser(params: { + backend: string; docker: SandboxDockerConfig; workspaceDir: string; stat?: (workspaceDir: string) => Promise<{ uid: number; gid: number }>; @@ -10,6 +11,10 @@ export async function resolveSandboxDockerUser(params: { if (configuredUser) { return params.docker; } + const backend = params.backend.trim().toLowerCase(); + if (backend !== "docker" && backend !== "podman") { + return params.docker; + } const stat = params.stat ?? ((workspaceDir: string) => fs.stat(workspaceDir)); try { const workspaceStat = await stat(params.workspaceDir); @@ -18,6 +23,9 @@ export async function resolveSandboxDockerUser(params: { if (uid === null || gid === null || uid < 0 || gid < 0) { return params.docker; } + if (backend === "podman" && (uid === 0 || gid === 0)) { + return params.docker; + } return { ...params.docker, user: `${uid}:${gid}` }; } catch { return params.docker; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b68836b79118..592d964345bf 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -16,17 +16,23 @@ import { SANDBOX_MOUNT_FORMAT_VERSION } from "./workspace-mounts.js"; type SpawnCall = { command: string; args: string[]; + globalArgs: string[]; }; const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], containerExists: true, inspectRunning: true, + inspectError: "", labelHash: "", + podmanInfo: "true\tfalse\t\t5.0.0\n", + podmanConnections: "[]\n", + podmanMachines: "[]\n", })); const registryMocks = vi.hoisted(() => ({ readRegistryEntry: vi.fn(), + removeRegistryEntry: vi.fn(), updateRegistry: vi.fn(), })); @@ -42,8 +48,30 @@ function makeTempDir(): string { return dir; } +function usePodmanMachine() { + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock", + Identity: "/tmp/podman-machine-default", + Default: true, + }, + ]); + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-default", + Running: true, + IdentityPath: "/tmp/podman-machine-default", + Port: 60000, + RemoteUsername: "core", + }, + ]); +} + vi.mock("./registry.js", () => ({ readRegistryEntry: registryMocks.readRegistryEntry, + removeRegistryEntry: registryMocks.removeRegistryEntry, updateRegistry: registryMocks.updateRegistry, })); @@ -52,19 +80,30 @@ 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}}") { - if (!spawnState.containerExists) { + if (spawnState.inspectError) { + code = 125; + stderr = spawnState.inspectError; + } else if (!spawnState.containerExists) { code = 1; stderr = "No such object"; } else { @@ -81,6 +120,12 @@ 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 (command === "podman" && args[0] === "machine") { + stdout = spawnState.podmanMachines; } else if (args[0] === "rm" && args[1] === "-f") { spawnState.containerExists = false; spawnState.inspectRunning = false; @@ -122,18 +167,21 @@ 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(); vi.doMock("./registry.js", () => ({ readRegistryEntry: registryMocks.readRegistryEntry, + removeRegistryEntry: registryMocks.removeRegistryEntry, updateRegistry: registryMocks.updateRegistry, })); vi.doMock("../../process/exec.js", async (importOriginal) => ({ ...(await importOriginal()), spawnCommand: spawnDockerProcess, })); - ({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch } = await import("./docker.js")); + ({ ensureSandboxContainer, resolveDockerEnvPolicyEpoch, PODMAN_SANDBOX_ENGINE } = + await import("./docker.js")); } function createSandboxConfig( @@ -148,6 +196,7 @@ function createSandboxConfig( scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", + dockerTmpfsSource: "default", docker: { image: "openclaw-sandbox:test", containerPrefix: "oc-test-", @@ -191,6 +240,7 @@ async function ensureSandboxCreateCallForTest(params: { cfg: SandboxConfig; workspaceDir?: string; scopeKey?: string; + engine?: import("./docker.js").SandboxContainerEngine; }): Promise { const workspaceDir = params.workspaceDir ?? "/tmp/workspace"; await ensureSandboxContainer({ @@ -198,13 +248,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; } @@ -220,8 +271,14 @@ describe("ensureSandboxContainer config-hash recreation", () => { spawnState.calls.length = 0; spawnState.containerExists = true; spawnState.inspectRunning = true; + spawnState.inspectError = ""; spawnState.labelHash = ""; + spawnState.podmanInfo = "true\tfalse\t\t5.0.0\n"; + spawnState.podmanConnections = "[]\n"; + spawnState.podmanMachines = "[]\n"; registryMocks.readRegistryEntry.mockClear(); + registryMocks.removeRegistryEntry.mockClear(); + registryMocks.removeRegistryEntry.mockResolvedValue(undefined); registryMocks.updateRegistry.mockClear(); registryMocks.updateRegistry.mockResolvedValue(undefined); runtimeMocks.log.mockClear(); @@ -600,4 +657,450 @@ describe("ensureSandboxContainer config-hash recreation", () => { `openclaw.mountFormatVersion=${SANDBOX_MOUNT_FORMAT_VERSION}`, ); }); + + it("uses the shared lifecycle with rootless Podman workspace ownership", async () => { + const workspaceDir = "/tmp/workspace"; + const cfg = createSandboxConfig([]); + cfg.docker.user = "1001:1002"; + 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:uid=1001,gid=1002", + ]); + expect(collectDockerFlagValues(createCall.args, "--user")).toEqual(["1001:1002"]); + 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("uses the workspace owner without keep-id for rootful Podman", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.user = "1001:1002"; + spawnState.podmanInfo = "false\tfalse\t\t5.0.0\n"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + const createCall = await ensureSandboxCreateCallForTest({ + cfg, + engine: PODMAN_SANDBOX_ENGINE, + }); + + expect(collectDockerFlagValues(createCall.args, "--user")).toEqual(["1001:1002"]); + expect(collectDockerFlagValues(createCall.args, "--userns")).toEqual([]); + }); + + it.each([ + { user: "0" }, + { user: "00" }, + { user: "0:0" }, + { user: "00:1002" }, + { user: "1001:0" }, + { user: "1001:000" }, + ])("rejects zero-valued rootless Podman user $user", async ({ user }) => { + const cfg = createSandboxConfig([]); + cfg.docker.user = user; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/cannot use UID or GID 0/iu); + + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + args: expect.arrayContaining(["create"]), + }), + ); + }); + + it("rejects Podman versions without mapped keep-id support", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.user = "1001:1002"; + spawnState.podmanInfo = "true\tfalse\t\t4.2.0\n"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/requires Podman 4\.3 or newer/iu); + }); + + it("rejects Podman GPU passthrough before Podman 5", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.gpus = "all"; + spawnState.podmanInfo = "false\tfalse\t\t4.9.3\n"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/GPU passthrough requires Podman 5\.0 or newer/iu); + }); + + it("rejects nonnumeric users for rootless Podman keep-id", async () => { + const cfg = createSandboxConfig([]); + cfg.docker.user = "node"; + spawnState.inspectRunning = false; + registryMocks.readRegistryEntry.mockResolvedValue(null); + + await expect( + ensureSandboxContainer({ + engine: PODMAN_SANDBOX_ENGINE, + scopeKey: "shared", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg, + }), + ).rejects.toThrow(/must be a numeric UID or UID:GID/iu); + + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + args: expect.arrayContaining(["create"]), + }), + ); + }); + + it("rejects a Podman runtime recorded for a different engine target", async () => { + const cfg = createSandboxConfig([]); + usePodmanMachine(); + registryMocks.readRegistryEntry.mockResolvedValue({ + containerName: "oc-test-podman-shared", + backendId: "podman", + backendTarget: { key: "local", globalArgs: [] }, + 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(registryMocks.removeRegistryEntry).not.toHaveBeenCalled(); + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + globalArgs: [], + args: expect.arrayContaining(["inspect"]), + }), + ); + }); + + it("recovers when a Podman target changed after the recorded runtime disappeared", async () => { + const cfg = createSandboxConfig([]); + spawnState.containerExists = false; + spawnState.inspectRunning = false; + spawnState.inspectError = + 'Error: no container with name or ID "oc-test-podman-shared" found: no such container'; + 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, + }), + ).resolves.toBe("oc-test-podman-shared"); + + expect(registryMocks.removeRegistryEntry).toHaveBeenCalledWith("oc-test-podman-shared"); + expect(spawnState.calls).toContainEqual( + expect.objectContaining({ + command: "podman", + globalArgs: [], + args: expect.arrayContaining(["create", "--name", "oc-test-podman-shared"]), + }), + ); + }); + + it("preserves a Podman registry entry when its recorded target is unreachable", async () => { + const cfg = createSandboxConfig([]); + spawnState.inspectError = "Error: unable to connect to Podman socket: connection refused"; + 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(/unable to connect to Podman socket/iu); + + expect(registryMocks.removeRegistryEntry).not.toHaveBeenCalled(); + expect(spawnState.calls).not.toContainEqual( + expect.objectContaining({ + command: "podman", + globalArgs: [], + args: expect.arrayContaining(["create", "--name", "oc-test-podman-shared"]), + }), + ); + }); + + 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-v8: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`]; + usePodmanMachine(); + 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:60000/run/user/501/podman/podman.sock", + "--identity", + "/tmp/podman-machine-default", + ]); + }); + + it("rejects Podman Machine bind sources outside the default home share", async () => { + const cfg = createSandboxConfig([]); + usePodmanMachine(); + 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); + }); }); diff --git a/src/agents/sandbox/docker.test.ts b/src/agents/sandbox/docker.test.ts index a2f7c2801e12..e846845cfe7b 100644 --- a/src/agents/sandbox/docker.test.ts +++ b/src/agents/sandbox/docker.test.ts @@ -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,10 +17,15 @@ const spawnState = vi.hoisted(() => ({ calls: [] as SpawnCall[], imageExists: true, inspectError: "", + infoAvailable: { docker: false, podman: false }, + podmanConnections: "[]\n", + podmanInfo: "true\tfalse\t\t5.0.0\n", + podmanMachines: "[]\n", lastOptions: undefined as SpawnCallOptions | undefined, executionError: undefined as Error | undefined, transportFailure: false, transportExitCode: 0, + plainExitWithoutStderr: false, })); async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallOptions) { @@ -39,12 +45,32 @@ async function spawnDockerProcess(commandAndArgs: string[], options?: SpawnCallO stderr: Buffer.alloc(0), }); } + if (spawnState.plainExitWithoutStderr) { + return { + failed: true, + isCanceled: false, + exitCode: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }; + } 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 (command === "podman" && args[0] === "machine") { + stdout = spawnState.podmanMachines; + } 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 +84,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 +95,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 +107,274 @@ async function loadFreshDockerModuleForTest() { ...(await importOriginal()), 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\t\t5.0.0\n"; + spawnState.podmanMachines = "[]\n"; + await loadFreshDockerModuleForTest(); + }); + + it("rejects an arbitrary remote Podman connection", async () => { + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\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\t\t5.0.0\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default", + URI: "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock", + Identity: "/tmp/podman-machine-default", + Default: true, + }, + ]); + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-default", + Running: true, + IdentityPath: "/tmp/podman-machine-default", + Port: 60000, + RemoteUsername: "core", + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: true, + rootless: true, + version: "5.0.0", + target: { + key: expect.stringMatching(/^machine:[a-f0-9]{32}$/u), + globalArgs: [ + "--url", + "ssh://core@127.0.0.1:60000/run/user/501/podman/podman.sock", + "--identity", + "/tmp/podman-machine-default", + ], + }, + }); + }); + + it("allows rootful Podman Machine connections", async () => { + spawnState.podmanInfo = "false\ttrue\t\t5.0.0\n"; + spawnState.podmanConnections = JSON.stringify([ + { + Name: "podman-machine-default-root", + URI: "ssh://root@127.0.0.1:60000/run/podman/podman.sock", + Identity: "/tmp/podman-machine-default", + Default: true, + }, + ]); + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-default", + Running: true, + IdentityPath: "/tmp/podman-machine-default", + Port: 60000, + RemoteUsername: "core", + }, + ]); + + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toMatchObject({ + machine: true, + rootless: false, + target: { + globalArgs: [ + "--url", + "ssh://root@127.0.0.1:60000/run/podman/podman.sock", + "--identity", + "/tmp/podman-machine-default", + ], + }, + }); + }); + + it("rejects an unknown configured remote connection", async () => { + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\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\t\t5.0.0\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://192.0.2.1:60000/run/user/1000/podman/podman.sock", + }, + async () => { + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-default", + Running: true, + IdentityPath: "/tmp/podman-machine-default", + Port: 60000, + RemoteUsername: "core", + }, + ]); + 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\t\t5.0.0\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\t5.0.0\n"; + + await withEnvAsync({ CONTAINER_CONNECTION: undefined, CONTAINER_HOST: undefined }, async () => { + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: false, + rootless: true, + version: "5.0.0", + 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\t\t5.0.0\n"; + await expect(resolvePodmanSandboxRuntimeInfo()).resolves.toEqual({ + machine: false, + rootless: true, + version: "5.0.0", + target: { key: "local", globalArgs: [] }, + }); + + spawnState.podmanInfo = "true\ttrue\t\t5.0.0\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, + version: "5.0.0", + 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\t\t5.0.0\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", + Default: true, + }, + ]); + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-first", + Running: true, + IdentityPath: "/tmp/first-machine-key", + Port: 60001, + RemoteUsername: "core", + }, + ]); + 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", + Default: true, + }, + ]); + spawnState.podmanMachines = JSON.stringify([ + { + Name: "podman-machine-second", + Running: true, + IdentityPath: "/tmp/second-machine-key", + Port: 60002, + RemoteUsername: "core", + }, + ]); + + await expect( + validateSandboxContainerEngineTarget(podmanSandboxEngine, first.target), + ).rejects.toThrow(/active Podman connection changed/u); + }); +}); + describe("ensureDockerImage", () => { beforeEach(async () => { spawnState.calls.length = 0; @@ -89,6 +384,7 @@ describe("ensureDockerImage", () => { spawnState.executionError = undefined; spawnState.transportFailure = false; spawnState.transportExitCode = 0; + spawnState.plainExitWithoutStderr = false; await loadFreshDockerModuleForTest(); }); @@ -116,8 +412,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 +423,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 +454,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", () => { @@ -186,4 +515,17 @@ describe("execDockerRaw", () => { "docker stream failed", ); }); + + it("does not include raw container arguments when stderr is empty", async () => { + spawnState.plainExitWithoutStderr = true; + const secret = "sandbox-secret-value"; + + const error = await execDockerRaw(["create", "--env", `TOKEN=${secret}`]).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Docker command failed (exit 1)"); + expect((error as Error).message).not.toContain(secret); + }); }); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aae4556eb56e..68ecc94d54c5 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -3,99 +3,64 @@ * * 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 { - 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 { readRegistryEntry, removeRegistryEntry, updateRegistry } from "./registry.js"; import { buildSandboxContainerName, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; import { validateSandboxSecurity } from "./validate-sandbox-security.js"; @@ -115,31 +80,6 @@ const sandboxContainerLifecycleQueue = new KeyedAsyncQueue(); type ExecDockerOptions = ExecDockerRawOptions; -function envRecordsEqual(left: Record, right: Record): 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 | 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 { - const result = await execDocker( + return await readContainerLabel(DOCKER_SANDBOX_ENGINE, containerName, label); +} + +export async function readContainerLabel( + engine: SandboxContainerEngine, + containerName: string, + label: string, +): Promise { + 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) { @@ -264,6 +241,37 @@ export async function dockerContainerState(name: string) { return { exists: true, running: result.stdout.trim() === "true" }; } +function isPodmanContainerNotFound(stderr: string): boolean { + // Target changes are destructive only after Podman confirms absence. Treat + // connection and authorization failures as unknown so the old runtime stays registered. + return ( + /no such container/iu.test(stderr) || + /no container with name or id .* found/iu.test(stderr) || + /container .* does not exist/iu.test(stderr) + ); +} + +async function recordedPodmanContainerState(engine: SandboxContainerEngine, name: string) { + const result = await execContainer(engine, ["inspect", "-f", "{{.State.Running}}", name], { + allowFailure: true, + }); + if (result.code === 0) { + return { exists: true, running: result.stdout.trim() === "true" }; + } + if (isPodmanContainerNotFound(result.stderr)) { + return { exists: false, running: false }; + } + const detail = result.stderr.trim(); + throw Object.assign( + new Error( + detail + ? `Unable to inspect recorded Podman sandbox runtime ${name}: ${detail}` + : `Unable to inspect recorded Podman sandbox runtime ${name} (exit ${result.code})`, + ), + { code: result.code }, + ); +} + function normalizeDockerLimit(value?: string | number) { if (value === undefined || value === null) { return undefined; @@ -338,8 +346,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 +452,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 +463,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 +510,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 { - return await readDockerContainerLabel(containerName, "openclaw.configHash"); +async function readContainerConfigHash( + engine: SandboxContainerEngine, + containerName: string, +): Promise { + return await readContainerLabel(engine, containerName, "openclaw.configHash"); } type EnsureSandboxContainerParams = { + engine?: SandboxContainerEngine; + podmanTarget?: SandboxContainerEngineTarget; scopeKey: string; workspaceDir: string; agentWorkspaceDir: string; @@ -505,10 +537,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 +556,42 @@ 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; + let 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" }, + ); + } + try { + assertPodmanSandboxTarget(existingRegistryEntry.backendTarget, podmanRuntimeInfo!.target); + } catch (error) { + if (existingRegistryEntry.backendTarget.globalArgs.length === 0) { + throw error; + } + const recordedEngine = bindPodmanSandboxEngine(existingRegistryEntry.backendTarget); + const recordedState = await recordedPodmanContainerState(recordedEngine, containerName); + if (recordedState.exists) { + throw error; + } + // A removed or replaced Podman target can leave registry metadata behind. + // Drop it only after the recorded target no longer exposes the runtime. + await removeRegistryEntry(containerName); + existingRegistryEntry = null; + } + } const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({ workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, @@ -526,7 +599,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 +611,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 +647,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 +655,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 +666,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, diff --git a/src/agents/sandbox/fs-bridge.test-helpers.ts b/src/agents/sandbox/fs-bridge.test-helpers.ts index e83058d823c6..74049730d478 100644 --- a/src/agents/sandbox/fs-bridge.test-helpers.ts +++ b/src/agents/sandbox/fs-bridge.test-helpers.ts @@ -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[1], + ) => hoisted.execDockerRaw(args, opts), execDockerRaw: (args: ExecDockerArgs, opts?: Parameters[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[1], + ) => hoisted.execDockerRaw(args, opts), execDockerRaw: (args: ExecDockerArgs, opts?: Parameters[1]) => hoisted.execDockerRaw(args, opts), + validateSandboxContainerEngineTarget: vi.fn(), })); vi.doMock("./fs-bridge-path-safety.runtime.js", createPathSafetyRuntimeMock); ({ createSandboxFsBridge: createSandboxFsBridgeImpl } = await import("./fs-bridge.js")); diff --git a/src/agents/sandbox/manage.test.ts b/src/agents/sandbox/manage.test.ts index 19885f40fb8c..29bf58f81bb1 100644 --- a/src/agents/sandbox/manage.test.ts +++ b/src/agents/sandbox/manage.test.ts @@ -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 () => { diff --git a/src/agents/sandbox/podman-runtime.ts b/src/agents/sandbox/podman-runtime.ts new file mode 100644 index 000000000000..ca96a1e88896 --- /dev/null +++ b/src/agents/sandbox/podman-runtime.ts @@ -0,0 +1,414 @@ +import { isIP } from "node:net"; +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"; +const PODMAN_KEEP_ID_MAPPING_MIN_VERSION = [4, 3] as const; +const PODMAN_GPUS_MIN_VERSION = [5, 0] as const; + +export type PodmanSandboxRuntimeInfo = { + machine: boolean; + rootless: boolean; + target: SandboxContainerEngineTarget; + version: string; +}; + +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" }); +} + +function resolvePodmanKeepIdMode(user: string | undefined): string { + const normalized = user?.trim(); + if (!normalized) { + return "keep-id"; + } + const match = /^(\d+)(?::(\d+))?$/u.exec(normalized); + if (!match) { + throw invalidPodmanConfig( + `Rootless Podman sandbox user "${normalized}" must be a numeric UID or UID:GID so keep-id can preserve bind-mount ownership.`, + ); + } + const uid = match[1] ?? ""; + const gid = match[2]; + const normalizedUid = BigInt(uid).toString(); + const normalizedGid = gid === undefined ? undefined : BigInt(gid).toString(); + if (normalizedUid === "0" || normalizedGid === "0") { + throw invalidPodmanConfig( + `Rootless Podman sandbox user "${normalized}" cannot use UID or GID 0 while preserving workspace bind ownership. Bake root-required setup into the image or use rootful Podman.`, + ); + } + return normalizedGid + ? `keep-id:uid=${normalizedUid},gid=${normalizedGid}` + : `keep-id:uid=${normalizedUid}`; +} + +function assertPodmanVersionAtLeast( + version: string, + minimum: readonly [major: number, minor: number], + feature: string, +): void { + const match = /^(\d+)\.(\d+)/u.exec(version.trim()); + const actualMajor = match ? Number(match[1]) : Number.NaN; + const actualMinor = match ? Number(match[2]) : Number.NaN; + if (actualMajor > minimum[0] || (actualMajor === minimum[0] && actualMinor >= minimum[1])) { + return; + } + throw invalidPodmanConfig( + `${feature} requires Podman ${minimum.join(".")} or newer, but the active engine reports "${version || "unknown"}". Upgrade Podman or choose another sandbox backend.`, + ); +} + +async function isPodmanMachineConnection(params: { + selectedName: string; + uri: string; + identity: string; +}): Promise { + let uri: URL; + try { + uri = new URL(params.uri); + } catch { + return false; + } + const hostname = uri.hostname.replace(/^\[|\]$/gu, ""); + const loopback = + (isIP(hostname) === 4 && hostname.startsWith("127.")) || + (isIP(hostname) === 6 && hostname === "::1"); + if (uri.protocol !== "ssh:" || !loopback || !uri.port || !uri.username) { + return false; + } + const result = await execContainer( + PODMAN_SANDBOX_ENGINE, + ["machine", "list", "--format", "json"], + { + allowFailure: true, + signal: AbortSignal.timeout(SANDBOX_ENGINE_PROBE_TIMEOUT_MS), + }, + ); + if (result.code !== 0) { + return false; + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return false; + } + if (!Array.isArray(parsed)) { + return false; + } + const selectedIdentity = params.identity ? path.resolve(params.identity) : ""; + return parsed.some((entry) => { + if (typeof entry !== "object" || entry === null) { + return false; + } + const machine = entry as Record; + const machineName = typeof machine.Name === "string" ? machine.Name : ""; + const nameMatches = + !params.selectedName || + params.selectedName === machineName || + params.selectedName === `${machineName}-root`; + const machinePort = + typeof machine.Port === "string" || typeof machine.Port === "number" + ? String(machine.Port) + : ""; + const portMatches = machinePort === uri.port; + const connectionUser = decodeURIComponent(uri.username); + const rootConnection = params.selectedName === `${machineName}-root`; + const userMatches = + typeof machine.RemoteUsername === "string" && + (connectionUser === machine.RemoteUsername || (rootConnection && connectionUser === "root")); + const machineIdentity = + typeof machine.IdentityPath === "string" && machine.IdentityPath + ? path.resolve(machine.IdentityPath) + : ""; + const identityMatches = + !selectedIdentity || !machineIdentity || selectedIdentity === machineIdentity; + return machine.Running === true && nameMatches && portMatches && userMatches && identityMatches; + }); +} + +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 => typeof entry === "object" && entry !== null, + ) + : []; + const configuredUri = process.env.CONTAINER_HOST?.trim(); + const configuredName = process.env.CONTAINER_CONNECTION?.trim(); + let selected: Record | 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://")) { + const identity = + process.env.CONTAINER_SSHKEY?.trim() || + (typeof selected?.Identity === "string" ? selected.Identity : ""); + if ( + await isPodmanMachineConnection({ + selectedName: typeof selected?.Name === "string" ? selected.Name : "", + uri: selectedUri, + 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 { + const result = await execContainer( + PODMAN_SANDBOX_ENGINE, + [ + "info", + "--format", + "{{.Host.Security.Rootless}}\t{{.Host.ServiceIsRemote}}\t{{.Host.RemoteSocket.Path}}\t{{.Version.Version}}", + ], + { + 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 = "", version = ""] = result.stdout + .trim() + .split("\t", 4); + let machine = false; + let target: SandboxContainerEngineTarget = { key: "local", globalArgs: [] }; + if (serviceIsRemote === "true") { + ({ machine, target } = await assertSupportedPodmanConnection(remoteSocketPath)); + } + return { machine, rootless: rootless === "true", target, version }; +} + +export async function validateSandboxContainerEngineTarget( + engine: SandboxContainerEngine, + expectedTarget?: SandboxContainerEngineTarget, +): Promise { + 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([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); + } + if (params.cfg.gpus?.trim()) { + assertPodmanVersionAtLeast( + params.runtimeInfo.version, + PODMAN_GPUS_MIN_VERSION, + "Podman sandbox GPU passthrough", + ); + } + + const extraCreateArgs = ["--http-proxy=false"]; + if (params.cfg.readOnlyRoot) { + extraCreateArgs.push("--read-only-tmpfs=true"); + } + if (params.runtimeInfo.rootless) { + if (params.cfg.user?.trim()) { + assertPodmanVersionAtLeast( + params.runtimeInfo.version, + PODMAN_KEEP_ID_MAPPING_MIN_VERSION, + "Rootless Podman sandbox user mapping", + ); + } + // Map the invoking engine user to the selected container identity. Plain + // keep-id is insufficient when docker.user differs from the host UID/GID. + extraCreateArgs.push("--userns", resolvePodmanKeepIdMode(params.cfg.user)); + } + 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-v9:${userMode}:${params.dockerTmpfsSource}`; +} + +export function resolvePodmanSandboxContainerPrefix(containerPrefix: string): string { + return `${containerPrefix}podman-`; +} diff --git a/src/agents/sandbox/prune.test.ts b/src/agents/sandbox/prune.test.ts index b92aae3b4d58..9fb5926b007f 100644 --- a/src/agents/sandbox/prune.test.ts +++ b/src/agents/sandbox/prune.test.ts @@ -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-", diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 74a0e2ee1b8d..19208d7761a7 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -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({ diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 2d1371cb365c..cd3bb1685ed7 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -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, diff --git a/src/agents/sandbox/sanitize-env-vars.ts b/src/agents/sandbox/sanitize-env-vars.ts index cb6fb081e0df..f6419e65f4a8 100644 --- a/src/agents/sandbox/sanitize-env-vars.ts +++ b/src/agents/sandbox/sanitize-env-vars.ts @@ -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 = [ /^ANTHROPIC_API_KEY$/i, @@ -55,6 +56,33 @@ type EnvSanitizationOptions = { const MAX_ENV_VAR_VALUE_BYTES = 32768; +function envRecordsEqual(left: Record, right: Record): 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 | 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")) { diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts index 3878fb421ed1..41e78dd4279c 100644 --- a/src/agents/sandbox/ssh-backend.test.ts +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -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-", diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index e3989b406d79..b0d307f9f17c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -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; diff --git a/src/agents/sandbox/workspace-authority.test.ts b/src/agents/sandbox/workspace-authority.test.ts index 56bb63194861..0b40aeb99b37 100644 --- a/src/agents/sandbox/workspace-authority.test.ts +++ b/src/agents/sandbox/workspace-authority.test.ts @@ -34,9 +34,9 @@ function createSessionStorePath(prefix: string): string { } describe("resolveSandboxWorkspaceAuthority", () => { - it("attests a writable Docker workspace", () => { + it.each(["docker", "Podman"])("attests a writable %s workspace", (backend) => { const result = resolveSandboxWorkspaceAuthority({ - config: configWithSandbox({ mode: "all", workspaceAccess: "rw" }), + config: configWithSandbox({ mode: "all", backend, workspaceAccess: "rw" }), agentId: "main", sessionKey: "agent:main:subagent:workboard-card", }); diff --git a/src/agents/sandbox/workspace-authority.ts b/src/agents/sandbox/workspace-authority.ts index 1fc785244220..cb6fc6ce078f 100644 --- a/src/agents/sandbox/workspace-authority.ts +++ b/src/agents/sandbox/workspace-authority.ts @@ -157,8 +157,9 @@ export function resolveSandboxWorkspaceAuthority(params: { if (!runtime.sandboxed) { return { sandboxed: false, workspaceAccess: sandbox.workspaceAccess }; } + const backend = sandbox.backend.trim().toLowerCase(); let confinementError: string | undefined; - if (sandbox.backend !== "docker") { + if (backend !== "docker" && 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."; diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 77b715eaf0e4..ac0898e4cefd 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -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 { +async function isContainerEngineAvailable(command: "docker" | "podman"): Promise { 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 { +async function noteCodexBwrapNamespaceWarning( + cfg: OpenClawConfig, + engineName: "Docker" | "Podman", +): Promise { const probe = await probeCodexBwrapNamespaces(cfg); if (probe.ok) { return; @@ -156,10 +168,10 @@ async function noteCodexBwrapNamespaceWarning(cfg: OpenClawConfig): Promise { +async function containerImageExists(command: "docker" | "podman", image: string): Promise { 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) { diff --git a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts index 4e007c04ff38..5c96d4893881 100644 --- a/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts +++ b/src/commands/doctor-sandbox.warns-sandbox-enabled-without-docker.test.ts @@ -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[]) => { diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 2797c4e94a09..e5ecd8a80a1c 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -135,44 +135,49 @@ describe("sandbox explain command", () => { }); }); - it("reports the effective rw workspace and Docker mount without changing workspaceRoot", async () => { - mockCfg = { - agents: { - defaults: { - sandbox: { - mode: "all", - scope: "agent", - workspaceAccess: "rw", - workspaceRoot: "/tmp/openclaw-sandboxes", + it.each(["docker", "podman"])( + "reports the effective rw workspace and %s mount without changing workspaceRoot", + async (backend) => { + mockCfg = { + agents: { + defaults: { + sandbox: { + mode: "all", + backend, + scope: "agent", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + }, }, + list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], }, - list: [{ id: "builder", workspace: "/tmp/openclaw-agent-workspace" }], - }, - session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, - }; + session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" }, + }; - const logs: string[] = []; - await sandboxExplainCommand({ json: true, agent: "builder" }, { - log: (msg: string) => logs.push(msg), - error: (msg: string) => logs.push(msg), - exit: (_code: number) => {}, - } as unknown as Parameters[1]); + const logs: string[] = []; + await sandboxExplainCommand({ json: true, agent: "builder" }, { + log: (msg: string) => logs.push(msg), + error: (msg: string) => logs.push(msg), + exit: (_code: number) => {}, + } as unknown as Parameters[1]); - const parsed = JSON.parse(logs.join("")); - const agentWorkspace = path.resolve("/tmp/openclaw-agent-workspace"); - expect(parsed.sandbox.workspaceRoot).toBe("/tmp/openclaw-sandboxes"); - expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe(agentWorkspace); - expect(parsed.sandbox.runtimeWorkdir).toBe("/workspace"); - expect(parsed.sandbox.workspaceSource).toBe("agent"); - expect(parsed.sandbox.workspaceMounts).toEqual([ - { - hostRoot: agentWorkspace, - containerRoot: "/workspace", - writable: true, - source: "workspace", - }, - ]); - }); + const parsed = JSON.parse(logs.join("")); + const agentWorkspace = path.resolve("/tmp/openclaw-agent-workspace"); + expect(parsed.sandbox.backend).toBe(backend); + expect(parsed.sandbox.workspaceRoot).toBe("/tmp/openclaw-sandboxes"); + expect(parsed.sandbox.effectiveHostWorkspaceRoot).toBe(agentWorkspace); + expect(parsed.sandbox.runtimeWorkdir).toBe("/workspace"); + expect(parsed.sandbox.workspaceSource).toBe("agent"); + expect(parsed.sandbox.workspaceMounts).toEqual([ + { + hostRoot: agentWorkspace, + containerRoot: "/workspace", + writable: true, + source: "workspace", + }, + ]); + }, + ); it("uses the canonical derived workspace for non-default agents", async () => { mockCfg = { diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 9519e9d5544a..3f09c7a33ad5 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -221,8 +221,10 @@ export async function sandboxExplainCommand( : workspaceLayout.agentWorkspaceDir; const runtimeWorkdir = sessionIsSandboxed ? sandboxWorkdir : directRuntimeCwd; const workspaceSource = sessionIsSandboxed ? workspaceLayout.workspaceSource : "direct"; + const usesLocalContainerMounts = + sandboxCfg.backend.toLowerCase() === "docker" || sandboxCfg.backend.toLowerCase() === "podman"; const workspaceMounts = - sessionIsSandboxed && sandboxCfg.backend === "docker" && sandboxWorkdir + sessionIsSandboxed && usesLocalContainerMounts && sandboxWorkdir ? buildSandboxFsMounts({ workspaceDir: workspaceLayout.workspaceDir, agentWorkspaceDir: workspaceLayout.agentWorkspaceDir,